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.

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.

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.


← Back to Stateful Scenario Sequences