Simulating Multi-Step Checkout Flows

Your end-to-end test adds an item, submits payment, and expects a confirmation number — but the mock happily returns a confirmation even when payment was never sent, so the test passes against a flow that would fail in production. A checkout is a sequence with order constraints: you cannot pay an empty cart, and you cannot confirm an unpaid order. A static mock cannot express those constraints because it has no memory of the previous step. This page builds a checkout mock that enforces the order, in both WireMock and MSW, so the test only passes when the steps happen in the right sequence.

Why checkout flows need a stateful mock

Checkout is the canonical example of a stateful sequence because each transition has a precondition. POST /payment should return 422 if no cart exists. POST /confirm should return 409 if payment has not cleared. GET /cart should return an empty cart before anything is added and a populated one after. Every one of these responses depends on history, not just the incoming request — exactly the property that stateful scenario sequences exists to model.

There are two ways to build it. A WireMock scenario expresses the flow declaratively in mapping JSON and is ideal when a non-JavaScript client drives the test. An MSW store expresses it imperatively in TypeScript and is ideal for a frontend test that runs in the same process. This page shows both, using the same three-step flow, so you can pick whichever fits your client.

Solution

1. The WireMock scenario version

Model the flow as a checkout scenario with three states. Each mapping requires the state its precondition establishes, so an out-of-order call finds no matching mapping and falls through to a catch-all error.

{
  "scenarioName": "checkout",
  "requiredScenarioState": "Started",
  "request": { "method": "POST", "url": "/cart/items" },
  "newScenarioState": "CartOpen",
  "response": {
    "status": 201,
    "jsonBody": { "cartId": "cart_100", "items": 1, "totalCents": 4200 }
  }
}
{
  "scenarioName": "checkout",
  "requiredScenarioState": "CartOpen",
  "request": { "method": "POST", "url": "/payment" },
  "newScenarioState": "Paid",
  "response": {
    "status": 200,
    "jsonBody": { "paymentId": "pay_100", "status": "authorized" }
  }
}
{
  "scenarioName": "checkout",
  "requiredScenarioState": "Paid",
  "request": { "method": "POST", "url": "/confirm" },
  "newScenarioState": "Confirmed",
  "response": {
    "status": 200,
    "jsonBody": { "orderId": "order_100", "status": "confirmed" }
  }
}

To reject out-of-order calls, add low-priority catch-all mappings that fire only when the precondition state has not been reached. WireMock matches the highest-priority mapping first, so the stateful mappings above win when their state is active:

{
  "scenarioName": "checkout",
  "requiredScenarioState": "Started",
  "priority": 10,
  "request": { "method": "POST", "url": "/payment" },
  "response": {
    "status": 422,
    "jsonBody": { "error": "no_cart" }
  }
}

Loading and driving the scenario uses the same WireMock admin API described in WireMock standalone configuration:

curl -s -X POST http://localhost:8080/__admin/scenarios/reset
curl -s -X POST http://localhost:8080/cart/items | jq .cartId    # "cart_100"
curl -s -X POST http://localhost:8080/payment    | jq .status    # "authorized"
curl -s -X POST http://localhost:8080/confirm    | jq .orderId   # "order_100"

2. The MSW store version

The imperative equivalent keeps a single checkout object and guards each transition in the handler. The store seeds its baseline from the factories in faker and fixture seeding:

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

type Phase = 'empty' | 'cart_open' | 'paid' | 'confirmed';

interface Checkout {
  phase: Phase;
  cartId: string | null;
  items: { productName: string; priceCents: number }[];
  paymentId: string | null;
  orderId: string | null;
}

function fresh(): Checkout {
  return { phase: 'empty', cartId: null, items: [], paymentId: null, orderId: null };
}

let checkout: Checkout = fresh();

export const checkoutStore = {
  state: () => checkout,
  addItem() {
    if (checkout.phase === 'empty') {
      checkout.cartId = 'cart_100';
      checkout.phase = 'cart_open';
    }
    checkout.items.push({
      productName: faker.commerce.productName(),
      priceCents: faker.number.int({ min: 500, max: 9000 }),
    });
    return checkout;
  },
  pay() {
    if (checkout.phase !== 'cart_open') return null;
    checkout.paymentId = 'pay_100';
    checkout.phase = 'paid';
    return checkout;
  },
  confirm() {
    if (checkout.phase !== 'paid') return null;
    checkout.orderId = 'order_100';
    checkout.phase = 'confirmed';
    return checkout;
  },
  reset() {
    checkout = fresh();
    faker.seed(Number(process.env.MOCK_SEED ?? 42));
  },
};

