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 transitionsempty → cart_openon the first call; subsequent calls append to an existing cart. A naive implementation that setsphase = '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
prioritynumber 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_100keeps assertions simple, but if a test needs a unique order id, derive it from the seeded faker rather thanDate.now()— a timestamp makes the response non-reproducible and reintroduces the flakiness the seed was meant to remove.
Testing the abandonment paths
Most checkout testing follows the flow to completion. The paths that matter commercially are frequently the ones where it does not complete, and a stateful mock is the only practical way to exercise them.
Leaving and returning. A customer fills the basket, closes the tab and comes back an hour later. Does the basket survive? Does the address? Does a reserved item still exist? These depend on state persisting across sessions, which a mock can model precisely and a static fixture cannot model at all.
Abandoning mid-payment. An authorisation is initiated and the customer never completes it. The order sits in a pending state that the interface must render sensibly and that a later retry must be able to resume or replace. A mock that only produces confirmed orders never surfaces the pending screen.
Changing something late. Editing the basket after the address is captured, or changing the address after payment is authorised, invalidates earlier steps. Which steps have to be redone is a business rule, and it is one that a scenario mock can express as explicit transitions.
Going back. Browser back through a multi-step flow is where state machines break most visibly, because the interface’s idea of the current step and the server’s diverge. A mock reporting its own state lets a test assert on that divergence directly rather than inferring it from what is rendered.
Each of these is a transition rather than a state, which is precisely why they resist fixture-based testing. Modelling the flow as named states with explicit transitions makes them cheap to reach — the test drives to a state and then does the unexpected thing — and it makes the resulting failure legible, because the mock can say exactly where the flow was when it broke.
Related
- Modeling CRUD State in a Mock Server — a general in-memory store for arbitrary create, read, update, delete
- Stateful Scenario Sequences — the parent overview of scenario machines and stores
- WireMock Standalone Configuration — set up the WireMock instance this flow runs on
← Back to Stateful Scenario Sequences