Resetting Mock State Between Test Runs

A test creates a record, a later test in another shard counts records and asserts “one”, and the count is two — because the first test’s write survived into the second. Any mock that remembers what a test did to it will leak that state across test boundaries unless you reset it at a deliberate point, and in parallel CI the leak is intermittent enough to look like random flake.

Why state bleeds and where to draw the boundary

Mocks accumulate state in three places, each needing its own reset:

  • Scenario progress. A stateful WireMock scenario advances through states as requests arrive; the next test inherits wherever the last one left the scenario.
  • Runtime overrides. MSW’s server.use() and WireMock’s dynamically-posted stubs add handlers on top of the defaults; without a reset they persist into unrelated tests.
  • Mutated data. A mock that models CRUD keeps created, updated, and deleted records in memory or a database; those mutations outlive the test that made them.

The cure is to reset at a known boundary — an afterEach for per-test isolation, or a between-shards step for parallel CI runs. This is a concrete application of mock lifecycle management, and it pairs with the stateful scenario sequences that create the state in the first place.

Four reset levels Four levels. Clearing the request journal costs nothing and clears evidence only. Resetting mappings reloads definitions and rewinds scenarios. Clearing the data store removes records created during the run. Recreating the container guarantees everything but costs a full startup. Level Clears Cost journal reset recorded requests only milliseconds mappings reset definitions and scenario positions milliseconds store clear records created during the run milliseconds container recreate everything, guaranteed a full startup The first three combined are almost always enough, and are three orders of magnitude cheaper than the fourth.

Solution

1. Reset a WireMock mock over the admin API

WireMock exposes two reset endpoints. Scenario reset rewinds every scenario to its start state; mapping reset discards stubs added at runtime and restores the ones loaded from disk:

# scripts/reset-wiremock.sh
#!/usr/bin/env bash
set -euo pipefail
BASE="${MOCK_BASE_URL:-http://localhost:8080}"

# Rewind all stateful scenarios to Started.
curl -fsS -X POST "${BASE}/__admin/scenarios/reset" > /dev/null

# Drop runtime stubs; keep the on-disk mappings.
curl -fsS -X POST "${BASE}/__admin/mappings/reset" > /dev/null

# Clear the recorded request journal so verifications start clean.
curl -fsS -X DELETE "${BASE}/__admin/requests" > /dev/null

echo "WireMock reset: scenarios, mappings, and request journal"

Call it between shards in CI, or from a test hook if your runner can shell out. The DELETE /__admin/requests line matters when tests assert on how many times an endpoint was called — a stale journal inflates the count.

2. Reset MSW handlers and an in-memory store

For in-process MSW, resetHandlers() removes runtime server.use() overrides. If your handlers back onto an in-memory store, clear that too — resetHandlers does not touch data your handlers mutate:

// mocks/store.ts
export interface Todo { id: string; title: string; done: boolean; }

const seed = (): Todo[] => [
  { id: '1', title: 'Write tests', done: false },
];

export let todos: Todo[] = seed();

/** Restore the store to its seeded baseline. */
export function resetStore(): void {
  todos = seed();
}
// vitest.setup.ts
import { afterEach, afterAll, beforeAll } from 'vitest';
import { setupServer } from 'msw/node';
import { handlers } from './mocks/handlers';
import { resetStore } from './mocks/store';

export const server = setupServer(...handlers);

beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));

afterEach(() => {
  server.resetHandlers();  // drop per-test overrides
  resetStore();            // drop per-test data mutations
});

afterAll(() => server.close());

Resetting both in the same afterEach guarantees each test starts from the seeded baseline regardless of what its predecessor did. Modelling that store correctly is covered in modeling CRUD state in a mock server.

3. Truncate and reseed a database-backed mock

When the mock persists to a real database (Postgres, SQLite) for fidelity, an in-memory reset is not enough — you must clear the rows and reseed:

// scripts/reset-db-mock.ts
import { Client } from 'pg';

const client = new Client({ connectionString: process.env.MOCK_DB_URL });

async function reset(): Promise<void> {
  await client.connect();
  // RESTART IDENTITY resets serial sequences so ids are reproducible.
  await client.query('TRUNCATE users, orders RESTART IDENTITY CASCADE');
  await client.query(
    `INSERT INTO users (email, name) VALUES ($1, $2)`,
    ['[email protected]', 'Seed User'],
  );
  await client.end();
  console.log('DB-backed mock reset and reseeded');
}

reset().catch((err) => { console.error(err); process.exit(1); });

RESTART IDENTITY is the detail that makes the reset reproducible — without it, auto-increment ids keep climbing and a test asserting id === 1 fails on the second run.

