Modeling CRUD State in a Mock Server

Your test creates a task with POST /tasks, then reads it back with GET /tasks/:id, and gets a 404 β€” because the mock returns a fixed list that never heard about the task you just created. A real API persists a write and reflects it in the next read; a static mock does not. This page builds an in-memory create-read-update-delete store inside MSW handlers so that within a single session a POST persists, a GET sees it, a PUT modifies it, and a DELETE removes it β€” behaving like the real backend for the duration of a test run.

Why a CRUD store, not fixtures

Static fixtures model a snapshot: the state of the world at one instant. CRUD interactions model transitions: create moves an entity from nonexistent to existing, delete moves it back. A test that exercises a create-then-read flow, an optimistic UI update, or a delete-and-verify-gone assertion is testing a transition, and a snapshot cannot represent one.

This is the arbitrary-mutation end of stateful scenario sequences. Where a checkout flow has a fixed order, CRUD has none β€” any entity can be created, read, updated, or deleted at any time β€” which makes an imperative in-memory store a better fit than a declarative state machine. The store below is generic enough to back any single resource, and it seeds its baseline from the reproducible factories in faker and fixture seeding.

Create then read, from one store A POST writes a record into the store and returns it with a server-assigned identifier. A later GET for that identifier reads the same store and returns the record, so the client observes the create it just performed. Without the shared store the read falls back to a static fixture and the created record disappears. The middle box is the whole design: two handlers sharing one store rather than two independent fixtures. POST /orders writes to the store returns the created record Store one map, one owner the only source of truth GET /orders/:id reads the store or the static fallback Client sees its own write the behaviour under test A store that is never reset makes this same design the leading cause of order-dependent test failures.

Solution

1. A typed in-memory collection

Model the collection as a Map keyed by id. A Map gives O(1) lookup for GET /:id, PUT /:id, and DELETE /:id, and preserves insertion order for the list endpoint. Keep create, read, update, delete, and reset behind a small interface so the handlers stay thin:

// src/mocks/store/collection.ts
import { faker } from '../seeded-faker';

export interface Task {
  id: string;
  title: string;
  done: boolean;
  createdAt: string;
}

function seedTasks(): Map<string, Task> {
  faker.seed(Number(process.env.MOCK_SEED ?? 42));
  const map = new Map<string, Task>();
  for (let i = 1; i <= 3; i++) {
    const id = `task_${i.toString().padStart(3, '0')}`;
    map.set(id, {
      id,
      title: faker.hacker.phrase(),
      done: false,
      createdAt: faker.date.recent({ days: 10 }).toISOString(),
    });
  }
  return map;
}

let tasks = seedTasks();
let counter = tasks.size;

export const taskStore = {
  list(): Task[] {
    return [...tasks.values()];
  },
  find(id: string): Task | undefined {
    return tasks.get(id);
  },
  create(input: { title: string; done?: boolean }): Task {
    const id = `task_${(++counter).toString().padStart(3, '0')}`;
    const task: Task = {
      id,
      title: input.title,
      done: input.done ?? false,
      createdAt: faker.date.recent({ days: 1 }).toISOString(),
    };
    tasks.set(id, task);
    return task;
  },
  update(id: string, patch: Partial<Omit<Task, 'id'>>): Task | undefined {
    const existing = tasks.get(id);
    if (!existing) return undefined;
    const updated = { ...existing, ...patch, id };
    tasks.set(id, updated);
    return updated;
  },
  remove(id: string): boolean {
    return tasks.delete(id);
  },
  reset(): void {
    tasks = seedTasks();
    counter = tasks.size;
  },
};

The reset() re-seeds faker and rebuilds the map, so every test starts with the same three baseline tasks and the same id counter β€” the isolation requirement from resetting mock state between test runs.

2. Wire the four verbs to the store

Each handler is a thin translation from HTTP to a store method. Note the status codes: 201 for create, 404 when an id is missing, 422 when the body is invalid, and 204 for a successful delete. Richer request parsing and conditional branching is covered in advanced MSW handler patterns:

// src/mocks/handlers/tasks.ts
import { http, HttpResponse } from 'msw';
import { taskStore } from '../store/collection';

export const taskHandlers = [
  http.get('/api/v1/tasks', () =>
    HttpResponse.json({ data: taskStore.list() })
  ),

  http.get('/api/v1/tasks/:id', ({ params }) => {
    const task = taskStore.find(params.id as string);
    return task
      ? HttpResponse.json(task)
      : HttpResponse.json({ error: 'not_found' }, { status: 404 });
  }),

  http.post('/api/v1/tasks', async ({ request }) => {
    const body = (await request.json()) as { title?: unknown; done?: boolean };
    if (typeof body.title !== 'string' || body.title.trim() === '') {
      return HttpResponse.json({ error: 'title_required' }, { status: 422 });
    }
    const created = taskStore.create({ title: body.title, done: body.done });
    return HttpResponse.json(created, { status: 201 });
  }),

  http.put('/api/v1/tasks/:id', async ({ params, request }) => {
    const patch = (await request.json()) as Partial<{ title: string; done: boolean }>;
    const updated = taskStore.update(params.id as string, patch);
    return updated
      ? HttpResponse.json(updated)
      : HttpResponse.json({ error: 'not_found' }, { status: 404 });
  }),

  http.delete('/api/v1/tasks/:id', ({ params }) => {
    const removed = taskStore.remove(params.id as string);
    return removed
      ? new HttpResponse(null, { status: 204 })
      : HttpResponse.json({ error: 'not_found' }, { status: 404 });
  }),
];

