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.

What resetHandlers does not touch A central handler reset is shown restoring the default handler array. Five surrounding stores are marked as untouched by it: the in-memory record store, the scenario position, the attempt counter, browser storage written by the mock, and the client-side query cache. Each is annotated with the symptom it produces when it leaks into the next test. resetHandlers() restores the default array — and nothing else record store (Map) next test sees orders it did not create scenario position a flow starts half-way through attempt counter the failure never fires a second time query cache / storage the UI renders without a request at all dashed lines mean "not reset by"

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.

The reset points around one test A timeline for a single test. Before the test body, the mock state is reset and a fresh query client is created. The test body then runs, registering handler overrides and mutating the store. After the body, the same reset runs again. An annotation shows that a test throwing mid-body still reaches the after-reset, so the next test starts clean either way. beforeEach resetMockState + fresh client the test body server.use overrides · store mutations · counters advance afterEach resetMockState again a thrown error skips the rest of the body — but not afterEach The before-reset is the guarantee a reader relies on; the after-reset is what contains a failing test. Only one of the two is enough right up until the first test that throws.

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() inside beforeAll is wiped by the first resetHandlers. 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 in beforeEach, 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: false for 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 Started but 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.


Adding new state safely Three rules for any new piece of mock state: it is registered in the single reset function on the day it is added, it is keyed per worker if parallel specs can reach it, and it has an assertion proving it resets. Skipping the third means the reset silently stops covering it later. Register it in the one reset on the day it is added never in an individual setup file Key it per worker if parallel specs can reach it module globals are shared per process Assert that it resets one spec that would fail without it or the reset silently regresses The third rule is what stops the reset function drifting out of date as state accumulates.

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.

← Back to Stateful Scenario Sequences