Stateful Scenario Sequences

This page covers mocks whose responses change across a sequence of calls: a WireMock scenario state machine defined in mapping JSON, and an MSW in-memory store that handlers mutate as requests arrive.

Static fixtures answer “what does this endpoint return?” with a single, history-independent payload. A stateful scenario answers a harder question: “what does this endpoint return given everything that happened before it?” A job that reports pending on the first poll and complete on the third, a resource that 404s until you create it, a checkout that must move through cart, payment, and confirmation in order — none of these can be expressed by a fixed response body. They need a mock that remembers.

Scenario state machine Three states — Started, AwaitingPayment, and Confirmed — connected by transitions. A POST to the cart moves Started to AwaitingPayment; a POST to payment moves AwaitingPayment to Confirmed; a reset returns Confirmed to Started. Started empty cart Awaiting Payment Confirmed order placed POST /cart POST /payment POST /__admin/scenarios/reset (between test runs) GET /cart returns a different body in each state — the response depends on history, not just the request

Prerequisites

  • Node.js 18 or later, locally and in CI
  • MSW v2 configured for Node — see MSW handler registration
  • A WireMock standalone instance v3.x reachable on localhost:8080 (Docker or JAR)
  • curl and jq for driving and inspecting sequences
  • Familiarity with faker and fixture seeding, since stateful stores are usually seeded with factory-built baseline data
  • A test runner with beforeEach/afterEach hooks (Vitest 1.x or Jest 29)

Phase 1 — A WireMock scenario state machine

WireMock models sequences with scenarios: a named state machine where each mapping declares the state it requires (requiredScenarioState) and, optionally, the state it moves to (newScenarioState). A scenario always begins in the built-in Started state. This is fully declarative — no code, just mapping JSON — which makes it the right tool when the sequence is fixed and you want it language-agnostic.

The following three mappings model a checkout moving from an empty cart, to a cart awaiting payment, to a confirmed order. Each lives in WireMock’s mappings/ directory:

{
  "scenarioName": "checkout",
  "requiredScenarioState": "Started",
  "request": { "method": "POST", "url": "/cart" },
  "newScenarioState": "AwaitingPayment",
  "response": {
    "status": 201,
    "headers": { "Content-Type": "application/json" },
    "jsonBody": { "cartId": "cart_001", "status": "open", "items": 1 }
  }
}
{
  "scenarioName": "checkout",
  "requiredScenarioState": "AwaitingPayment",
  "request": { "method": "POST", "url": "/payment" },
  "newScenarioState": "Confirmed",
  "response": {
    "status": 200,
    "headers": { "Content-Type": "application/json" },
    "jsonBody": { "cartId": "cart_001", "status": "paid", "orderId": "order_001" }
  }
}
{
  "scenarioName": "checkout",
  "requiredScenarioState": "AwaitingPayment",
  "request": { "method": "POST", "url": "/payment" },
  "newScenarioState": "AwaitingPayment",
  "response": {
    "status": 402,
    "headers": { "Content-Type": "application/json" },
    "jsonBody": { "error": "card_declined" }
  }
}

The two /payment mappings share a required state but return different responses; WireMock matches the first mapping whose request predicate fits, so add request-body matching (for example, a declined test card number) to steer between them. To read the current cart in any state, add a GET /cart mapping per state so the body reflects history:

{
  "scenarioName": "checkout",
  "requiredScenarioState": "AwaitingPayment",
  "request": { "method": "GET", "url": "/cart" },
  "response": {
    "status": 200,
    "jsonBody": { "cartId": "cart_001", "status": "open", "items": 1 }
  }
}

Load these into a running WireMock and drive the sequence:

# POST /cart  → 201, state becomes AwaitingPayment
curl -s -X POST http://localhost:8080/cart | jq .status
# "open"

# POST /payment → 200, state becomes Confirmed
curl -s -X POST http://localhost:8080/payment | jq .status
# "paid"

The mechanics of configuring the WireMock instance itself — mappings directory, __files, Docker — live in the WireMock standalone configuration guide; this page is only concerned with the scenario wiring on top.


Phase 2 — An MSW in-memory store

When the sequence is not a fixed pipeline but arbitrary reads and writes, a declarative state machine becomes awkward. An MSW in-memory store is the imperative alternative: a module-level object that handlers read and mutate directly. A POST persists, a later GET reflects it — all within one process lifetime.

Keep the store and its mutators in one module so every handler shares the same instance. Seed the baseline with the factories from faker and fixture seeding so the starting data is realistic and reproducible:

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