3. Query the live collection

A list endpoint that ignores query parameters hides bugs in filtering and pagination code. Because the store holds live state, the list handler can filter and page over the current contents β€” including entities created earlier in the same session. Add a query-aware list method:

// src/mocks/store/collection.ts  (add to taskStore)
export interface TaskQuery {
  done?: boolean;
  page?: number;
  pageSize?: number;
}

// inside the taskStore object:
query(q: TaskQuery): { data: Task[]; page: number; total: number } {
  let rows = [...tasks.values()];
  if (q.done !== undefined) {
    rows = rows.filter((t) => t.done === q.done);
  }
  const page = q.page ?? 1;
  const pageSize = q.pageSize ?? 20;
  const total = rows.length;
  const start = (page - 1) * pageSize;
  return { data: rows.slice(start, start + pageSize), page, total };
},

The handler parses the query string and returns a paged envelope, so a filter applied after a POST reflects the newly created row:

// src/mocks/handlers/tasks.ts  (replace the list handler)
http.get('/api/v1/tasks', ({ request }) => {
  const url = new URL(request.url);
  const doneParam = url.searchParams.get('done');
  const result = taskStore.query({
    done: doneParam === null ? undefined : doneParam === 'true',
    page: Number(url.searchParams.get('page') ?? 1),
    pageSize: Number(url.searchParams.get('pageSize') ?? 20),
  });
  return HttpResponse.json(result);
}),

Now a test that creates a completed task and then requests GET /tasks?done=true sees exactly one more row than the baseline β€” the store makes the filter genuinely stateful. Combining this with the request-parsing techniques in advanced MSW handler patterns covers sorting and cursor pagination when you need them.

4. Reset between tests

Register the reset so the store returns to its seeded baseline before each test. Without this, a task created in one test is visible in the next, and a delete in one test makes a later β€œtask exists” assertion fail:

// src/setupTests.ts
import { beforeEach, afterAll } from 'vitest';
import { server } from './mocks/node';
import { taskStore } from './mocks/store/collection';

beforeEach(() => {
  taskStore.reset();
  server.resetHandlers();
});

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

Building the store on top of faker and fixture seeding means the baseline tasks are realistic and identical on every machine, so a snapshot of GET /tasks is stable across local and CI runs β€” the same guarantee that deterministic seed management provides for pure fixtures.

The semantics worth implementing Six behaviours. Create assigns an identifier and returns 201 with a Location header. Read returns 404 for an unknown identifier. Update returns the merged record. Delete is idempotent. List reflects creates and deletes. Conflict returns 409 on a concurrent write. Each is paired with the client behaviour it exercises. Operation Must Exercises create 201, Location header, assigned id optimistic insert and redirect read unknown 404 with an error body the not-found surface update return the merged record cache reconciliation delete be idempotent double-click and retry list reflect creates and deletes cache invalidation conflict 409 on a stale write the concurrent-edit path Most stores implement the first three. The last three are where the interesting client bugs live.

Verification

One test walks a full create, read, update, delete cycle and asserts each step reflects the last:

// src/__tests__/tasks.test.ts
import { describe, it, expect, beforeEach } from 'vitest';
import { taskStore } from '../mocks/store/collection';

beforeEach(() => taskStore.reset());

describe('task CRUD store', () => {
  it('persists a create and reflects it on read', () => {
    const created = taskStore.create({ title: 'Ship it' });
    expect(taskStore.find(created.id)?.title).toBe('Ship it');
  });

  it('updates and deletes within a session', () => {
    const created = taskStore.create({ title: 'Draft' });
    taskStore.update(created.id, { done: true });
    expect(taskStore.find(created.id)?.done).toBe(true);
    expect(taskStore.remove(created.id)).toBe(true);
    expect(taskStore.find(created.id)).toBeUndefined();
  });

  it('starts from the seeded baseline after reset', () => {
    expect(taskStore.list()).toHaveLength(3);
  });
});
npx vitest run src/__tests__/tasks.test.ts
# Expected: 3 passed

Gotchas and edge cases

  • PUT must not let the client change the id. The update method spreads patch and then forces id back to the path parameter. Without that final id override, a body containing { id: 'evil' } would relocate the entity in the map and orphan its original key. Always pin server-owned fields after merging client input.

  • The id counter must reset with the data. reset() restores both the map and counter. If you reset only the map, the next create() mints task_004 instead of reusing the baseline numbering, so a snapshot of created-task ids drifts between test files. Reset every piece of mutable state together.

  • A shared store leaks across browser and Node. MSW can run in the browser (via a service worker) and in Node (via a request interceptor). If both import the same store module in a single test process, they share one map. Keep browser and Node handler registration separate, or namespace the store per environment, so a browser test does not see writes made by a Node test.


How far to take the imitation Three boundaries. A mock store should not implement transactions, should not enforce referential integrity beyond what the API surfaces, and should not persist across a run. Each is paired with the reason: past these lines the mock becomes a second implementation with its own bugs. No transactions the API surface does not expose them a rollback bug in the mock is pure cost No deep integrity rules enforce only what the API returns as an error otherwise the mock rejects what the API accepts No persistence in memory, cleared per run persistence turns isolation into an ongoing chore A mock store that becomes a small database has stopped being cheap, which was the reason to have one.

← Back to Stateful Scenario Sequences