Stop Sharing State Between Your Tests
Cover photo by ThisisEngineering on Unsplash
Shared state in tests is a slow-motion disaster that eventually eats your entire delivery pipeline.
Why shared state is a trap
I have seen too many test suites fail because one test modified a database row that another test expected to be pristine. It is the easiest way to lose trust in your automation. If your tests depend on order or shared memory, you are not testing your application code. You are testing your ability to manage side effects in your test harness.
Keep your tests independent
Each test should set up its own data, perform its action, and clean up. This might feel like extra boilerplate (and, honestly, it kind of is), but the payoff is a suite that stays green even when you run tests in parallel. Parallelization is the single biggest win for developer productivity.
// Bad: Reliance on global statelet user;beforeAll(() => { user = createUser(); });
test('updates name', () => { user.name = 'New'; });test('checks name', () => { expect(user.name).toBe('Old'); });// Good: Isolated setuptest('updates name', async () => { const user = await setupUser(); await updateName(user.id, 'New'); expect(await getUser(user.id)).toBe('New');});What about database overhead?
“But hitting the database for every test makes the suite run way too slow!” I hear this a lot. It is a fair point. If your integration tests take twenty minutes to run, you will stop running them locally. I am not sure this holds for extremely massive architectures where spinning up a container is truly expensive, but for most web apps, the bottleneck is usually poorly written queries rather than the overhead of creating a new row.
If you find your tests are hitting the database way too often, try mocking the external network requests instead of the database itself. Databases are fast if you keep them local and clean.
Managing side effects
Sometimes isolation is hard. You might have singleton services or caches that persist across your process. If you can, inject these dependencies. If you cannot (because the code is legacy and you cannot change it), use a reset function that clears the state explicitly between tests.
Should every test be a unit test?
No. I have a bias toward integration tests because they give me more confidence, but that confidence is worthless if the tests are flaky. Flakiness is almost always a symptom of leaking state. If you find yourself writing code just to make your test suite pass, take a step back and look at your architecture. Maybe the problem is not the test; maybe the code is too coupled to the environment.
Finding the middle ground
There is a balance. I do not run a full database reset between every single unit test, because unit tests should not be hitting the database at all. If your unit tests need a database, you are doing it wrong (or you are actually writing integration tests). Keep your unit tests pure. For integration tests, prioritize speed by using transaction rollbacks or ephemeral containers.
Is there a scenario where shared state is acceptable? Perhaps when running expensive heavy-load tests where you need to maintain a warm cache for a specific benchmark. Even then, I would isolate that process into its own separate job outside of the standard test suite. Keep the main pipeline fast and isolated.
How do you handle your test teardown logic without making it too complex to read?