State Transition Testing With Practical Examples

8 min read

Most non-trivial software is a state machine in disguise. An order moves from cart to paid to shipped to delivered. A subscription moves from trial to active to cancelled. A bug ticket moves from new to triaged to fixed to closed. Each move is a transition, and the bugs that cause real-world incidents almost always live in the transitions — not in the states themselves. State transition testing is the technique for finding those bugs systematically, before a customer hits one.

The four ingredients

State transition testing has only four moving parts:

  • A state is a condition the system can be in. "Cart" is a state. "Paid" is a state. "Shipped" is a state.
  • A transition is a move from one state to another. "Paid → Shipped" is a transition.
  • An event is what triggers the transition. "Warehouse confirms dispatch" is the event that causes "Paid → Shipped."
  • An action is what the system does as a result. "Send shipping confirmation email" is the action.

If you can name the four for any feature, you can test it. The work is mostly in being honest about which transitions the system should allow and which it should not.

State transition diagrams

The fastest way to design tests is to draw the system as a diagram — circles for states, arrows for transitions, labels on the arrows for the event that triggers each. The diagram below shows a typical e-commerce order lifecycle. Green arrows are valid transitions. Coral (red-tinted) arrows are transitions that must not be possible — and that you must explicitly test the system rejects.

The two ❌ illegal arrows are the ones that catch the most production bugs. A "Shipped" order should not be able to go back to "Cart" — and yet it can, in surprising number of real systems, if the developer forgot to lock the order at the right moment.

State transition tables

Once the diagram is drawn, convert it into a table. Each row is one (current state, event) pair. Each row records the expected next state — or the explicit fact that the event should be rejected.

Current stateEventExpected next state
CartCheckoutCheckout
CheckoutPay (auth ok)Paid
CheckoutPay (auth fail)Checkout (with error)
PaidDispatchShipped
PaidCancel by userRefunded (rules permitting)
ShippedMark deliveredDelivered
ShippedModify cartRejected — order locked
DeliveredMark shippedRejected — illegal back-transition
DeliveredInitiate returnRefunded

Every row in this table is a test case. The "rejected" rows are the highest-value: they verify the system actively blocks illegal transitions, not that it happens not to encounter them.

Valid versus invalid transitions

A common mistake is testing only the valid arrows. Half the value of this technique lies in deliberately testing the invalid ones. For each illegal transition, ask three questions:

  • What should the system do? Reject silently? Reject with a clear error message? Throw an exception? Log? Send an alert?
  • What should the state actually be after the attempt? Unchanged is the usual answer — the illegal event should leave the order exactly as it was.
  • What side effects, if any, should occur? A blocked transition should not have triggered emails, webhooks, or audit-log entries that imply success.

If two out of three behaviours are wrong, you have a bug. In production, "the system silently accepted the illegal move and the order is now in a corrupt state" is an entire category of incident that this discipline exists to prevent.

A real example: the dangerous transitions

A retailer's order system shipped a refactor of the warehouse integration. The new code re-marked an order as "Shipped" every time the warehouse confirmed dispatch — even if the order was already in "Delivered." The redundant transition triggered a second shipping email and a second tracking entry. Customers received "Your order has shipped!" the day after they had already received the package. The bug was caught in a state transition test that explicitly attempted Delivered → Shipped and verified the system would reject it.

That single test case took five minutes to write and prevented thousands of confused support tickets. None of the team's existing happy-path tests would have caught it, because none of them ever tried the illegal arrow.

⚠️ Common mistakes

  • Testing only the happy-path arrows. The valid transitions are the easy half of the work. The illegal ones are where production-breaking bugs live, and they are the half most testers skip.
  • Forgetting the rejection behaviour. "The illegal transition was rejected" is not enough — how it was rejected matters. A silent rejection that leaves no audit trail is itself a bug in any system that needs traceability.
  • Drawing the diagram once and never revising it. Real systems gain new states (Paused, On hold, Awaiting fraud check) over time. A state diagram from a year ago is a fiction; treat the diagram as a living artefact and update it whenever a new state appears in the code.

🎯 Practice task

Pick a stateful feature you understand well — a job application tracker, a subscription, a bug ticket, a hotel booking. Spend 25 minutes mapping its states.

  1. List 5–8 states the entity can be in.
  2. Draw a state diagram (paper, whiteboard, or a free tool — Excalidraw works) with arrows and event labels for every valid transition.
  3. Identify at least three illegal transitions that the system must reject. Mark them in red.
  4. Convert the diagram into a state transition table — one row per (state, event) pair, including the rejection rows.
  5. Pick the most dangerous illegal transition and write its full test case: preconditions, the event, expected rejection behaviour, and the expected post-state and side effects.

The next lesson uses a complementary technique — decision tables — for features where the bugs come from combinations of inputs rather than transitions between states.

// tip to track lessons you complete and pick up where you left off across devices.