Skip to content

State-Transition Testing

Every technique so far has quietly assumed a system with no memory. Equivalence partitioning and boundary value analysis map a single input to a single output. Decision tables handle several inputs at once, but still in one snapshot: given these conditions now, do this. Feed the same inputs again and you get the same result. That is the world of a pure function — and a great deal of software does not live there.

Consider a bank card at an ATM. Enter the wrong PIN and it says “try again.” Enter the wrong PIN twice more and the same input — a wrong PIN — now says “card blocked.” Nothing about the current input changed; what changed is the history. The system remembers. This is the defining mark of stateful software, and it quietly breaks the assumption every earlier technique leaned on: that a test case is a single (input → expected output) pair. Here the expected output is not a function of the input alone — it is a function of the input and everything the system has seen so far. When output depends not just on what you type now but on the sequence of things you typed before, a decision table is the wrong tool, because a decision table has no column for “what happened earlier.” You need a technique that treats history as a first-class thing to test. That technique is state-transition testing, and it earns its place in the test-design toolkit precisely for the software the earlier techniques cannot describe.

When you need it: output depends on history

Section titled “When you need it: output depends on history”

The trigger is a single question: does the same input ever produce different behaviour depending on what happened before? If yes, the system has state, and you should model it. Three everyday examples make the pattern concrete.

  • Login lockout. Three wrong passwords in a row locks the account. The third wrong password is treated differently from the first — same input, different outcome, because the system counted.
  • Order lifecycle. An order moves created → paid → shipped → delivered. You can cancel a created order; you must not “cancel” a delivered one, or “ship” one that was never paid. The legal action depends entirely on which state the order is in.
  • Media player. Press play on a stopped track and it starts. Press play on an already-playing track and it should do nothing (or pause, depending on the design). The button is the same; the meaning depends on the player’s state.

In each case the input alone under-determines the output. The missing variable is where the system currently is. State-transition testing makes that variable explicit and then tests it deliberately.

There is a deeper reason this matters. A stateless function has an input space you can partition and bound, and once you have covered the partitions you are done — the function cannot surprise you with a fourth dimension. A stateful system has a second axis entirely: for every input you must also ask from which state? The combined space is states × inputs, and it is almost always larger than teams expect. The only way to cover it deliberately, rather than by accident, is to write the state down. An undocumented state machine still exists — it is just living in the code, untested and unagreed. Drawing it is how you turn implicit, forgotten behaviour into something you can point at and check.

The model has exactly four ingredients. Get these named and you can draw the machine:

  1. States — the distinct “modes” the system can be in. For an order: created, paid, shipped, delivered, cancelled. A system is always in exactly one state.
  2. Events (inputs) — the things that happen to the system: a user action, a message, a timeout. pay, ship, deliver, cancel.
  3. Transitions — a rule of the form “in state S, event E takes you to state S′.” This is the heart of the model.
  4. Actions — the observable output a transition fires as a side effect: send a receipt, decrement stock, log an audit line. A transition may or may not have one.

Drawn out, an order lifecycle looks like this:

pay ship deliver
┌─────────┐ ───► ┌──────┐ ───► ┌─────────┐ ───► ┌───────────┐
│ created │ │ paid │ │ shipped │ │ delivered │
└─────────┘ └──────┘ └─────────┘ └───────────┘
│ cancel
┌───────────┐
│ cancelled │ (terminal — no event leaves it)
└───────────┘

Read a transition as a sentence: “In created, event pay moves to paid and fires the action charge card + email receipt.” The diagram is the specification of legal behaviour. Everything not drawn is, by implication, illegal — and the illegal cases are where the bugs hide.

Two modelling habits keep the diagram honest. First, mark initial and terminal states explicitly: a new order starts in created, and delivered and cancelled are terminal — no event leaves them. A terminal state with an outgoing arrow you did not intend (say, cancel out of delivered) is a bug you can see in the picture before writing a line of test code. Second, resist the urge to fold guard conditions into the state names. If “shipped” behaves differently depending on whether the item was insured, that is a hint you have two states hiding inside one label, or that a decision-table condition belongs on the transition — either way, make it visible rather than burying it.

