Skip to main content

Stateful Mocking: WireMock Scenarios vs Spec-Based State

Model resource lifecycles as explicit, resettable state machines tied to the statuses documented in the contract.

Updated 5 min readMarkdown

Quick answer: Many APIs are not pure request-response: the response to a second call depends on what the first call did, and a stateless mock that always returns the same body cannot reproduce that behavior. Stateful mocking models a resource's lifecycle — created, then captured, then refunded, for example — so the mock returns different responses based on its current state. The engineering requirement is to keep that state machine explicit and reproducible, reset between tests, and anchored to the statuses the contract already documents, rather than relying on hidden state that accumulates across a test run.

Teams reach for stateful mocks the moment they try to test a multi-step workflow. A payment charge is created, captured, and then refunded; each action transitions the resource and changes what a subsequent GET returns. A mock that ignores those transitions will happily return "captured" before a capture ever happens, and the test passes against behavior the real API would reject. The value of stateful mocking is not realism for its own sake; it is the ability to assert that a workflow drives the resource through the correct sequence and rejects invalid transitions.

What a state machine has to capture

Three elements define a usable stateful mock, and missing any one makes tests unreliable.

  • Named states and transitions. Each request moves the resource from one state to another, and the response reflects the new state.
  • Guards on invalid transitions. Refunding an uncaptured charge should return the documented error, not silently succeed.
  • Deterministic reset. The state returns to a known baseline before each test so execution order cannot change the result.

# state machine expressed as data, mapping each transition to the # status already declared in the OpenAPI contract states: initial: none transitions: - on: POST /v1/charges from: none to: pending respond: 201 - on: POST /v1/charges/{id}/capture from: pending to: captured respond: 200 - on: POST /v1/charges/{id}/capture from: captured respond: 409 # already captured - on: POST /v1/charges/{id}/refund from: captured to: refunded respond: 200

Scenario tools versus spec-based state

Established mock servers implement stateful behavior through scenario stubs, which are powerful but typically live in a separate, tool-specific configuration and carry implicit state that must be reset. A spec-based approach keeps the state machine portable and ties each transition to a response and status code the OpenAPI document already defines, so the mock's lifecycle cannot drift from the contract.

Dimension Scenario stubs Spec-based state machine
State definition Tool-specific stubs Portable data/config
Contract alignment Manual Derived from documented statuses
Invalid transitions Extra stubs required Declared as guarded edges
Reset between tests Must be triggered Explicit initial state
Portability Tied to the server Reusable across tools

Assert the sequence, not just individual calls

A stateful test should walk the full workflow and verify both the happy path and a rejected transition, proving the mock enforces the lifecycle rather than echoing inputs. Reset the state in test setup so the same assertions hold regardless of run order.

it("drives a charge through capture and rejects a double capture", async () => { await mock.resetState(); const charge = await client.createCharge({ amount: 4999, currency: "usd" }); expect(charge.status).toBe("pending"); const captured = await client.capture(charge.id); expect(captured.status).toBe("captured"); await expect(client.capture(charge.id)).rejects.toMatchObject({ status: 409, code: "already_captured", }); });

Keep the state machine tied to the contract

A local-first OpenAPI workflow keeps the state machine next to the spec and generates the mock from both, so every transition's response is validated against the documented schema and the target is recreated identically in CI. As the resource lifecycle changes, the contract and the state machine update together, and contract checks catch a transition that points to a status that no longer exists. This keeps multi-step workflows testable without a mock whose hidden state quietly diverges from the real API.

Frequently asked questions

When do I need stateful mocking instead of fixed stubs?

When the response to a call depends on prior calls in the same workflow. If every endpoint returns the same body regardless of history, fixed stubs are simpler. Introduce state only where a real resource lifecycle exists.

How do I prevent state leakage between tests?

Reset the mock to an explicit initial state in test setup and avoid relying on state created by a previous test. Each test should construct its own sequence from the documented baseline.

Should the mock model every possible state?

Model the states and transitions the workflow under test actually uses, including the invalid transitions that must be rejected. Do not build a full replica of the server; target the lifecycle paths the client exercises.

What if the API's lifecycle changes?

Update the OpenAPI contract and the state machine together, then regenerate the mock. Because each transition references a documented status, a guard that points to a removed or renamed status is caught during validation rather than failing at runtime.

This is part of the Powerduck vs WireMock comparison — start there for the full decision around mock servers and stateful behavior.

No comments yet. Be the first to comment.