You probably test a state machine by walking the paths you believe are legal: an order moves from pending to paid, then to shipped, and finally to delivered. That test passes and tells you very little, because the failures that actually freeze a service tend to come from the cells you never filled in. A missing transition is worse than a wrong transition; the wrong one at least throws an exception you can find in a log, while the undefined one often falls through to a default branch that silently returns the current state and reports success.
The problem grows when the state machine itself was drafted by a model. A model is good at producing the transitions that follow from a natural-language description, but it rarely asks what should happen when a cancel event arrives after the order has already shipped. It leaves the grid incomplete and your code often patches the gap with a broad else clause, which is precisely the place where a real payment system starts drifting from the order history your customer service team sees.
Start from the contract rather than from a vague request to attack the machine. You are not looking for a broken state; you are looking for an unhandled event. A useful test clarifies that every combination of state and event must have an explicit owner, even when the owner is a rejection. That clarity is much easier to produce when you have cheap compute for the enumeration and a free model to suggest which of the missing cells should refuse the event instead of handling it.
Disclosure: This article was prepared as part of MonkeyCode's product outreach.
With MonkeyCode's free model access and free server option, you can afford to run the entire cross-product instead of sampling a few happy paths, but the value still depends on the distinction you force the model to make. Ask it to list events that must be rejected for each state, not just events that move the machine forward. The model's guesses are then turned into a table your test can verify.
Here is a small state machine with a deliberately incomplete transition map. The important part is that the missing cells are not obvious from reading the code; you only see them when you enumerate the grid with itertools.product:
from itertools import product
State = str
Event = str
# Explicit transition table: (current_state, event) -> next_state
transitions: dict[tuple[State, Event], State] = {
("pending", "pay"): "paid",
("pending", "cancel"): "cancelled",
("paid", "ship"): "shipped",
("paid", "cancel"): "cancelled",
("shipped", "deliver"): "delivered",
("delivered", "return"): "returned",
("cancelled", "refund"): "refunded",
("returned", "refund"): "refunded",
}
states = ["pending", "paid", "shipped", "delivered", "cancelled", "returned", "refunded"]
events = ["pay", "cancel", "ship", "deliver", "return", "refund", "update_address"]
# Everything outside the explicit table is initially undefined.
undefined = []
for state, event in product(states, events):
if (state, event) not in transitions:
undefined.append((state, event))
print(undefined)
That printout will include combinations like ("shipped", "cancel"), ("delivered", "cancel"), and ("refunded", "pay"). None of those are wrong states, and none of them would cause a crash on a clean input. They are simply absent from the machine's vocabulary, and production traffic will eventually submit one of them.
The next step is to have the free model classify each undefined cell. The model should not choose a next state by guessing what feels plausible; it should place each combination into one of two buckets: an explicit target state, or an explicit rejection. The distinction matters because a rejection is also a decision. If the model says that a cancel on a shipped order should leave the state unchanged, you need to write that as an assertion rather than let a default branch do it for you. This is the difference between a precise state machine and a collection of transitions wrapped in an else.
The harness then turns the model's classification into a checkable table:
rejections = {
("shipped", "cancel"),
("delivered", "cancel"),
("refunded", "pay"),
("cancelled", "ship"),
("paid", "update_address"),
("pending", "ship"),
("delivered", "pay"),
}
for state, event in product(states, events):
handled = (state, event) in transitions or (state, event) in rejections
if not handled:
raise AssertionError(f"unhandled cell: {state} x {event}")
A free server is the right place to run this loop during a review or a pre-merge check, because the loop is embarrassingly parallel and the inputs are tiny. You are not executing business logic; you are verifying that no cell in the state-event grid has been left to an implicit default. That verification is cheap, repeatable, and far more revealing than a test that only confirms the path from pending to delivered.
The model's most useful contribution is not writing the transitions; it is proposing the cells that should be rejected before somebody in product asks why a cancelled order can still be shipped. In practice, the free model will sometimes label a missing cell as a rejection even when the business rule later changes, and it will occasionally suggest a new target state that sounds reasonable but conflicts with your existing history. You must review each classification because the model only knows the vocabulary you gave it; it cannot know whether a refund after delivery needs a separate returned state before it is allowed.
There are limits to this approach. The cross-product becomes impractical when the number of states and events is large, because the table grows multiplicatively. In that case you should split the machine into smaller sub-machines or sample the boundary with property-based tests. The harness also proves completeness of the event vocabulary, not that the transition targets are correct in the financial sense. A fully specified state machine can still route a refund to the wrong ledger, so the assertion is necessary but not sufficient.
You should not rely on this workflow when events are open-ended, when new events arrive from a third-party system without a fixed schema, or when the state space is so large that every generated rejection table is immediately out of date. The method is also less useful if your current implementation does not use a central transition function, because then there is no single place where an unhandled event can be caught.
The reason this test belongs before deployment is that it forces the undefined cells out of the code review and into an assertion. A state machine that only handles the happy path looks complete in a diff, especially when the missing branches are hiding behind a default. Running the full state-event cross-product through a free server turns those quiet gaps into printed omissions, and that written record is what prevents a weekend incident when a previously unthinkable event finally arrives in the queue.
Top comments (0)