Deriving tests: cover states, valid transitions, and illegal ones

Section titled “Deriving tests: cover states, valid transitions, and illegal ones”

A state diagram hands you your test cases almost mechanically — this is the quiet superpower of the technique. Where earlier methods asked you to invent representative inputs, here the model enumerates the cases for you: walk the arrows and you have your valid tests, walk the empty cells and you have your illegal ones. There are three coverage obligations, in increasing strictness.

Every state must be reached by at least one test. An unreached state in your suite is a mode of the system nobody exercised — and often a mode the developer half-forgot. Reaching cancelled forces you to test the one path that diverges from the happy line.

For each arrow in the diagram, write a test that: puts the system in the source state, fires the event, and asserts both the resulting state and the action. This is where you catch “the transition happens but the wrong action fires” — the card is charged but no receipt is sent, the order ships but stock is never decremented.

This is the part beginners skip and the part that pays. For every state, ask what happens for every event that is not a legal arrow out of it. What does deliver do to a created order? What does pay do to an already-paid one? The diagram says nothing should happen — the system should reject the event and stay put. Testing this is how you prove the system guards its states rather than blindly obeying whatever event arrives.

Happy path (valid transitions):
created ──pay──► paid ──ship──► shipped ──deliver──► delivered
Illegal transitions to test (a few of many):
created ──ship────► ✗ must reject, stay "created"
created ──deliver─► ✗ must reject, stay "created"
delivered──cancel──► ✗ must reject, stay "delivered"
paid ──pay─────► ✗ double-charge risk! must reject

That paid ──pay──► case is not academic. If the guard is missing, a retried request or a double-clicked button charges the customer twice. The illegal transition is the test that would have caught it.

Notice how naturally these three obligations layer. State coverage is the weakest — it only proves each mode is reachable. Valid-transition coverage is stronger — it proves each legal move behaves. Illegal-transition coverage is strongest and least glamorous — it proves the system says no when it should. Most teams stop after the first two because those are the tests the happy-path demo already suggests. The third is the one you must go looking for, and it is where state machines actually fail in production.

The state-transition table: making the undefined visible

Section titled “The state-transition table: making the undefined visible”

A diagram is easy to read but easy to under-specify — a missing arrow looks the same as a deliberately-forbidden one. The cure is to flip the diagram into a state-transition table: one row per state, one column per event, and a cell for every combination. Now every state/event pair has a box, and an empty box screams “you never decided what happens here.”

State \ Event │ pay ship deliver cancel
──────────────┼──────────────────────────────────────────
created │ paid — — cancelled
paid │ — shipped — —
shipped │ — — delivered —
delivered │ — — — —
cancelled │ — — — —

The grid has 5 states × 4 events = 20 cells. Only 4 are valid transitions (the happy path plus cancel). The other 16 are “impossible” combinations — and each one is a decision: is a dash “reject and stay” or “we never thought about it”? The table forces the question for all 16, where the diagram let you skip them. Every dash is a test case for an illegal transition; every valid entry is a test case for a valid one. The table is the test plan.

This is why experienced testers reach for the table even when the diagram is prettier. A diagram invites you to trace the lines that are there; a table confronts you with the boxes that are empty. The bug in a state machine is almost never on a line someone drew — it is in a combination nobody considered, and the table is the only representation that lists every combination whether or not anyone thought about it. Completeness is the feature.

Take the login lockout end to end, because it is small and its illegal transitions are the whole point. States: logged out, attempt-1-failed, attempt-2-failed, locked, logged in. Events: correct password, wrong password. A correct password from any un-locked state logs you in; a wrong password advances the failure counter; the third wrong password locks the account; and — critically — a correct password while locked must not log you in.

State \ Event │ correct pw wrong pw
─────────────────┼──────────────────────────────────
logged out │ logged in attempt-1-failed
attempt-1-failed│ logged in attempt-2-failed
attempt-2-failed│ logged in locked
locked │ ✗ stay locked ✗ stay locked
logged in │ — —

