Resetting Scenario State Between Tests
Your suite passes when run whole and fails when you run one file. Or it passes locally and fails on CI. Or it passed until someone added a spec at the top of the file. All three are the same bug: mock state surviving from one test into the next. This page enumerates what has to be reset, and proves the reset works.
Context: handlers and state are different things
server.resetHandlers() restores the default handler array. It does nothing to the values those handlers have been mutating, and that distinction is the source of most leakage.
A stateful mock, of the kind modeling CRUD state in a mock server describes, keeps records in a module-level Map. A scenario keeps a position. A fault injector keeps an attempt counter. A browser-based mock may have written to localStorage. Every one of those survives a handler reset, and every one can change what the next test sees.
There is a second, subtler category: state the mock caused but does not own. A component that cached a response, a query client holding stale data, a service worker registration from a previous spec. Those need resetting too, and they are easy to forget because they live outside the mock.
Solution
1. One reset function, one list
// src/mocks/reset.ts
import { server } from './server';
import { resetStore } from './store';
import { resetScenarios } from './scenarios';
import { resetAttempts } from './attempt-log';
import { setFault } from './faults';
/**
* Everything mutable the mock layer owns. Adding new state means adding it
* HERE — never in an individual setup file, or the next setup file forgets.
*/
export function resetMockState(): void {
server.resetHandlers(); // handler overrides registered with server.use()
resetStore(); // records created or mutated during the test
resetScenarios(); // named scenario positions back to their initial state
resetAttempts(); // retry/fault counters
setFault(null); // any process-wide fault selection
}
Centralising the list is the whole design. The alternative — each setup file resetting the two or three things its author remembered — degrades every time someone adds state, and the resulting failures appear in unrelated specs.
2. Reset on both sides of every test
// vitest.setup.ts
import { afterAll, afterEach, beforeAll, beforeEach } from 'vitest';
import { server } from './src/mocks/server';
import { resetMockState } from './src/mocks/reset';
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
// Before: this test does not inherit anything.
beforeEach(() => resetMockState());
// After: a failing test cannot poison its neighbour, and watch mode starts clean.
afterEach(() => resetMockState());
afterAll(() => server.close());
Resetting on both sides looks redundant and is not. beforeEach gives the guarantee you rely on when reading a test; afterEach contains the damage when a test throws mid-way and never reaches its own cleanup.
3. Reset the client-side state too
The mock is only half of it. A query cache that answered from memory means the handler never ran, and the test asserts on data the previous spec fetched:
// src/test/render.tsx
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { render as rtlRender } from '@testing-library/react';
import type { ReactElement } from 'react';
export function render(ui: ReactElement) {
// A FRESH client per render — a shared one carries cache across tests.
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false, gcTime: 0, staleTime: 0 } },
});
const result = rtlRender(<QueryClientProvider client={queryClient}>{ui}</QueryClientProvider>);
return { ...result, queryClient };
}
retry: false matters as much as the fresh client: a query client that retries on its own will quietly turn a single injected failure into three requests and break any attempt-count assertion.
For browser-level suites, clear storage as part of the per-spec fixture rather than trusting the context:
// e2e/fixtures.ts
test.beforeEach(async ({ page }) => {
await page.addInitScript(() => {
localStorage.clear();
sessionStorage.clear();
});
});
addInitScript runs before the page’s own scripts, which is the only point where clearing is guaranteed to precede whatever the application writes on boot.
Verification
Order-dependent leakage is invisible in a stable sequence, so the verification has to disturb the order:
# Randomised order — a leak surfaces as a different failure each run
npx vitest run --sequence.shuffle --reporter=basic
# Each spec repeated: catches state that accumulates rather than leaks once
npx vitest run --repeats=3 --reporter=basic
# Single file in isolation must pass exactly as it does in the whole suite
npx vitest run src/features/orders/create.test.ts
Run the shuffle in CI on a schedule rather than on every push — it is the check that finds leakage nobody knew about, and it needs to run often enough to catch a regression while being tolerant of the occasional unrelated flake it exposes.
Gotchas and edge cases
-
server.use()insidebeforeAllis wiped by the firstresetHandlers. Overrides registered once for a whole file disappear the moment the per-test reset runs, and the symptom is that the first test passes and every later one fails. Register file-wide overrides inbeforeEach, after the reset, or make them part of the default handler set. -
Module-level state survives between files only sometimes. Vitest isolates modules per file by default, so a store leak may be invisible until someone sets
isolate: falsefor speed. Do not rely on module isolation as the reset mechanism — reset explicitly, so the suite’s correctness does not depend on a performance setting. -
Resetting a scenario is not the same as resetting the data. WireMock’s scenario reset returns the state machine to
Startedbut leaves any records created through the admin API. If your flow both advances a scenario and creates data, both need clearing, and the WireMock reset endpoints differ in exactly which they touch.
Isolation as a property of the suite, not of each test
It is tempting to treat isolation as something each test arranges for itself. That framing does not scale, because it puts the obligation on the person least likely to know what state exists — whoever is writing the newest spec.
The alternative is to treat isolation as a property the suite guarantees. Every spec starts from a defined state because the harness put it there, not because the spec remembered to. Three consequences follow.
Specs get shorter. A spec that does not have to arrange or clean up state contains only the thing it is testing, which makes it both faster to write and clearer to read when it fails.
New state is a harness change. Adding a store, a counter or a cache means updating one reset function, and that update is visible in review as a change to shared infrastructure rather than buried in a feature branch.
The guarantee is testable. A suite that claims isolation can be checked — shuffle the order, repeat each spec, run one file alone. A suite where each test arranges its own cannot be checked at all, because there is no single claim to verify.
The failure mode of the per-spec approach is characteristic. Everything works while the suite is small. Somebody adds a store. A few specs clean it up, most do not, and the ones that do not pass because of the order they happen to run in. Six months later the suite is order-dependent in a way nobody can untangle, and the standard remedy — running it serially — hides the problem while doubling the runtime.
Establishing the harness-level guarantee early costs almost nothing. Retrofitting it onto a suite that has grown around the assumption costs weeks, which is the strongest argument for doing it on day one.
Related
- Modeling CRUD State in a Mock Server — the store this reset clears
- Simulating Multi-Step Checkout Flows — scenarios whose positions must be rewound
- Resetting Mock State Between Test Runs — the same problem at the pipeline level
← Back to Stateful Scenario Sequences