export interface CartItem {
  id: string;
  productName: string;
  quantity: number;
  priceCents: number;
}

export interface Cart {
  id: string;
  status: 'open' | 'paid';
  items: CartItem[];
}

interface CartState {
  cart: Cart;
}

function freshState(): CartState {
  return { cart: { id: 'cart_001', status: 'open', items: [] } };
}

let state: CartState = freshState();

export const cartStore = {
  get(): Cart {
    return state.cart;
  },
  addItem(quantity: number): CartItem {
    const item: CartItem = {
      id: faker.string.uuid(),
      productName: faker.commerce.productName(),
      quantity,
      priceCents: faker.number.int({ min: 500, max: 8000 }),
    };
    state.cart.items.push(item);
    return item;
  },
  pay(): Cart {
    state.cart.status = 'paid';
    return state.cart;
  },
  /** Restore the baseline — call between test runs. */
  reset(): void {
    state = freshState();
    faker.seed(Number(process.env.MOCK_SEED ?? 42));
  },
};

Handlers translate HTTP verbs into store mutations. The GET now genuinely depends on prior POSTs:

// src/mocks/handlers/cart.ts
import { http, HttpResponse } from 'msw';
import { cartStore } from '../store/cart-store';

export const cartHandlers = [
  http.get('/api/v1/cart', () => HttpResponse.json(cartStore.get())),

  http.post('/api/v1/cart/items', async ({ request }) => {
    const body = (await request.json()) as { quantity?: number };
    const item = cartStore.addItem(body.quantity ?? 1);
    return HttpResponse.json(item, { status: 201 });
  }),

  http.post('/api/v1/cart/payment', () => {
    const cart = cartStore.get();
    if (cart.items.length === 0) {
      return HttpResponse.json({ error: 'empty_cart' }, { status: 422 });
    }
    return HttpResponse.json(cartStore.pay());
  }),
];

This store is deliberately minimal. For a full create-read-update-delete surface with per-entity lookup and 404 handling, see modeling CRUD state in a mock server. For richer conditional logic layered on top of a store, the techniques in advanced MSW handler patterns apply directly.


Phase 3 — Resetting state between runs and CI integration

Stateful mocks introduce the one hazard static fixtures never have: state that outlives a test. A cart left in paid by one test makes the next test’s “cart starts empty” assertion fail — and only when the tests run in that order. The cure is an unconditional reset before every test.

For the MSW store, call reset() in beforeEach:

// src/setupTests.ts
import { beforeEach, afterAll } from 'vitest';
import { server } from './mocks/node';
import { cartStore } from './mocks/store/cart-store';

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

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

For WireMock, reset the scenario over its admin API so the state machine returns to Started:

# Reset every scenario to its Started state
curl -s -X POST http://localhost:8080/__admin/scenarios/reset

# Or reset one scenario by name
curl -s -X PUT http://localhost:8080/__admin/scenarios/checkout/state

Wire both resets into the test lifecycle so a failed test never poisons its successors. This is the same discipline formalised in resetting mock state between test runs, and it sits inside the broader mock lifecycle management concern of clean startup, isolation, and teardown. In CI, the reset step runs between shards as well as between tests, because parallel shards may share a single long-lived WireMock container:

