Index

Test Process State, Not Message Choreography

A strict mock test expects five calls in order:

load process
check message ID
dispatch ReserveInventory
save process
record inbox

Every expectation passes. Unfortunately, save process writes the old awaiting_payment state. The command says inventory should be reserved while the durable process still says it is waiting for payment.

Now fix the persistence and move record inbox before save process inside the same database transaction. The committed process, inbox, and outbox rows are identical. The strict test fails because two calls changed places.

The reproducible fixture retains both controls. One is a false success; the other is a false failure. Together they expose the problem with testing a Process Manager as a script of collaborator calls: the script is not the behavior the Process Manager owns.

Its behavior is a durable state transition and the work newly owed because of that transition. Tests should make those facts difficult to get wrong.

Start with the transition, not the handler

Enterprise Integration Patterns describes a Process Manager as a component which maintains sequence state and determines the next step from intermediate results. That gives us a useful test boundary:

(current state, incoming message)
    -> (next state, newly owed commands)

For the fixture’s fulfilment process, payment capture is one row:

(awaiting_payment, PaymentCaptured)
    -> (reserving_inventory, ReserveInventory)

There is no repository, queue, clock, or framework facade in that statement. Those mechanisms matter later. They are not needed to decide whether this message is valid in this state or which command follows.

The executable table has eight accepted transitions:

Current stateMessageNext stateNewly owed command
awaiting paymentpayment capturedreserving inventoryreserve inventory
reserving inventoryinventory reservedbooking shipmentbook shipment
reserving inventoryinventory rejectedrefundingrefund payment
booking shipmentshipment bookedcompletednone
booking shipmentfulfilment timed outrefundingrefund payment
refundingrefund completedcompensatednone
refundingrefund failedmanual reviewnone
refundingshipment bookedmanual reviewreconcile late shipment

That table is smaller and more stable than the handler which persists it. It also makes omissions visible. If refund failure loses its reason, or a late shipment silently completes while a refund is running, the wrong state appears in a named row rather than behind a chain of mocked calls.

The table is not the whole test suite. It is the business specification which tells the other tests what they must preserve.

Duplicate delivery has two meanings

An inbox answers a transport question: has this message ID already been handled? State answers a business question: is this fact still allowed to advance the process?

The fixture sends event-1 twice. After the first delivery, the process is at version one, one inbox row exists, and one ReserveInventory command exists. Exact redelivery changes none of them.

Then it sends event-2, a different message ID reporting the same captured payment. This message belongs in the inbox because it was received. It must not reserve inventory again because the process has already left awaiting_payment. The retained result has two inbox rows and one outbox row.

A test which asserts only wasHandled(event-1) misses the semantic duplicate. A test which asserts only the state transition misses broken inbox deduplication. The two controls belong at different boundaries because they detect different defects.

Pure tests should carry most of the state graph

Timeouts, compensation, and late messages are where state tests earn their keep. They can set the exact starting condition without arranging a database history or waiting for a worker.

The fixture gives a completed process a timeout which expected booking_shipment. The result is unchanged and owes no command. Give the same timeout to a process which is still booking shipment and the result becomes refunding with failure reason shipment_timeout and one RefundPayment command.

The distinction is the state predicate, not whether a delayed job executed. Testing a queue dispatch proves that a timeout can be sent. It does not prove that a stale timeout is harmless when it arrives.

The compensation cases use the same boundary. RefundFailed moves a refunding process to manual_review and preserves provider_down. A shipment booked while refunding also moves to manual review, but stores the late shipment ID and owes ReconcileLateShipment. These are business decisions. Mocking a refund client or shipment repository adds ceremony without making either decision clearer.

Pure transition tests deliberately know nothing about SQL. That is also their limit. They cannot prove that the chosen state survives a commit.

Atomicity needs a real database

The handler owns a second contract. For one accepted message, four durable facts must agree:

process state and version
inbox receipt
outbox commands
message metadata used for diagnosis

The fixture writes them in one SQLite transaction. Laravel’s database transaction API provides the same local commit-or-rollback shape; the important part is observing the real database mechanism rather than mocking beginTransaction() and commit().

After PaymentCaptured, the integration test reads the tables back. It finds state reserving_inventory, version one, payment payment-1, inbox message event-1, and outbox command reserve-order-1. A controlled exception before commit leaves the original awaiting_payment row at version zero and leaves both inbox and outbox empty.

Move either inbox or outbox outside the transaction and the corresponding mutation fails. A mock which merely saw all three repository methods cannot prove this property. Transaction membership is behavior only the database can settle.

The same applies to concurrency. The fixture updates with:

UPDATE fulfilment_processes
SET state = :state,
    version = version + 1
WHERE process_id = :process_id
  AND version = :expected_version

A writer expecting version zero is rejected after another writer commits version one. No second inbox or outbox row survives the failed attempt. Remove the version predicate and that control fails.

This is one optimistic policy, not a universal preference. Laravel also supports pessimistic locking. A lock may be the better choice when all contenders share one database and holding it across the local decision is acceptable. An expected version fits when conflicts should be detected explicitly or work happens outside one lock scope. The test should observe whichever concurrency contract the application actually chooses.

Repair is another guarded transition

“Replay the event” is a dangerously vague operator instruction. The process may have moved since the failure, and the old message may no longer describe a valid action.

The fixture models repair as a new command with an expected state, operator ID, reason, action, and idempotency key. From manual_review, an accepted retry_shipment repair moves the process to booking_shipment, records operator-7 and carrier confirmed capacity, and creates one BookShipment command named repair-1.

If the process is already completed, the same repair is rejected. State, inbox, and outbox remain untouched. Removing the expected-state guard makes the mutation fail.

The fixture proves this selected repair contract, not that retrying shipment is correct for every fulfilment system. That policy needs the real business decision. The reusable testing point is narrower: operator actions should have typed preconditions and observable durable consequences.

Use each boundary for the defect it can see

The resulting suite is intentionally uneven.

The state graph belongs in pure transition tests. They cover stale and semantic duplicates, timeout races, compensation failure, late success, and repair decisions without collaboration mocks.

A smaller set uses a real database. Those tests cover mapping, atomic inbox and outbox writes, rollback, uniqueness, versions, and repair persistence.

A still smaller end-to-end set can prove that the chosen queue serializes a message, reaches the handler, and publishes committed work. That wider boundary is less precise when a transition is wrong.

Protocol mocks still have a place when call order is itself part of an external contract. A signed request that must fetch a challenge before submitting a response is not the same as a repository whose save and recordInbox calls may safely change order inside one transaction. Mock the protocol when the protocol is observable behavior. Do not promote every implementation sequence to a contract.

For a much larger graph, named examples may stop covering meaningful event sequences. Property tests or model checking can then explore invariants such as “completed never returns to active.” That is a reason to widen the state testing technique, not to return to handler choreography.

A passing call script is weak evidence

The fixture passes fourteen behavior cases with 60 assertions. Ten isolated mutations prove that those assertions notice old-state persistence, transaction leaks, missing version protection, duplicate handling errors, stale timeout acceptance, lost compensation evidence, unsafe repair, and call-order coupling. A clean archive of the committed fixture reproduces the same semantic results.

Those counts are diagnostics, not a claim about coverage percentage or team productivity. The useful evidence is which defect each boundary detects.

Start a Process Manager test with one question: after this message, what must be durably true? Put the state decision in a transition test. Put atomicity and concurrency in a real-database test. Put only transport crossing in an end-to-end test. Keep a mock only when the interaction sequence itself is the contract.

That arrangement lets harmless refactoring remain harmless—and makes a green test much harder to earn while the process is still in the wrong state.