The Hidden Danger of Shared State in Angular Unit Tests
Recently, while working on a legacy Angular project, I encountered an interesting — and admittedly a little frustrating — problem. It reinforced an important lesson about writing truly independent unit tests, and I thought it would be valuable to share the experience.
We all know one of the core principles of unit testing:
Every test should be independent. The outcome of a test should never depend on the execution order of other tests.
But what happens when this rule is accidentally broken?
The Symptom: Passing Tests... Until You Change the Order
At first, everything seemed fine. All the Jasmine unit tests were passing — green across the board. However, based on the behavior I was observing, curiosity led me to change the order of the tests within the spec file.
To my surprise, some tests started failing. Even stranger, when I restored the original order, they would pass again!
This was a huge red flag. A reliable test suite should behave the same way, regardless of the order in which tests are executed.
The Root Cause: A Shared Mutable Object
After some investigation, I discovered the issue:
Essentially, tests were leaking into each other, causing unpredictable behavior depending on the test execution order.
Here’s a simplified example of what I found:
// shared object defined outside
const sharedData = { value: 0 };
describe('Test Suite A', () => {
it('should set value to 1', () => {
sharedData.value = 1;
expect(sharedData.value).toBe(1);
});
});
describe('Test Suite B', () => {
it('should have value 0 initially', () => {
expect(sharedData.value).toBe(0); // This fails if Test Suite A ran before
});
});
In this example, if Test Suite A runs first, it modifies sharedData.value to 1. Then, when Test Suite B runs, it incorrectly finds sharedData.value already changed, resulting in a test failure.
This explains why the results changed based on the order of execution.
The Solution: Reinitialize State with beforeEach
The fix was simple but crucial: move the object initialization inside a beforeEach block, ensuring each test gets a fresh copy of the object.
Here’s the corrected version:
let sharedData: { value: number };
beforeEach(() => {
sharedData = { value: 0 }; // re-initialize before every test
});
describe('Test Suite A', () => {
it('should set value to 1', () => {
sharedData.value = 1;
expect(sharedData.value).toBe(1);
});
});
describe('Test Suite B', () => {
it('should have value 0 initially', () => {
expect(sharedData.value).toBe(0); // Now this test will always pass
});
});
Now, no matter the order in which the tests are executed, they are completely isolated and independent.
Key Takeaways