# .github/workflows/stateful.yml
name: Stateful scenarios
on: [push, pull_request]
jobs:
  test:
    runs-on: ubuntu-latest
    services:
      wiremock:
        image: wiremock/wiremock:3.5.4
        ports: ["8080:8080"]
    env:
      MOCK_SEED: "42"
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: "20"
          cache: "npm"
      - run: npm ci
      - name: Load mappings
        run: |
          for f in mocks/wiremock/mappings/*.json; do
            curl -s -X POST http://localhost:8080/__admin/mappings \
              -H 'Content-Type: application/json' --data @"$f" > /dev/null
          done
      - name: Reset scenarios before tests
        run: curl -s -X POST http://localhost:8080/__admin/scenarios/reset
      - run: npm test

Verification steps

Confirm the response genuinely changes across the sequence.

  • MSW store persists a write:
    # Assumes a Node process with the handlers registered on :3001
    curl -s -X POST http://localhost:3001/api/v1/cart/items -d '{"quantity":2}' -H 'Content-Type: application/json' | jq .quantity
    # Expected: 2
    curl -s http://localhost:3001/api/v1/cart | jq '.items | length'
    # Expected: 1  (the GET reflects the earlier POST)
  • Payment on an empty cart is rejected after reset:
    curl -s -o /dev/null -w "%{http_code}\n" -X POST http://localhost:3001/api/v1/cart/payment
    # Expected: 422 (fresh state, empty cart)
  • WireMock scenario advances:
    curl -s -X POST http://localhost:8080/__admin/scenarios/reset
    curl -s -X POST http://localhost:8080/cart | jq .status      # "open"
    curl -s -X POST http://localhost:8080/payment | jq .status   # "paid"
  • Scenario state is queryable:
    curl -s http://localhost:8080/__admin/scenarios | jq '.scenarios[] | select(.name=="checkout") | .state'
    # Expected: "Confirmed" after the two POSTs above

Troubleshooting

WireMock returns the same response no matter how many times I call the endpoint

Cause: The mapping has no requiredScenarioState, so it fires in every state and never participates in the machine. A mapping only becomes stateful when it declares both a scenarioName and a requiredScenarioState.

Fix: Add scenarioName and requiredScenarioState to each mapping, and newScenarioState to the ones that should advance. Confirm with GET /__admin/scenarios that the state changes after each call.

MSW test passes alone but fails in the full suite

Cause: The in-memory store carried state from an earlier test. Because the store is a module-level singleton, its data survives across tests in the same process.

Fix: Call cartStore.reset() in beforeEach (Phase 3). Never assume a store starts empty; assert it. This is the most common stateful-mock bug and the reason resetting mock state between test runs exists as a dedicated procedure.

Two /payment mappings both match and the wrong one wins

Cause: Both mappings require AwaitingPayment and match POST /payment, so WireMock picks by priority and request specificity. Without a distinguishing predicate it may pick the success mapping when you wanted the decline.

Fix: Add a bodyPatterns matcher (for example, a declined test card number) to the 402 mapping and raise its priority. The more specific mapping then wins for declined cards while the generic one handles the rest.

Store mutations leak across parallel test workers

Cause: Each worker has its own module instance, so per-worker stores are isolated — but a shared WireMock container is not. Parallel shards advance the same scenario concurrently and interfere.

Fix: Give each shard its own scenario name (suffix with the worker id), or serialise scenario-dependent tests onto one shard. For MSW stores, the per-worker isolation is automatic; the hazard is only with shared external mock servers.

Reset endpoint returns 404

Cause: WireMock’s admin API is disabled, or you posted to the wrong path. The reset path is /__admin/scenarios/reset (POST), not /scenarios/reset.

Fix: Confirm the admin API is enabled (it is by default) and that you are hitting the admin port. curl -s http://localhost:8080/__admin/mappings | jq '.meta.total' should return a count.


When to advance

Your stateful scenarios are production-ready when:

  • Every scenario mapping declares its required and next state; no stateful endpoint relies on call order by accident
  • The MSW store is reset in beforeEach and the WireMock scenario is reset before each test file
  • A test that drives a sequence out of order gets the correct error (for example, paying an empty cart returns 422)
  • Running the suite twice, and running it sharded, produces identical results
  • Baseline store data is seeded from factories, not hand-written, so it stays reproducible

Once these hold, model a concrete flow in simulating multi-step checkout flows, or build a full entity surface in modeling CRUD state in a mock server.


FAQ

When do I need a stateful mock instead of static fixtures?

Use a stateful mock when the correct response depends on what happened earlier in the session: a resource that does not exist until you POST it, a job that reports pending then complete on successive polls, or a checkout that must pass through cart, payment, and confirmation in order. Static fixtures return the same payload regardless of history, so they cannot model sequences. If every call to an endpoint should return the same body, keep it static — reach for state only when the sequence itself is the thing under test.

How is a WireMock scenario different from an MSW in-memory store?

A WireMock scenario is a declarative state machine defined in mapping JSON: each mapping fires only in a named state and transitions to the next. An MSW store is imperative JavaScript state that handlers read and mutate. Scenarios are language-agnostic and ideal for fixed sequences; stores are more flexible for arbitrary CRUD because you write the logic directly. Teams that mock for both browser tests and non-JavaScript clients often run both — an MSW store for frontend tests and an equivalent WireMock scenario for mobile or backend clients.

Why must stateful mocks be reset between tests?

State that survives a test leaks into the next one, so a test passes or fails depending on which tests ran before it — the definition of a flaky suite. Reset the WireMock scenario with a POST to /__admin/scenarios/reset and clear the MSW store in beforeEach so every test starts from an identical baseline. The dedicated procedure in resetting mock state between test runs covers the CI-shard case where a single mock server is shared across parallel jobs.


← Back to Data Generation & Realism Strategies