The interesting cells are the locked row. If either cell there accepts the correct password, the lockout is theatre — an attacker who guesses the PIN on the fourth try still gets in, and the whole point of counting was lost. That row is invisible on the happy path (a legitimate user never locks themselves out in a demo) and is exactly the pair of tests that proves the feature does its job. State-transition testing puts those two cells on your test list by construction, because the table has a box for them and the box is not empty by accident — it is a deliberate “reject.”

How thoroughly you test transitions is itself a dial, named by how many transitions in a row each test walks. The terminology (due to Chow’s “switch cover”) counts the transitions between the start and end of a test path. The names sound abstract; the idea is simply how long a sequence does one test drive? — one arrow, two arrows, or a longer chain.

  • 0-switch coverage — every single transition is exercised at least once. One arrow per test. This is the baseline and corresponds to filling the transition table.
  • 1-switch coverage — every pair of consecutive transitions is exercised. You test created→paid→shipped as a sequence, not just the two arrows in isolation.
  • N-switch coverage — every sequence of N+1 consecutive transitions. Higher N catches bugs that only appear after a particular history of moves.
0-switch: created ──pay──► paid (1 transition)
1-switch: created ──pay──► paid ──ship──► shipped (2 transitions in sequence)
N-switch: ... a chain of N+1 transitions ...

Why go past 0-switch? Because some bugs need a run-up. A player might handle play correctly from stopped and from paused in isolation, yet corrupt its buffer only on the specific sequence play → pause → play — a state its two isolated tests never visited together. 0-switch is cheap and catches most guard bugs; N-switch is expensive (paths multiply combinatorially) and reserved for logic where sequence-dependent bugs are both likely and costly.

A practical rule: reach for 0-switch by default — it fills the table and tests every guard — and escalate to 1-switch or higher only for the states where a history plausibly changes behaviour. The player’s transport controls, a checkout wizard that lets you go back a step, a connection that reconnects after dropping — these are the places a sequence bug lurks. A stateless-feeling machine whose transitions never interact rarely repays the N-switch cost.

State-transition testing exists to surface three failure families that other techniques structurally cannot see:

  • Forbidden transitions that are allowed. The missing guard. cancel succeeds on a delivered order; pay fires twice and double-charges. The illegal-transition tests are the direct antidote.
  • Unreachable states. A state exists in the code but no sequence of events can reach it — dead logic, or worse, a state you meant to be reachable but wired no path to. Drawing the diagram and finding a state with no inbound arrow reveals it before you write a single test.
  • Actions fired in the wrong state. The transition lands in the right state but triggers the wrong side effect — ships without charging, emails a receipt on cancel, decrements stock twice. Asserting the action (not just the resulting state) on every transition catches these.

There is a fourth, subtler failure worth naming: the event handled but silently dropped. The guard is present, the illegal event is correctly rejected — but the system swallows it with no error, no log, no signal to the caller that anything was refused. Functionally the state is protected, yet the caller may believe its action succeeded. A good illegal-transition test asserts not only “the state did not change” but also “the caller was told the event was rejected.” Rejection and silence look identical to the state machine; they look very different to the human who clicked cancel on a delivered order and got no feedback at all.

Under the hood — a guarded transition in code

Section titled “Under the hood — a guarded transition in code”

The whole model reduces to one idea in code: validate the transition before performing it. A state machine that guards its transitions rejects illegal events instead of blindly applying them. In Rust it often looks like a match on (current_state, event) where only the legal pairs return a new state and everything else returns an error:

enum State { Created, Paid, Shipped, Delivered, Cancelled }
enum Event { Pay, Ship, Deliver, Cancel }
fn next(state: State, event: Event) -> Result<State, &'static str> {
use State::*;
use Event::*;
match (state, event) {
(Created, Pay) => Ok(Paid), // + charge card, email receipt
(Created, Cancel) => Ok(Cancelled),
(Paid, Ship) => Ok(Shipped), // + decrement stock
(Shipped, Deliver) => Ok(Delivered),
// every other pair is an illegal transition:
_ => Err("illegal transition"),
}
}

