State Transition Testing¶
State transition testing applies when the response to an event depends on what happened before. The system is modelled as states and transitions, and tests are derived from the model rather than from individual inputs.
Use it whenever the same action gives different results at different times: order lifecycles, payment flows, login attempts and lockout, subscriptions, connection handling, document approval.
The model¶
stateDiagram-v2
[*] --> Draft
Draft --> Placed: submit
Placed --> Paid: payment succeeds
Placed --> Cancelled: cancel
Paid --> Shipped: dispatch
Paid --> Refunded: refund
Shipped --> Delivered: delivery confirmed
Shipped --> Returned: return requested
Delivered --> [*]
Cancelled --> [*]
Four things come from this picture, and each is a class of test:
| Element | Test question |
|---|---|
| States | Is each state reachable, and correct when reached? |
| Transitions | Does each arrow work? |
| Events in the wrong state | Is each missing arrow rejected? |
| Guards | Does a conditional transition respect its condition? |
The state table¶
The transition diagram shows what is allowed. The table shows everything, which is where the defects hide.
| State \ Event | submit | pay | cancel | ship |
|---|---|---|---|---|
| Draft | Placed | invalid | Draft deleted | invalid |
| Placed | invalid | Paid | Cancelled | invalid |
| Paid | invalid | invalid: double charge | Refunded | Shipped |
| Shipped | invalid | invalid | invalid: already gone | invalid |
The bold cells are the valuable tests. Most state defects are not a broken transition, they are an accepted transition that should have been refused. "Pay an already paid order" and "cancel a shipped order" are real incidents, not theoretical ones.
Coverage levels¶
graph LR
T0["0-switch: every single transition once"] --> T1["1-switch: every pair of consecutive transitions"]
T1 --> T2["2-switch and beyond: longer sequences"]
T0 --> INV["Invalid events: every empty cell in the table"]
| Level | Covers | Cost |
|---|---|---|
| All states | Each state entered at least once | Weakest, misses most transitions |
| 0-switch | Each transition once | The normal target |
| 1-switch | Each valid pair of transitions in sequence | Catches defects that need two steps |
| Invalid events | Each state and event combination that must be refused | Where the incidents live |
0-switch plus invalid events is a good default. Go to 1-switch for money, safety or anything with a retry loop, since order-dependent bugs need at least two steps to appear.
Guards and extended state¶
Real systems carry data alongside the state, and transitions carry conditions.
stateDiagram-v2
[*] --> Active
Active --> Locked: failed login [attempts >= 3]
Active --> Active: failed login [attempts < 3]
Locked --> Active: unlock after timeout
Test both sides of every guard, which is boundary value analysis applied to the transition condition: attempts at 2 and at 3, not at 1 and 7.
Practice notes¶
- Build the model from the requirement, then check it against the code. A disagreement is either a defect or an undocumented state, and both are worth knowing.
- Look for states the model forgot. "Payment pending with the provider" and "partially refunded" are the ones teams discover in production.
- Test the timeouts. Anything that leaves a state on its own after a delay is a transition with no event, and it is routinely untested.
- Reject rather than ignore. An invalid event should produce a clear error, not a silent no-op, otherwise the caller cannot tell what happened.
Check Your Understanding¶
Which tests derived from a state model most often prevent real incidents?
Why choose 1-switch coverage over 0-switch for a payment flow?