How to prove a reset actually works Four runs that together prove isolation: the suite in its normal order, the suite in a shuffled order, each spec repeated, and one file run alone. Any difference between the four localises the leak to a specific pair of specs. Four cheap runs, and the pattern of which ones fail names the kind of leak. Normal order the baseline should pass Shuffled order exposes order dependence different failures each run Repeated specs exposes accumulation fails on the second repeat One file alone exposes cross-file leakage passes alone, fails together A suite that only ever runs in one order has not demonstrated isolation — it has demonstrated one ordering.

Verification

Prove the reset works by mutating, resetting, and re-reading in one line:

curl -fsS -X POST "${MOCK_BASE_URL}/api/todos" -d '{"title":"temp"}' -H 'content-type: application/json' && \
bash scripts/reset-wiremock.sh && \
curl -fsS "${MOCK_BASE_URL}/api/todos" | jq 'length'

Expected output is the seeded count (for example 1), not 2 — confirming the created record did not survive the reset.

Gotchas and edge cases

  • resetHandlers() does not reset data. It only removes handler overrides added with server.use(). If a handler mutates a module-level array or map, that data persists until you clear it explicitly — which is why the afterEach above calls both resetHandlers() and resetStore().
  • Reset order matters for scenarios plus data. Reset scenarios before reseeding data, not after. Reseeding first and then resetting scenarios can leave a scenario pointing at a state that expects records the reseed just replaced, producing a mismatched first response.
  • A shared mock across parallel shards needs coarser reset points. Per-test afterEach reset is unsafe when several shards hit one instance, because one shard’s reset wipes another shard’s in-flight state. Either give each shard its own instance (the per-PR stack approach) or reset only at shard boundaries, never mid-test.

The state nobody remembers to reset Three overlooked stores. Browser storage written by the application rather than by the mock, a client-side query cache that answers without a request at all, and a filesystem fixture the run wrote back to. None of them belong to the mock, and all of them change what the next test sees. Browser storage written by the app, not the mock clear it before navigation, not after Client query cache answers without a request at all a fresh client per render Written-back fixture files json-server and recorders do this mount read-only, or pass --no-save Each one makes the mock look broken when the mock is fine, which is why they cost so much debugging time.

Where reset belongs in a pipeline, not just in a suite

Everything above is about resetting between tests. There is a second, coarser level — between runs — and it fails in different ways.

Within a run, state is process-local and cheap to clear. Between runs, state is whatever survived: a container that was reused, a volume that was not pruned, a fixture file that was written back to, a cache that was restored. None of those are cleared by anything in the test suite, and all of them can make a run inherit the previous run’s world.

Three mechanisms cover the between-run case.

Immutability. Mount definitions read-only. A run that physically cannot alter its own stubs cannot leave them altered for the next one. This converts a discipline into a property the runtime enforces, which is a strictly better arrangement than a convention everybody agrees with and someone eventually forgets.

Ephemerality. Recreate the container rather than restarting it. docker compose down -v between runs costs one startup and guarantees no volume survives; docker compose restart costs less and guarantees nothing. On a shared runner where several pipelines use the same daemon, this is the difference between isolation and coincidence.

Cache hygiene. A cache keyed on inputs is safe. A cache keyed on a branch name, or on nothing, restores whatever the last run left — and if that run mutated its fixtures, the mutation is now the starting state. Anything mutated during a run must never be cached, which is a rule that has to be written down because caching it always looks like an optimisation.

The diagnostic for between-run leakage is different from the within-run one. Within a run, shuffling the order exposes the problem. Between runs, the tell is that the first run on a fresh runner behaves differently from subsequent ones — the classic “it only fails on the second build” report. If a pipeline behaves differently on a cold runner than a warm one, something is surviving that should not be, and it is almost always one of the three above.

A short checklist before blaming the tests

When a suite behaves differently between runs, working through a fixed list is faster than reasoning about it.

  • Does it pass on a cold runner and fail on a warm one? Something is surviving between runs — a cache, a volume, a reused container.
  • Does it pass serially and fail in parallel? State is shared within the run.
  • Does it pass alone and fail in the file? An earlier spec is leaving something behind.
  • Does it fail only after a specific spec runs? That spec creates state nothing resets.
  • Does it pass twice and fail on the third repeat? State is accumulating rather than leaking once.

Each answer points at a different layer, and between them they cover nearly every instance of this problem. The value of running through them is mostly that it stops the investigation starting from “the tests are flaky”, which is the conclusion that ends investigation rather than beginning it.

← Back to Ephemeral Preview Environments