The _ => Err(...) arm is the guard for all 16 illegal cells at once. A state-transition test suite is, precisely, a test for each Ok(...) arm (valid transitions, checking state and the commented action) plus a test that each illegal pair hits the Err arm and leaves the state unchanged. The bug is almost always a legal-looking arm that should not exist, or a missing guard that lets an event through — and both are visible only if your tests walk the table, not just the happy line.

The wildcard arm also carries a hidden danger the tests must probe: a catch-all _ is convenient precisely because it silently absorbs every case you did not enumerate — including ones you meant to make legal and forgot. A missing (Paid, Cancel) => ... arm, if refunds-before-shipping were supposed to be allowed, does not produce a compile error; it quietly falls into Err and a legitimate action is refused. Walking every valid cell of the table with a test is how you catch the opposite failure of an over-eager guard: not “an illegal event got through,” but “a legal event was wrongly turned away.” Both directions matter, and only the full table exercises both.

State-transition testing is the first technique in this part built for software that remembers. Pull back to altitude and the trade-offs come into focus.

  • Why does it exist? Because a large class of software has memory — the same input means different things depending on history — and stateless techniques like decision tables have no way to express “what happened before.” State-transition testing gives history a model you can test.
  • What problem does it solve? It systematically finds the bugs of stateful systems: forbidden transitions that slip through (double-charge, cancel-after-delivery), unreachable states, and actions fired in the wrong state — none of which a single-snapshot test can reach.
  • What are the trade-offs? You must first build and agree on the state model, which takes effort and can explode combinatorially (states × events, and worse at higher N-switch). In return you get a near-mechanical way to enumerate test cases and, critically, to see the illegal cases you’d otherwise never think to test.
  • When should I avoid it? When the system is genuinely stateless — output depends only on current inputs — reach for decision tables instead; modelling states there is wasted ceremony. It also strains when the real state is a huge continuous value (an account balance) rather than a small set of named modes.
  • What breaks if I remove it? Your suite tests only the happy path through a stateful system and ships the guards untested. Illegal transitions fire in production — orders cancel after shipping, PINs never lock, cards charge twice — because nothing ever asked “what does this event do in that state?”
  1. What single property of a system tells you to reach for state-transition testing instead of a decision table?
  2. Name the four ingredients of a state model, and for a transition say which two pieces of output you should assert in a test.
  3. For the order machine (5 states, 4 events), how many table cells are there, how many are valid transitions, and how many illegal transitions must you test for 0-switch coverage?
  4. Explain the difference between 0-switch and 1-switch coverage, and give a concrete bug that only 1-switch (or higher) could catch.
  5. List the three bug families state-transition testing is designed to find, and for each name the modelling or testing step that surfaces it.
Show answers
  1. That the same input produces different behaviour depending on history — i.e. the output depends on what happened before, not only on the current input. That memory is state, and modelling it is what state-transition testing does; a decision table has no way to represent “earlier.”
  2. States, events (inputs), transitions, and actions. On each transition test you should assert both the resulting state and the action/side effect it fired — asserting only the state misses “right state, wrong action” bugs like shipping without charging.
  3. 5 × 4 = 20 cells; 4 are valid transitions (pay, ship, deliver, cancel); the remaining 16 are illegal transitions that 0-switch coverage requires you to test (each must reject the event and stay in its state).
  4. 0-switch exercises every single transition once (one arrow per test); 1-switch exercises every pair of consecutive transitions (two arrows in sequence). A sequence-only bug — e.g. a media player that corrupts its buffer specifically on play → pause → play, though it handles each transition correctly in isolation — is invisible to 0-switch and caught by 1-switch.
  5. Forbidden transitions allowed — surfaced by writing an illegal-transition test for every non-arrow cell (e.g. paid ──pay──► double-charge). Unreachable states — surfaced by drawing the diagram/table and finding a state with no inbound arrow. Actions fired in the wrong state — surfaced by asserting the action, not just the resulting state, on every valid transition.