State Transition Testing: Deriving Test Cases from the Order State Machine, Invalid Transitions and 0-Switch/1-Switch Coverage
State transition testing derives test cases from the state diagram and the state table when a system's behavior depends on its history: valid transitions (0-switch), invalid transitions in the table's empty cells, and pairs of consecutive transitions (1-switch). For Bookstore's order, three green tests missed that a shipped order could be cancelled and that an order released from a hold couldn't be shipped; 40 invalid transitions and 10 transition pairs caught both.
Version note: The code in this post was built and run with .NET 10, xUnit.net v3 4.0.3 and Microsoft.Extensions.TimeProvider.Testing 11.0.0.
Guess First
In Bookstore, the online bookstore used as the shared example across this site's series, an order's life cycle is a state machine. An order is born Created, becomes Paid when it's paid for, Shipped when it's handed to the carrier, and Delivered when it reaches the customer. An order that's paid but not yet shipped can be cancelled, and the money is refunded. Within 14 days of delivery the customer can return the book. There is also a warehouse state: if a stock count shows the book isn't on the shelf, the order is put OnHold and its stock reservation is dropped; when the book arrives from the supplier, Release makes it Paid again.
Ayşe has written the code as a single Fire method with a switch over a tuple:
namespace Bookstore.Ordering;
public enum OrderState
{
Created,
Paid,
OnHold,
Shipped,
Delivered,
Cancelled,
Returned,
}
public enum OrderEvent
{
Pay,
Hold,
Release,
Ship,
Deliver,
Cancel,
Return,
}
public sealed class Order(TimeProvider clock)
{
public static readonly TimeSpan ReturnWindow = TimeSpan.FromDays(14);
private readonly List<string> _actions = [];
private bool _stockReserved;
private DateTimeOffset? _deliveredAt;
public OrderState State { get; private set; } = OrderState.Created;
public IReadOnlyList<string> Actions => _actions;
public void Fire(OrderEvent e)
{
switch (e, State)
{
case (OrderEvent.Pay, OrderState.Created):
_stockReserved = true;
State = OrderState.Paid;
break;
case (OrderEvent.Hold, OrderState.Paid):
_stockReserved = false;
State = OrderState.OnHold;
break;
case (OrderEvent.Release, OrderState.OnHold):
State = OrderState.Paid;
break;
case (OrderEvent.Ship, OrderState.Paid):
if (!_stockReserved)
{
throw new InvalidOperationException("Cannot ship an order without a stock reservation.");
}
_stockReserved = false;
State = OrderState.Shipped;
break;
case (OrderEvent.Deliver, OrderState.Shipped):
_deliveredAt = clock.GetUtcNow();
State = OrderState.Delivered;
break;
case (OrderEvent.Cancel, not (OrderState.Delivered or OrderState.Cancelled or OrderState.Returned)):
if (State != OrderState.Created)
{
_actions.Add("Refund");
}
_stockReserved = false;
State = OrderState.Cancelled;
break;
case (OrderEvent.Return, OrderState.Delivered) when clock.GetUtcNow() - _deliveredAt <= ReturnWindow:
_actions.Add("Refund");
State = OrderState.Returned;
break;
default:
throw new InvalidOperationException($"{e} is not allowed when the order is {State}.");
}
}
}
The team's tests try the order's normal flow, cancelling a paid order, and coming back from a hold:
// The team's existing tests: the normal flow, a cancellation and a hold
[Fact]
public void Order_goes_from_created_to_delivered()
{
Order order = new(_clock);
order.Fire(OrderEvent.Pay);
order.Fire(OrderEvent.Ship);
order.Fire(OrderEvent.Deliver);
Assert.Equal(OrderState.Delivered, order.State);
}
[Fact]
public void Paid_order_can_be_cancelled_with_a_refund()
{
Order order = new(_clock);
order.Fire(OrderEvent.Pay);
order.Fire(OrderEvent.Cancel);
Assert.Equal(OrderState.Cancelled, order.State);
Assert.Equal(["Refund"], order.Actions);
}
[Fact]
public void Held_order_is_released_back_to_paid()
{
Order order = new(_clock);
order.Fire(OrderEvent.Pay);
order.Fire(OrderEvent.Hold);
order.Fire(OrderEvent.Release);
Assert.Equal(OrderState.Paid, order.State);
}
Test run summary: Passed!
total: 3
failed: 0
succeeded: 3
skipped: 0
duration: 281ms
All three tests are green. Two questions: what happens if you try to cancel an order that has already been shipped? Can an order that comes back from a hold be shipped?
The Answer: Two Bugs in the Code, No Red in the Tests
The shipped order gets cancelled. Instead of listing the states where cancelling is allowed, the Cancel arm lists the forbidden ones and forgets to put Shipped on the list. The result: the book is on its way, the money has been refunded, and the order is Cancelled. A bug like this is noticed in the logs or in accounting, weeks later.
The order that comes back from a hold, on the other hand, can't be shipped. Release sets the state to Paid but doesn't restore the stock reservation that Hold dropped; the next Ship throws an exception. The warehouse clerk gets an error the moment they say "ship this order".
The existing tests walk right past both bugs, because they only try the flows that are supposed to work. In a state machine, bugs usually sit in two places: in transitions that should never be allowed, and where transitions follow one another in a particular order. This post's technique, state transition testing, exists to test exactly those two places systematically.
State, Event, Guard, Action
The ISTQB CTFL v4.0.1 syllabus describes state transition testing on top of a state diagram. The diagram shows the system's states and the valid transitions between them. An event starts a transition; the event may carry a guard condition, and the system may run an action during the transition. The common way to label arrows is event [guard] / action. In our order, "Return [≤ 14 days] / Refund" carries all three: the event is Return, the guard is the time since delivery, the action is the refund.
We draw the diagram as in the UML series' "State Machine Diagrams" post; that post took the order's life cycle from the diagram to code, and we'll derive test cases from the same diagram:

