Serve fixtures from Mock Service Worker
Write one fixture recipe and use it in three places: a unit test builds it directly, a Mock Service Worker (MSW) handler returns it from a mocked endpoint, and a Storybook story previews it. Every call builds new objects, so one test or story cannot change the data another one sees. The same id always gives the same data, so a failure in one place can be reproduced in the others.
The recipe uses Zod for the response schema, @mimlet/consumers for the JSON response and MSW 3.0.2:
npm install --save-dev @mimlet/core@0.1.0-beta.3 @mimlet/consumers@0.1.0-beta.3 @mimlet/zod@0.1.0-beta.3 zod@4.6.5 msw@3.0.2Any Mimlet builder works the same way. Use your own adapter if your API schema comes from TypeBox, Valibot, ArkType, JSON Schema or OpenAPI.
1. Write the fixture recipe
import { z } from 'zod';
import { createSession } from '@mimlet/core';
import { fromZodFactory } from '@mimlet/zod';
// The JSON your API sends. Validated output is the response body.
export const Order = z.object({
id: z.string(),
status: z.enum(['open', 'paid', 'cancelled']),
customer: z.object({ name: z.string(), email: z.email() }),
lines: z
.array(z.object({ sku: z.string(), quantity: z.int().min(1), unitPriceCents: z.int().min(0) }))
.min(1),
totalCents: z.int().min(0),
});
const products = [
{ sku: 'MUG', unitPriceCents: 1200 },
{ sku: 'TEE', unitPriceCents: 2500 },
{ sku: 'CAP', unitPriceCents: 1800 },
];
// The id seeds the session: the same id always gives the same order, and
// every build returns new objects. The total is a transform, which runs
// after with(), so an order with changed lines keeps a matching total.
export const orders = fromZodFactory(Order, (id: string) => {
const session = createSession({ seed: id, fingerprint: 'orders/v1', provider: 'shop-mocks@1' });
return {
id,
status: 'open',
customer: {
name: session.pick(['Ada Lovelace', 'Grace Hopper', 'Alan Turing']),
email: `${id}@example.test`,
},
lines: Array.from({ length: session.integer(1, 3) }, () => ({
...session.pick(products),
quantity: session.integer(1, 4),
})),
totalCents: 0,
};
}).transform((order) => ({
...order,
totalCents: order.lines.reduce((sum, line) => sum + line.quantity * line.unitPriceCents, 0),
}));View the tested fixture recipe.
The factory takes the order id and seeds a session from it. A request for order-1, a unit test that builds order-1 and a story that shows order-1 all get the same order, and each one gets its own copy. Export the builder, not a built object: a shared constant would let one test's change leak into the next.
buildValidated() checks every order against the schema, so the mock cannot drift from the API contract without a test noticing. Here the schema's input and output are the same JSON. If your schema transforms values when it parses (for example a date string into a Date), serve the input from build() instead, because that is what goes over the wire.
2. Return it from an MSW handler
import { http } from 'msw';
import { jsonResponseResolver } from '@mimlet/consumers';
import { orders } from './msw-orders.js';
export const api = 'https://shop.example.test/api';
// Every request builds a new order and gets its own JSON response.
// Pass a builder variant to change what the endpoint returns.
export function orderHandler(variant = orders) {
return http.get<{ id: string }>(`${api}/orders/:id`, ({ request, params }) =>
jsonResponseResolver(() => variant.buildValidated(params.id))(request)
);
}
export const handlers = [orderHandler()];jsonResponseResolver from @mimlet/consumers turns the build into a native Response: a new JSON body for each request, a JSON content-type header and a 1 MiB body limit. It passes request aborts through and never turns an error into a successful response. If a builder variant fails validation, buildValidated() throws, MSW answers with status 500 and logs the error, and the test fails instead of reading a bad body. Mimlet does not intercept requests or replace fetch: MSW does, in the test setup you control.
3. Use the same recipe in tests
import assert from 'node:assert/strict';
import { after, afterEach, before, test } from 'node:test';
import { setupServer } from 'msw/node';
import { api, handlers, orderHandler } from './msw-handlers.js';
import { Order, orders } from './msw-orders.js';
const server = setupServer(...handlers);
before(() => server.listen({ onUnhandledFrame: 'error' }));
afterEach(() => server.resetHandlers());
after(() => server.close());
// The code under test: an API client that fetches and parses an order.
async function getOrder(id: string) {
const response = await fetch(`${api}/orders/${id}`);
if (!response.ok) {
throw new Error(`GET order failed with ${response.status}`);
}
return Order.parse(await response.json());
}
test('a unit test builds the order it needs', () => {
const twoMugs = [{ sku: 'MUG', quantity: 2, unitPriceCents: 1200 }];
const order = orders.with({ lines: twoMugs }).buildValidated('order-1');
assert.equal(order.totalCents, 2400);
});
test('every build returns new objects', () => {
const order = orders.buildValidated('order-1');
order.lines.length = 0;
assert.notEqual(orders.buildValidated('order-1').lines.length, 0);
});
test('the MSW handler serves the order the unit test builds', async () => {
assert.deepEqual(await getOrder('order-1'), orders.buildValidated('order-1'));
});
test('one test changes the response with a builder variant', async () => {
server.use(orderHandler(orders.with({ status: 'cancelled' })));
assert.equal((await getOrder('order-2')).status, 'cancelled');
});The file runs with node --test. With Vitest or Jest, use beforeAll, afterEach and afterAll for the same three server calls. MSW 3 renamed the onUnhandledRequest option to onUnhandledFrame; on MSW 2, pass onUnhandledRequest: 'error' instead.
- A unit test builds the order it needs with
with()and never starts MSW. - A test of code that fetches gets the same order through the handler. Comparing the response with a direct build shows that both paths use one recipe.
server.use(orderHandler(variant))changes the response for one test, andserver.resetHandlers()restores the default after it.
4. Preview it in Storybook
import { fixtureLoader } from '@mimlet/consumers';
import { handlers } from './msw-handlers.js';
import { orders } from './msw-orders.js';
// A Storybook CSF file, without the component import and render functions.
// msw-storybook-addon serves parameters.msw.handlers to components that fetch.
export default {
title: 'Orders/OrderPage',
parameters: { msw: { handlers } },
};
// A component that fetches order-1 gets it from the handlers above.
export const Fetched = { args: { orderId: 'order-1' } };
// A component that takes the order as a prop gets a fresh copy from a loader.
export const Loaded = {
loaders: [fixtureLoader('order', () => orders.buildValidated('order-1'))],
};With msw-storybook-addon set up in your Storybook preview, parameters.msw.handlers serves the same handlers to components that fetch. A component that takes the order as a prop can use fixtureLoader instead: Storybook passes the value as loaded.order, and the loader builds and clones a new order each time Storybook runs it, so a component that mutates its props cannot change the next story.
The recipe leaves out the component import and the render function, because Storybook and a UI framework are not installed in this repository's tests. The tests check the story's loader and handlers, not a rendered story.
Fresh and reproducible data
- New objects for every call. Each build runs the factory again, Zod's parser returns new objects, each response gets its own body, and
fixtureLoaderclones the value. Nothing is cached between calls. - Same id, same data. The id is the seed, so the data does not depend on the order in which tests run or requests arrive. This holds for the same recipe code and package versions: editing the factory, for example adding a
pick()call, can change the data for every id. - Variants stay coherent.
with()replaces the fields a test cares about, and the transform recomputes the total from whatever lines the order ends up with.
How this is tested
pnpm test:examples compiles these files against packed Mimlet packages and MSW 3.0.2 and runs the test file above as written. It also checks that the story loader returns fresh, equal copies, that the handler returns the same order, that an invalid variant answers 500 and that an unhandled request fails. Storybook, msw-storybook-addon and browser service workers are not part of that run.
See the consumers package for the full jsonResponseResolver and fixtureLoader contracts, and seed a database for writing the same kind of fixtures to a test database.