Refactor remaining composables used for global state management to use Pinia stores to resolve potential flaky acceptance tests caused by shared module-level state #1905
Is your improvement related to a problem? Please describe.
Our current Vue.js application has some remaining composables that maintain state at the module level. While this approach simplifies state sharing across components, it introduces significant issues in our acceptance tests conducted with Vitest and JSDOM. Specifically, the shared module-level state could lead to flaky tests due to state persistence between test runs.
JavaScript modules are singletons, meaning the module-level state in our composables persists across different test cases.
Tests inadvertently affect each other's outcomes because they operate on the same shared state, leading to unpredictable results.
Flaky Tests:
Tests may pass or fail inconsistently depending on the order of execution and the residual state left by previous tests.
This flakiness undermines the reliability of our test suite and complicates continuous integration efforts.
Testing Challenges with Vitest and JSDOM:
Vitest and JSDOM do not automatically isolate module-level state between tests.
Workarounds to reset the state between tests are non-trivial and can add unnecessary complexity to the testing setup.
Impact
Reduced Confidence in Test Results:
Flaky tests make it difficult to discern genuine issues from false negatives or positives.
Developers may spend excessive time debugging tests that fail due to shared state rather than actual code defects.
Slower Development Cycle:
Time spent troubleshooting flaky tests slows down the development process.
Potential for real bugs to be overlooked if tests are assumed to be unreliable.
Describe the suggested solution
Refactor our composables to use Pinia stores instead of maintaining state at the module level. Pinia is the recommended state management library for Vue.js that offers reactive and typesafe stores. By using Pinia:
Isolated State Per Test Run:
Each test can instantiate a fresh Pinia store, ensuring complete isolation of state between tests.
Eliminates side effects caused by shared state in module-level variables.
Improved Test Reliability:
Tests become deterministic and reliable, enhancing confidence in test outcomes.
Simplifies the testing process by removing the need for complex state reset mechanisms.
Enhanced Codebase Maintainability:
Adopting Pinia aligns with Vue's recommended state management practices.
Provides a more scalable and maintainable approach as the application grows.
Many, if not all, of the "composables" in the Frontend\src\composables folder can be either moved to Pinia stores or refactored into general functions, with their names adjusted to not start with "use".
With testability in mind, composables should primarily only be used for organizing shared functionality and logic, rather than for global state management.
Advantages of Using Pinia
State Isolation:
Each store instance is scoped to the app or test instance, preventing unintended state sharing.
Improved Developer Experience:
Pinia integrates seamlessly with Vue DevTools for easier debugging.
Provides a clean and intuitive API for state management.
Scalability:
As the application grows, Pinia's structured approach to state management will facilitate maintenance and scalability.
Describe alternatives you've considered
Do not use JSDOM for acceptance testing and do not directly instantiate the app, instead use a headless browser. However, the idea behind using a virtual DOM (JSDOM) instead of a real browser is to have acceptance tests that run way faster than tests that require spinning a whole headless browser
Refactor Composables to Avoid Module-Level State
Refactor your composables so that they don't rely on module-level state. Instead, initialize state within functions or use a factory pattern. However this creates state everytime the useComposable function is invoked, defeating the purpose of having state that is global.
Before (Module-Level State in Composable):
// myComposable.js
import { ref } from 'vue';
// Module-level state (shared across imports)
const globalState = ref(0);
export function useMyComposable() {
return {
globalState,
};
}
New state instance per call (not useful for state that is meant to be shared across components)
// myComposable.js
import { ref } from 'vue';
export function useMyComposable() {
const state = ref(0);
return {
state,
};
}
Describe the suggested improvement
Is your improvement related to a problem? Please describe.
Our current Vue.js application has some remaining composables that maintain state at the module level. While this approach simplifies state sharing across components, it introduces significant issues in our acceptance tests conducted with Vitest and JSDOM. Specifically, the shared module-level state could lead to flaky tests due to state persistence between test runs.
NOTE: The application already uses Pinia stores. See https://github.com/Particular/ServicePulse/tree/master/src/Frontend/src/stores. This issue is about completing the migration to Pinia but emphasizes the importance of this work to help with automated testing efforts.
Testing challenges when using module-level state
Shared State Across Tests:
Flaky Tests:
Testing Challenges with Vitest and JSDOM:
Impact
Reduced Confidence in Test Results:
Slower Development Cycle:
Describe the suggested solution
Refactor our composables to use Pinia stores instead of maintaining state at the module level. Pinia is the recommended state management library for Vue.js that offers reactive and typesafe stores. By using Pinia:
Isolated State Per Test Run:
Improved Test Reliability:
Enhanced Codebase Maintainability:
Many, if not all, of the "composables" in the
Frontend\src\composables
folder can be either moved to Pinia stores or refactored into general functions, with their names adjusted to not start with "use".With testability in mind, composables should primarily only be used for organizing shared functionality and logic, rather than for global state management.
Advantages of Using Pinia
State Isolation:
Improved Developer Experience:
Scalability:
Describe alternatives you've considered
Refactor your composables so that they don't rely on module-level state. Instead, initialize state within functions or use a factory pattern. However this creates state everytime the useComposable function is invoked, defeating the purpose of having state that is global.
Before (Module-Level State in Composable):
New state instance per call (not useful for state that is meant to be shared across components)
Additional Context
Main offenders that urge this refactoring: