State Transition Testing: Model and Test Stateful Systems
Many systems maintain state that changes through defined transitions. An order progresses from pending to processing to shipped to delivered (or cancelled). A user account moves from active to suspended to closed. A checkout session starts as open, becomes locked during payment, then resolves to completed or abandoned.
State transition testing is a systematic technique for verifying these systems. It ensures every valid state is reachable, every valid transition works correctly, and invalid transitions are properly rejected.
Core Concepts
States
A state represents a distinct condition that the system can be in. States are distinguishable — the system behaves differently in each state, either accepting different inputs or producing different outputs.
Order states:
pending— order placed, awaiting payment confirmationconfirmed— payment confirmed, queued for fulfillmentprocessing— fulfillment in progressshipped— handed to carrierdelivered— confirmed receivedcancelled— cancelled before shipmentreturned— returned after deliveryrefunded— refund processed
Transitions
A transition is a change from one state to another, triggered by an event or action. Each transition has:
- Source state: Where we're coming from
- Event/trigger: What causes the transition
- Target state: Where we're going
- Guard condition: Optional condition that must be true for the transition to occur
- Action: Optional operation performed during transition
State Diagram
A state diagram (or state machine diagram) visualizes states as nodes and transitions as directed edges.
[pending] --payment_confirmed--> [confirmed]
[pending] --cancel--> [cancelled]
[confirmed] --start_fulfillment--> [processing]
[confirmed] --cancel--> [cancelled]
[processing] --ship--> [shipped]
[processing] --cancel--> [cancelled]
[shipped] --deliver--> [delivered]
[delivered] --return--> [returned]
[returned] --process_refund--> [refunded]State Transition Table
A state transition table represents the same information in tabular form. Rows are source states, columns are events. Each cell contains the target state or "invalid" for transitions that shouldn't be possible.
| payment_confirmed | cancel | start_fulfillment | ship | deliver | return | process_refund | |
|---|---|---|---|---|---|---|---|
| pending | confirmed | cancelled | — | — | — | — | — |
| confirmed | — | cancelled | processing | — | — | — | — |
| processing | — | cancelled | — | shipped | — | — | — |
| shipped | — | — | — | — | delivered | — | — |
| delivered | — | — | — | — | — | returned | — |
| returned | — | — | — | — | — | — | refunded |
| cancelled | — | — | — | — | — | — | — |
| refunded | — | — | — | — | — | — | — |
"—" means an invalid transition — the event should not be accepted in this state.
Coverage Criteria
State Coverage (N-switch 0)
Every state is visited at least once. Minimum coverage — ensures basic reachability.
Required test paths:
- pending → confirmed → ... (reach confirmed)
- pending → ... → shipped → delivered (reach delivered)
- pending → cancelled (reach cancelled)
- ... etc.
Transition Coverage (N-switch 1)
Every transition is exercised at least once. More thorough — verifies that each event/action pair works in its valid state.
For our order example: 9 transitions → minimum 9 test steps (covered in fewer paths since tests can chain transitions).
All-Transitions Pair Coverage (N-switch 2)
Every pair of consecutive transitions is exercised. Catches bugs in sequential state behavior — where a transition from state A works correctly when entering directly but fails when entering via a different path.
Invalid Transition Coverage
Attempts to perform an invalid transition are rejected appropriately. Often overlooked but critical for system security and data integrity.
Writing State Transition Tests
Single Transition Tests
Test each transition in isolation:
class TestOrderStateTransitions:
def test_payment_confirmed_moves_to_confirmed(self, order_service):
order = order_service.create(items=[item(1)])
assert order.status == 'pending'
order_service.confirm_payment(order.id)
order = order_service.get(order.id)
assert order.status == 'confirmed'
def test_cancel_from_pending(self, order_service):
order = order_service.create(items=[item(1)])
order_service.cancel(order.id)
assert order_service.get(order.id).status == 'cancelled'
def test_cancel_from_confirmed(self, order_service):
order = order_service.create(items=[item(1)])
order_service.confirm_payment(order.id)
order_service.cancel(order.id)
assert order_service.get(order.id).status == 'cancelled'
def test_cancel_from_processing(self, order_service):
order = order_service.create(items=[item(1)])
order_service.confirm_payment(order.id)
order_service.start_fulfillment(order.id)
order_service.cancel(order.id)
assert order_service.get(order.id).status == 'cancelled'Invalid Transition Tests
Equally important — verify that invalid transitions are rejected:
def test_cannot_ship_from_pending(self, order_service):
order = order_service.create(items=[item(1)])
with pytest.raises(InvalidStateTransitionError):
order_service.ship(order.id)
def test_cannot_deliver_from_confirmed(self, order_service):
order = order_service.create(items=[item(1)])
order_service.confirm_payment(order.id)
with pytest.raises(InvalidStateTransitionError):
order_service.deliver(order.id)
def test_cannot_cancel_after_shipping(self, order_service):
order = order_service.create(items=[item(1)])
order_service.confirm_payment(order.id)
order_service.start_fulfillment(order.id)
order_service.ship(order.id)
with pytest.raises(InvalidStateTransitionError):
order_service.cancel(order.id)End-to-End Path Tests
Test complete workflows:
def test_full_order_lifecycle_happy_path(self, order_service):
"""Complete order lifecycle: pending → confirmed → processing → shipped → delivered"""
order = order_service.create(items=[item(1)])
assert order.status == 'pending'
order_service.confirm_payment(order.id)
assert order_service.get(order.id).status == 'confirmed'
order_service.start_fulfillment(order.id)
assert order_service.get(order.id).status == 'processing'
order_service.ship(order.id)
assert order_service.get(order.id).status == 'shipped'
order_service.deliver(order.id)
assert order_service.get(order.id).status == 'delivered'
def test_return_refund_path(self, order_service):
"""Return flow: delivered → returned → refunded"""
order = create_delivered_order(order_service)
order_service.initiate_return(order.id)
assert order_service.get(order.id).status == 'returned'
order_service.process_refund(order.id)
order = order_service.get(order.id)
assert order.status == 'refunded'
assert order.refund_amount > 0Authentication Flow Example
User authentication is a classic state machine:
States: logged_out, authenticating, logged_in, locked_out, mfa_required
Transitions:
logged_out --submit_credentials--> authenticating
authenticating --credentials_valid--> logged_in (no MFA required)
authenticating --credentials_valid_mfa--> mfa_required (MFA enabled)
authenticating --credentials_invalid--> logged_out (attempts < max)
authenticating --credentials_invalid--> locked_out (attempts >= max)
mfa_required --mfa_valid--> logged_in
mfa_required --mfa_invalid--> logged_out (attempts < max)
mfa_required --mfa_invalid--> locked_out (attempts >= max)
logged_in --logout--> logged_out
locked_out --admin_unlock--> logged_out
locked_out --timeout_expires--> logged_outdescribe('Auth state machine', () => {
test('valid credentials without MFA → logged in', async () => {
const session = await authService.createSession();
expect(session.state).toBe('logged_out');
await authService.submitCredentials(session.id, validUser, validPassword);
expect(session.state).toBe('logged_in');
});
test('valid credentials with MFA → mfa_required', async () => {
const session = await authService.createSession();
await authService.submitCredentials(session.id, mfaUser, validPassword);
expect(session.state).toBe('mfa_required');
});
test('invalid credentials 3 times → locked_out', async () => {
const session = await authService.createSession();
for (let i = 0; i < 3; i++) {
await authService.submitCredentials(session.id, validUser, 'wrong');
}
expect(session.state).toBe('locked_out');
});
test('cannot submit credentials when locked out', async () => {
const session = createLockedOutSession();
await expect(
authService.submitCredentials(session.id, validUser, validPassword)
).rejects.toThrow('Account is locked');
});
});Testing Against the Transition Table
A systematic approach: iterate through the transition table and generate a test for every cell:
import pytest
# State transition table: {(state, event): expected_result}
VALID_TRANSITIONS = {
('pending', 'confirm_payment'): 'confirmed',
('pending', 'cancel'): 'cancelled',
('confirmed', 'start_fulfillment'): 'processing',
('confirmed', 'cancel'): 'cancelled',
('processing', 'ship'): 'shipped',
('processing', 'cancel'): 'cancelled',
('shipped', 'deliver'): 'delivered',
('delivered', 'return'): 'returned',
('returned', 'process_refund'): 'refunded',
}
ALL_STATES = ['pending', 'confirmed', 'processing', 'shipped',
'delivered', 'returned', 'cancelled', 'refunded']
ALL_EVENTS = ['confirm_payment', 'cancel', 'start_fulfillment',
'ship', 'deliver', 'return', 'process_refund']
# Generate valid transition tests
@pytest.mark.parametrize("state,event,expected", [
(state, event, target)
for (state, event), target in VALID_TRANSITIONS.items()
])
def test_valid_transition(state, event, expected, order_service):
order = order_service.create_in_state(state)
getattr(order_service, event)(order.id)
assert order_service.get(order.id).status == expected
# Generate invalid transition tests
INVALID_COMBINATIONS = [
(state, event)
for state in ALL_STATES
for event in ALL_EVENTS
if (state, event) not in VALID_TRANSITIONS
]
@pytest.mark.parametrize("state,event", INVALID_COMBINATIONS)
def test_invalid_transition_rejected(state, event, order_service):
order = order_service.create_in_state(state)
with pytest.raises((InvalidStateTransitionError, Exception)):
getattr(order_service, event)(order.id)This generates exhaustive tests for both valid and invalid transitions from the data structure, not from manually written test cases.
Common State Machine Bugs
Missing guard condition: A transition fires when it shouldn't. The cancel event is accepted after shipping because the guard "status in [pending, confirmed, processing]" was omitted.
Incorrect terminal state: A "terminal" state (refunded, cancelled) allows further transitions that it shouldn't.
Race condition on state change: Two concurrent events both attempt to transition the same object. Without proper locking, both may succeed, leaving the system in an inconsistent state.
Lost events: An event arrives for an object in an unexpected state (perhaps after a crash and restore) and is silently dropped rather than queued or rejected with an error.
Missing initial state: Objects created without an explicit state often default to null or 0, which is not a defined state in the machine.
State Testing with HelpMeTest
State machines in web applications are often distributed across UI and backend. A checkout flow has UI state (which step you're on) and backend state (order status). These can diverge — the backend order is "shipped" but the UI shows "processing" due to a caching bug.
HelpMeTest tests that verify the full state machine through the UI — from order placement through each state change to delivery confirmation — catch this class of UI/backend divergence that backend-only tests miss. Running these continuously ensures that state machine changes in deployments don't break the user-facing flow.