The diagram has seven states and nine valid transitions. But the diagram shows only what's allowed. This is where the syllabus moves to a second model, the state table: rows are states, columns are events, cells are target states. It carries the same information as the diagram, with one difference: empty cells explicitly show the invalid transitions.
| State | Pay | Hold | Release | Ship | Deliver | Cancel | Return |
|---|---|---|---|---|---|---|---|
| Created | Paid | Cancelled | |||||
| Paid | OnHold | Shipped | Cancelled / Refund | ||||
| OnHold | Paid | Cancelled / Refund | |||||
| Shipped | Delivered | ||||||
| Delivered | [≤ 14 days] Returned / Refund | ||||||
| Cancelled | |||||||
| Returned |
Of forty-nine cells, nine are filled and forty are empty. The Cancel column of the Shipped row is empty: a shipped order can't be cancelled. The requirement said this as "an order that hasn't been shipped yet can be cancelled"; the table says the same thing as a single cell, in a testable form. The Cancelled and Returned rows are completely empty; these are final states.
Three Coverage Criteria
The syllabus defines three coverage criteria for state transition testing, and the relationship between them directly determines the number of tests.
In all states coverage, the coverage items are the states: each state must be visited at least once. For our diagram two test cases are enough: the sequence Pay, Hold, Release, Ship, Deliver, Return visits the six states other than Cancelled, and a single Cancel covers the seventh. This criterion is weak; it reaches 100 percent without ever running three of the nine transitions.
In valid transitions coverage, also called 0-switch coverage, the coverage items are the individual valid transitions. All nine transitions must run at least once. According to the syllabus, this is the most widely used criterion. It can be completed with four test cases using long sequences; we'll write each transition as a separate test, because we want to see at a glance which transition a red test belongs to.
In all transitions coverage, the coverage items are all cells of the state table: the nine valid transitions must run, and the forty invalid transitions must be attempted. The syllabus recommends attempting only one invalid transition per test case; the masking we saw with equivalence partitioning in the second post applies here too. The syllabus also sees this criterion as a minimum for mission- and safety-critical software. An order flow that refunds money sits close to that class.
From the State Table to TheoryData
The source of the tests is the diagram, not the code. In the previous post we saw that copying tests from the code's rule table means writing the same bug twice; here too we write the model in the test project, independently, from the diagram:
namespace Bookstore.Ordering.Tests;
// The model taken from the UML state machine diagram; from the diagram, not from the code
public static class OrderStateModel
{
public sealed record Transition(OrderState From, OrderEvent Event, OrderState To, bool Refunds = false);
// Valid transitions: event [guard] / action
public static readonly Transition[] Valid =
[
new(OrderState.Created, OrderEvent.Pay, OrderState.Paid),
new(OrderState.Created, OrderEvent.Cancel, OrderState.Cancelled),
new(OrderState.Paid, OrderEvent.Hold, OrderState.OnHold),
new(OrderState.Paid, OrderEvent.Ship, OrderState.Shipped),
new(OrderState.Paid, OrderEvent.Cancel, OrderState.Cancelled, Refunds: true),
new(OrderState.OnHold, OrderEvent.Release, OrderState.Paid),
new(OrderState.OnHold, OrderEvent.Cancel, OrderState.Cancelled, Refunds: true),
new(OrderState.Shipped, OrderEvent.Deliver, OrderState.Delivered),
new(OrderState.Delivered, OrderEvent.Return, OrderState.Returned, Refunds: true), // [within 14 days of delivery]
];
// The shortest event sequence from Created to each state
public static readonly Dictionary<OrderState, OrderEvent[]> ShortestPath = new()
{
[OrderState.Created] = [],
[OrderState.Paid] = [OrderEvent.Pay],
[OrderState.OnHold] = [OrderEvent.Pay, OrderEvent.Hold],
[OrderState.Shipped] = [OrderEvent.Pay, OrderEvent.Ship],
[OrderState.Delivered] = [OrderEvent.Pay, OrderEvent.Ship, OrderEvent.Deliver],
[OrderState.Cancelled] = [OrderEvent.Cancel],
[OrderState.Returned] = [OrderEvent.Pay, OrderEvent.Ship, OrderEvent.Deliver, OrderEvent.Return],
};
}
ShortestPath is an important decision: to bring an order to Shipped we don't assign a value to the State property, we run the real events in order. The test assumes the order arrives at that state the way it does in production. The tests are generated from these two tables:
using Microsoft.Extensions.Time.Testing;
using static Bookstore.Ordering.Tests.OrderStateModel;
namespace Bookstore.Ordering.Tests;
public sealed class OrderStateTransitionTests
{
private readonly FakeTimeProvider _clock = new(new DateTimeOffset(2026, 11, 2, 9, 0, 0, TimeSpan.Zero));
private Order OrderIn(OrderState state)
{
Order order = new(_clock);
foreach (OrderEvent e in ShortestPath[state])
{
order.Fire(e);
}
return order;
}
// 0-switch (valid transitions) coverage: each valid transition once
public static TheoryData<OrderState, OrderEvent, OrderState, bool> ValidTransitions =>
new(Valid.Select(t => (t.From, t.Event, t.To, t.Refunds)));
[Theory]
[MemberData(nameof(ValidTransitions))]
public void Valid_transition_moves_the_order_to_the_target_state(
OrderState from, OrderEvent e, OrderState to, bool refunds)
{
Order order = OrderIn(from);
int refundsBefore = order.Actions.Count(a => a == "Refund");
order.Fire(e);
Assert.Equal(to, order.State);
Assert.Equal(refunds ? 1 : 0, order.Actions.Count(a => a == "Refund") - refundsBefore);
}
// Empty cells of the state table: 7 states x 7 events - 9 valid transitions = 40 invalid transitions
public static TheoryData<OrderState, OrderEvent> InvalidTransitions =>
new(from state in Enum.GetValues<OrderState>()
from e in Enum.GetValues<OrderEvent>()
where !Valid.Any(t => t.From == state && t.Event == e)
select (state, e));
[Theory]
[MemberData(nameof(InvalidTransitions))]
public void Invalid_transition_is_rejected_and_the_state_does_not_change(OrderState from, OrderEvent e)
{
Order order = OrderIn(from);
Assert.Throws<InvalidOperationException>(() => order.Fire(e));
Assert.Equal(from, order.State);
}
}
We don't write the invalid transitions one by one; we generate the empty cells of the state table, that is, the full product of states and events minus the valid ones. So when a transition is added to the diagram the invalid list shrinks by itself, and when a state or event is added it grows by itself. An invalid transition makes two claims: an exception is thrown, and the state doesn't change. The second matters at least as much as the first; code that changes the state before throwing reports an error and still corrupts the order. The TheoryData mechanics are covered in the Unit Test series' "Data-Driven Tests in xUnit v3" post. Against the old code:
failed Bookstore.Ordering.Tests.OrderStateTransitionTests.Invalid_transition_is_rejected_and_the_state_does_not_change(from: Shipped, e: Cancel) (0ms)
Assert.Throws() Failure: No exception was thrown
Expected: typeof(System.InvalidOperationException)
Test run summary: Failed!
total: 108
failed: 1
succeeded: 107
skipped: 0
duration: 252ms
One of the forty invalid transitions is red: cancelling the shipped order. All nine valid transitions are green; 0-switch coverage is 100 percent and the second bug is still invisible.
1-Switch Coverage: The Code Has More States Than the Diagram
The 0-switch tests ran each transition once, but each one with the order arriving at that state by the shortest route. We always reached Paid through Pay, never through Release. And that's exactly where the bug is: the code has two different Paids. In the Paid that comes from Pay the stock is reserved; in the Paid that comes from Release it isn't. What is a single state in the diagram is two different combinations of the State and _stockReserved fields in the code.
To find bugs like this we go one step further and look at pairs of transitions. The idea of N-switch coverage goes back to Tsun S. Chow's 1978 paper in IEEE Transactions on Software Engineering on testing designs modeled as finite-state machines: 0-switch means single transitions, 1-switch means sequences of two consecutive transitions, and N-switch means sequences of N+1 transitions. The ISTQB glossary uses the term in this sense; the CTFL v4.0.1 syllabus, at Foundation level, covers only the three criteria above. The value of 1-switch is that it asks whether a transition's behavior depends on which transition brought the order to that state. In correct code it shouldn't.
We generate the pairs from the model too: every transition leaving the target state of the first transition forms a pair. Nine transitions yield ten pairs:
// 1-switch coverage: every pair of two consecutive valid transitions
public static IEnumerable<TheoryDataRow<OrderEvent[], OrderState>> TransitionPairs =>
from first in Valid
from second in Valid
where second.From == first.To
select new TheoryDataRow<OrderEvent[], OrderState>(
[.. ShortestPath[first.From], first.Event, second.Event], second.To)
{
Label = $"{first.Event} -> {second.Event}",
};
[Theory]
[MemberData(nameof(TransitionPairs))]
public void Pair_of_transitions_ends_in_the_expected_state(OrderEvent[] events, OrderState expected)
{
Order order = new(_clock);
foreach (OrderEvent e in events)
{
order.Fire(e);
}
Assert.Equal(expected, order.State);
}
We run only the Ordering test project (dotnet test --project tests/Bookstore.Ordering.Tests):
failed Bookstore.Ordering.Tests.OrderStateTransitionTests.Pair_of_transitions_ends_in_the_expected_state [Release -> Ship] (0ms)
Xunit.MicrosoftTestingPlatform.XunitException: System.InvalidOperationException : Cannot ship an order without a stock reservation.
failed Bookstore.Ordering.Tests.OrderStateTransitionTests.Invalid_transition_is_rejected_and_the_state_does_not_change(from: Shipped, e: Cancel) (0ms)
Assert.Throws() Failure: No exception was thrown
Expected: typeof(System.InvalidOperationException)
Test run summary: Failed!
total: 91
failed: 2
succeeded: 89
skipped: 0
duration: 304ms
Seven of the ten pairs were sequences the 0-switch tests already ran, because the ShortestPath prefixes contain them naturally. The three new ones are those starting with Release, and the red one is the Release and Ship pair. A note: had we chosen to complete 0-switch coverage with four long test cases, the sequence Pay, Hold, Release, Ship could have caught this bug too. The criterion doesn't guarantee it; the guarantee is in 1-switch. The fix is two lines:
case (OrderEvent.Release, OrderState.OnHold):
_stockReserved = true;
State = OrderState.Paid;
break;
case (OrderEvent.Cancel, OrderState.Created or OrderState.Paid or OrderState.OnHold):
The second line turns the Cancel arm from listing the forbidden states into listing the allowed ones. If a new state is added to the diagram tomorrow, say ReturnedToSender for parcels sent back by the carrier, the old pattern would silently treat it as cancellable; the new one doesn't.
Testing a Guard Condition at Its Boundary
The guard on the Return transition is a boundary: at most 14 days after delivery. A transition with a guard really has two outcomes, Returned if the guard holds and an invalid transition if it doesn't. Saying "Return works" with a single test tests half of the guard. We apply the boundary value analysis from the third post directly, advancing the clock with FakeTimeProvider:
// Guard: Return [at most 14 days after delivery]; the boundary and just past it
[Fact]
public void Return_is_allowed_exactly_14_days_after_delivery()
{
Order order = OrderIn(OrderState.Delivered);
_clock.Advance(Order.ReturnWindow);
order.Fire(OrderEvent.Return);
Assert.Equal(OrderState.Returned, order.State);
}
[Fact]
public void Return_is_rejected_one_tick_after_the_window()
{
Order order = OrderIn(OrderState.Delivered);
_clock.Advance(Order.ReturnWindow + TimeSpan.FromTicks(1));
Assert.Throws<InvalidOperationException>(() => order.Fire(OrderEvent.Return));
Assert.Equal(OrderState.Delivered, order.State);
}
These two tests passed on the old code too; the guard was written correctly. But we only know that after trying both sides of the boundary. The mechanics of testing time are in the Unit Test series' "Hard Cases in Unit Testing" post; what matters here is seeing that the guard is a partition boundary. After all the fixes:
Test run summary: Passed!
total: 120
failed: 0
succeeded: 120
skipped: 0
duration: 269ms
From Diagram to Code, From Code to E2E
These tests don't know Order's internals; they use only Fire and State. If you switched to an implementation that moves each state into its own object, as in the Design Patterns series' "State Pattern" post, not a single line would change in the test project. State transition testing is a black-box technique; it tests the model, not the implementation.
The same model also spreads across test levels. Fifty-nine unit tests, 9 + 40 + 10, run in milliseconds and cover every cell of the state machine. At the end-to-end level, one scenario is usually enough: the one showing that the Pay, Ship, Deliver sequence really works through the API, the database and the UI. Trying forty invalid transitions in a browser is both slow and unnecessary; their proof is at the unit level. How to write that scenario is the subject of the Integration and E2E Testing series.
Purchase approval for institutional orders is also a state machine that suits the same technique: a PurchaseApproval starts Pending; if the amount is below the approver's own limit it becomes Approved, above it it goes to a second approval, and if the approver rejects it it becomes Rejected. The guards here are the amount limit and the approver being someone other than the person who created the order; both are tested with boundary values. It's one of the exercises at the end of the post.
Production Notes
Keep the state table next to the requirement. The empty cells are where most of the discussion happens: the question "can a delivered order be cancelled?" only gets asked once the table is on the table. Confirming with Selin that every empty cell is deliberately empty answers in advance the "why can't we cancel this?" question that will come from customer service a week later.
Log invalid transitions. If InvalidOperationException is silently swallowed in production, a screen trying to cancel a shipped order or a double-clicked button never shows up. Logging which event was rejected in which state may be the first thing that reveals an untested flow.
Keep the state and the hidden fields together. The bug in this post came from the _stockReserved field living apart from the state. In a persistent system that field is a separate column in the database too, and it can drift out of sync with the state. If such fields exist, consider 1-switch tests together with an invariant test of the form "in this state, this field must have this value".
Events don't always arrive in order. The payment provider's webhook, the carrier's delivery notification and two clicks on the warehouse screen can reach the same order at almost the same moment. Even if the state machine itself is correct on a single thread, concurrent events need separate protection, optimistic concurrency or a single consumer. That is tested at the integration level, not the unit level.
Common Pitfalls
Testing only the normal flow. The three tests at the start of the post are the example: all correct, all green, and blind to both bugs. Most of a state machine's tests should be about invalid transitions; for this order there are forty invalid transitions against nine valid ones.
Covering a transition with a guard in a single test. A guard is a condition, and a condition has two sides. A test that says "can be returned within 14 days" tests the guard only when day 14 and just past it are both tried.
Assigning the state directly in tests. If we could write order.State = OrderState.Paid, the tests would be shorter, but we could never produce the Paid that comes from Release. Reaching a state through real events also sets up the code's hidden fields the way production does.
Deriving the model from the code. If you read the case arms in Order.Fire and fill in the state table, cancelling the shipped order goes into the table as a "valid" cell. The table is built from the requirement and the diagram.
Going to 1-switch for every state machine. Ten pairs are cheap for this diagram; in a workflow with thirty states and a hundred transitions, the number of pairs grows quickly. Using 1-switch around loops and places where hidden state is suspected pays off better than applying it everywhere.
A Small Experiment
★☆☆ Put the Cancel arm back to its old form, not (...), and add Shipped to the list. Which test turns green? Then add a new final state called ReturnedToSender to the diagram and to the enum. Does the old pattern allow cancelling in this new state? Which test catches it?
★★☆ Write an invariant test: reach every state both by the shortest route and via Release, and verify that a Paid order has a stock reservation. _stockReserved is private; which public behavior would you use in the test? How does this test differ from the 1-switch test?
★★★ Build the state diagram and the state table for PurchaseApproval: Pending, AwaitingSecondApproval, Approved, Rejected; events Approve and Reject; guards for the approver's limit and the approver being someone other than the order's creator. How many invalid transitions are there? Write the TheoryData that tests the guards with boundary values.
In this post the order's history changed the outcome. In the next post we move to a place where the outcome depends on combinations of many parameters that look independent of each other: browser, language, customer type, payment method and delivery type. Trying every combination means hundreds of tests; with pairwise testing and Microsoft's PICT tool we'll bring that down to around a dozen and catch a bug hiding in the interaction of two parameters.