The handlers turn a failed precondition into the right HTTP error:

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

export const checkoutHandlers = [
  http.get('/api/v1/checkout', () => HttpResponse.json(checkoutStore.state())),

  http.post('/api/v1/cart/items', () =>
    HttpResponse.json(checkoutStore.addItem(), { status: 201 })
  ),

  http.post('/api/v1/payment', () => {
    const result = checkoutStore.pay();
    return result
      ? HttpResponse.json({ paymentId: result.paymentId, status: 'authorized' })
      : HttpResponse.json({ error: 'no_cart' }, { status: 422 });
  }),

  http.post('/api/v1/confirm', () => {
    const result = checkoutStore.confirm();
    return result
      ? HttpResponse.json({ orderId: result.orderId, status: 'confirmed' })
      : HttpResponse.json({ error: 'not_paid' }, { status: 409 });
  }),
];

3. Model the asynchronous payment step

Real payment providers rarely authorize synchronously. The client submits payment, receives a processing status, and polls a status endpoint until it flips to authorized. That poll is itself a stateful sequence: the same GET /payment/status request returns a different body depending on how many times it has been called. Model it with a counter in the store so the mock reproduces the poll-until-ready behaviour a real integration must handle:

// src/mocks/store/checkout-store.ts  (add to the store object)
let pollCount = 0;
const POLLS_UNTIL_AUTHORIZED = 2;

export const paymentPolling = {
  status(): 'processing' | 'authorized' {
    pollCount += 1;
    return pollCount >= POLLS_UNTIL_AUTHORIZED ? 'authorized' : 'processing';
  },
  reset() {
    pollCount = 0;
  },
};
// src/mocks/handlers/checkout.ts  (add to the handlers array)
import { paymentPolling } from '../store/checkout-store';

http.get('/api/v1/payment/status', () =>
  HttpResponse.json({ status: paymentPolling.status() })
),

The client’s retry loop now exercises real code paths — a spinner during processing, a transition to the confirmation view on authorized — instead of an instant success that hides timing bugs. This is the transport-level counterpart to the async handling covered in advanced MSW handler patterns.

4. Reset before every test

Every version must return to the initial state between tests, or a confirmed order and a stale poll counter from one test break the next. Reset the store, the poll counter, and the scenario over the admin API, following resetting mock state between test runs:

// src/setupTests.ts
import { beforeEach } from 'vitest';
import { checkoutStore, paymentPolling } from './mocks/store/checkout-store';

beforeEach(() => {
  checkoutStore.reset();
  paymentPolling.reset();
});

Verification

One test drives the happy path and one out-of-order call, asserting the mock enforces ordering:

// src/__tests__/checkout.test.ts
import { describe, it, expect, beforeEach } from 'vitest';
import { checkoutStore } from '../mocks/store/checkout-store';

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

describe('checkout flow', () => {
  it('rejects payment before a cart exists', () => {
    expect(checkoutStore.pay()).toBeNull();
  });

  it('completes cart, payment, confirmation in order', () => {
    checkoutStore.addItem();
    expect(checkoutStore.pay()?.phase).toBe('paid');
    expect(checkoutStore.confirm()?.phase).toBe('confirmed');
  });
});
npx vitest run src/__tests__/checkout.test.ts
# Expected: 2 passed

Gotchas and edge cases

  • Adding a second item must not reset the phase. addItem() only transitions empty → cart_open on the first call; subsequent calls append to an existing cart. A naive implementation that sets phase = 'cart_open' unconditionally would let a mid-payment add-item silently rewind the flow. Guard the transition on the current phase, as the store does.

  • WireMock picks the wrong mapping for out-of-order calls. If your catch-all error mapping and a stateful mapping share the same required state and URL, priority decides the winner. Give error mappings a lower priority number so the specific stateful mapping wins when its state is active, and the error only fires when no stateful mapping matches.

  • The confirmation number must be stable per run. Hard-coding order_100 keeps assertions simple, but if a test needs a unique order id, derive it from the seeded faker rather than Date.now() — a timestamp makes the response non-reproducible and reintroduces the flakiness the seed was meant to remove.


← Back to Stateful Scenario Sequences