Why parallel Playwright tests and a shared mock server don't mix — and how bucket-scoped isolation fixes it, proven on a real app.
The pain point
Playwright defaults to running specs across multiple workers (fullyParallel: true). Most MSW-based mocks are fine with that, because they're stateless: every worker calls the same handler, gets the same canned JSON back, no shared state to fight over.
That stops working the moment a test needs a round trip — create a chat, see it in the list; delete a message, see it disappear after refresh. A stateless mock can't do that; it always returns list_chats: [], whether you just created one or not. Every "state-aware" spec ends up working around it: per-test response overrides, chained fake responses, awkward .first() assertions to dodge stale data.
The obvious next move is to make the mock stateful — a real in-memory store the handlers actually read and write. But make it a single, shared stateful mock, and parallel workers now do something worse than race on read timing: they mutate the same database out from under each other. Worker 2 resets the data right as worker 5 is mid-assertion. The usual response is to force workers: 1 on CI and accept the slower run — which throws away the entire point of fullyParallel.
What msw-terrarium does about it
The fix isn't "make it stateful." It's "make it stateful and give every worker its own copy of that state." That's the whole premise of msw-terrarium: a bucket-scoped mock server built on MSW and @msw/data.
-
Bucket-per-worker isolation. Each worker sends an
x-mock-bucket: w0header (attached automatically by the Playwright adapter); the mock server routes every request — reads and writes — to that worker's own in-memoryWorld. Nothing shared, nothing to race on. -
A declarative schema, via
defineSchema: describe your collections (Zod or any StandardSchema validator), get@msw/datacollections with id generation and acreateNexthelper, wired into a freshWorldper bucket. -
A declarative REST layer, via
createRestHandlers: a{ 'METHOD /path': handler }table instead of hand-written MSW handlers, with body parsing, param parsing, and sidecar precedence handled for you. -
A
given.*BDD API for driving state from the spec itself:given.fresh()resets the bucket,given.load('seed-name')applies a named JSON fixture,given.patch({...})upserts rows or pins responses inline,given.failNext(key, spec)arms a one-shot failure for fault-injection tests. - Streaming support — SSE and NDJSON responses (chunked, with configurable delay and error sentinels) for endpoints that stream tokens instead of returning single JSON payloads.
-
A Playwright fixture adapter,
extendWithGiven, that wires all of the above intotestin one call: worker-scoped bucket id, apagefixture with the bucket header pre-attached, thegivenAPI, and an auto-reset fixture so every test starts clean.
Proving it on a real app
The demo isn't synthetic. nextcov-example — an existing Next.js Todo app used to demonstrate three-tier test coverage — has this exact problem, documented in its own README:
The current solution for SSR test is by changing the mock server data before each test, which means tests can only be run serially. Parallel testing is not supported at the moment.
Its mock layer was a single json-server instance shared by every Playwright worker — no per-worker isolation, so workers was forced to 1 on CI.
msw-terrarium-example is that same app with the mock layer swapped out, nothing else changed — same Next.js UI, same nextcov coverage setup, same unit/component tests. The port is a straight application of the features above:
-
e2e/mocks/schema.jsdeclares ataskcollection withdefineSchema— no baseline, so every fresh bucket starts empty by default (the oldjson-serverdb.json baked four canned tasks into the shared state; here that becomes an explicit named seed instead). -
e2e/mocks/handlers.jsmaps the app's existingGET/POST/PUT/DELETE /tasksroutes onto that collection withcreateRestHandlers— the app's ownfetchcalls didn't change at all. - The spec loads that seed where it's needed and resets to empty where it isn't:
test.beforeEach(async ({ given }) => {
await given.load("default-tasks");
});
test("list when empty tasks", async ({ page, given }) => {
await given.fresh(); // this one test wants a genuinely empty bucket
await page.goto("/");
await expect(page.getByText("No task")).toBeVisible();
});
That "list when empty tasks" test was test.skip'd in the original app — there was no safe way to represent "this worker's data is empty" without stepping on every other worker's fixtures. With bucket isolation it's the default state, so the test just works. Un-skipping it was a one-line diff.
Full walkthrough — schema, handlers, wiring, fixture composition with the app's existing nextcov coverage — is in the demo repo's README.
Before / after
playwright.config.ts, before:
workers: process.env.CI ? 1 : undefined,
After:
workers: process.env.CI ? 4 : undefined,
Local run (npm run integration-test):
Running 5 tests using 5 workers
ok 1 › list when empty tasks (410ms)
ok 5 › finish a task (455ms)
ok 4 › delete task - cancel the modal (739ms)
ok 2 › delete task (862ms)
ok 3 › edit task (859ms)
5 passed (3.5s)
CI-mode run (CI=true npm run integration-test):
Running 5 tests using 4 workers
ok 4 › list when empty tasks (331ms)
ok 5 › finish a task (222ms)
ok 1 › delete task - cancel the modal (634ms)
ok 2 › delete task (729ms)
ok 3 › edit task (780ms)
5 passed (3.1s)
Five tests, four concurrent workers, one mock server on one port, each worker with its own isolated data — including the test that used to be skipped because there was no safe way to run it alongside the others. No flaky ordering, no shared fixtures to step around, no workers: 1 compromise.
Try it
- Library:
stevez/msw-terrarium·npm install msw-terrarium - Full demo:
stevez/msw-terrarium-example
Top comments (0)