What makes one test affect another?

Tests become dependent when one leaves something behind for the next. A changed module-level variable, an event listener that remains attached, or a timer that fires later can make a test pass alone but fail in a suite. These failures can be difficult to diagnose because the test reporting the error may not be the one that caused it.

A useful check is to run a test by itself and then with its neighbors. If the outcome changes, look for state that survives between tests. Resetting that state at the start of each test establishes a known starting point; cleaning up afterward prevents work from escaping into the next test. Both steps matter, but neither is a substitute for giving each test its own state when that is possible.

Reset shared values before each test

The following example runs with Node.js 20 or newer and its built-in test runner. Save the code as shared-state.test.mjs, then run node --test shared-state.test.mjs. It uses no packages or browser globals. The module-level preference and event bus stand in for shared application state.

import { beforeEach, afterEach, test } from 'node:test'; import assert from 'node:assert/strict'; import { EventEmitter } from 'node:events'; const bus = new EventEmitter(); let preference = 'light'; const cleanups = []; function subscribe(handler) { bus.on('change', handler); return () => bus.off('change', handler); } beforeEach(() => { preference = 'light'; }); afterEach(() => { for (const cleanup of cleanups.splice(0)) cleanup(); assert.equal(bus.listenerCount('change'), 0); }); test('reports a changed preference', () => { const received = []; cleanups.push(subscribe(value => received.push(value))); preference = 'dark'; bus.emit('change', preference); assert.deepEqual(received, ['dark']); }); test('starts with the default preference', () => { assert.equal(preference, 'light'); assert.equal(bus.listenerCount('change'), 0); });

The beforeEach hook sets the preference to its documented default before either test runs. That makes each test's starting value explicit rather than relying on another test to leave it unchanged. Reset every relevant part of a shared object, not just the property that caused the most recent failure. If a reset function becomes complicated, that is often a sign that the code would be easier to test with a fresh object per test.

Clean up resources even when an assertion fails

The first test subscribes to a shared event bus. Its unsubscribe function goes into cleanups, and afterEach calls it. Keeping cleanup in the hook means it is still attempted if an assertion in the test fails. The listener-count assertion then makes an untracked listener visible instead of letting it silently affect a later test.

Apply the same pattern to other resources a test creates. Keep a reference to a timer so cleanup can call clearTimeout or clearInterval. Disconnect an observer, remove a DOM event listener, restore a stubbed function, and close an opened connection when the test is finished. If cleanup itself can throw, consider how to attempt the remaining cleanup actions; one failure should not needlessly leave every other resource active.

Avoid making a test's final assertion its only cleanup point. For example, removing a listener after an assertion in the test body will not happen if that assertion throws. Register the cleanup as soon as the resource is created, or use a try/finally block when ownership is local to one test.

Prefer fresh state when tests may run concurrently

Hooks make shared fixtures manageable, but they do not make concurrent access safe. If two tests use the same mutable preference or bus at the same time, one test can reset or clean up state while the other still needs it. Do not enable concurrent execution for tests that rely on a shared mutable fixture unless that fixture is isolated for each test.

A stronger approach is to construct a new preference store and event bus inside each test, or in a per-test setup hook, and pass them to the code under test. Then one test cannot alter another test's starting values or remove its listeners. Use shared fixtures only when the cost and behavior are understood, and document what must be reset.

When a suite behaves differently depending on test order, resist fixing it by prescribing an order. Identify who owns each value or resource, establish a known starting state, and make cleanup visible. Tests that manage their own surroundings are easier to run alone, debug, and extend.

Back to category ↗