State transition testing helps when system behavior depends on an object's current state. Users, orders, tickets, payments, subscriptions, and sessions all have statuses and permitted transitions.
When to use it
- →There are explicit states such as created, active, blocked, canceled, and completed.
- →Some transitions are forbidden.
- →Behavior and available actions depend on current state.
- →Bugs often involve lifecycle violations rather than individual fields.
What to document
- →The object's states
- →Permitted transitions
- →Events that trigger transitions
- →Restrictions: who can initiate a transition, under which conditions, and what follows
Example
Suppose a bug report has Open, In Progress, Ready for Test, Reopened, and Closed statuses. State transition testing checks whether direct closure is allowed, who may reopen it, and whether another fix cycle preserves correct logic.
What QA should examine
- →Forbidden transitions: the system should block them or explain them appropriately.
- →Transition side effects: notifications, auditing, access changes, and logging.
- →Repeated transitions and backward movement.
- →States after failure: what happens if an operation is interrupted halfway?
If an object has a lifecycle, drawing even a simple state diagram is usually useful. It quickly reveals blind spots that are hard to see in a case list.
Worked order lifecycle with an explicit contract
This is a fictional model for a single order, not the behavior of every shop or payment API. Draft is an unsent order, Placed is submitted, Paid is paid, Shipped is dispatched, and Cancelled is terminal. Only the five transitions below are allowed. Cancellation after payment, refunds, permissions, transport errors and concurrent requests are outside this small model and require additional rules.
Draft --submit--> Placed --pay--> Paid --ship--> Shipped
Draft --cancel--> Cancelled
Placed --cancel--> CancelledText alternative: submit moves Draft to Placed; pay moves Placed to Paid; ship moves Paid to Shipped. Cancel moves Draft or Placed to Cancelled. Shipped and Cancelled have no outgoing transitions. Every other state/event combination is rejected without changing state or producing a business side effect.
A complete single-event test table
Reset the order to the first column before each row and use a fresh request key. The count is a model counter for a committed business transition; it does not mean a universal number of logs, notifications or external charges. An accepted transition increments it by one; a rejection adds zero.
Initial state Event Result Final state New effects
Draft submit accepted Placed 1
Draft pay rejected Draft 0
Draft ship rejected Draft 0
Draft cancel accepted Cancelled 1
Placed submit rejected Placed 0
Placed pay accepted Paid 1
Placed ship rejected Placed 0
Placed cancel accepted Cancelled 1
Paid submit rejected Paid 0
Paid pay rejected Paid 0
Paid ship accepted Shipped 1
Paid cancel rejected Paid 0
Shipped submit rejected Shipped 0
Shipped pay rejected Shipped 0
Shipped ship rejected Shipped 0
Shipped cancel rejected Shipped 0
Cancelled submit rejected Cancelled 0
Cancelled pay rejected Cancelled 0
Cancelled ship rejected Cancelled 0
Cancelled cancel rejected Cancelled 0Retries are a separate contract, not an extra state
For this model, a successful request stores its event and result under a key scoped to this order. Repeating the same key and event returns that stored result with no new side effect. Reusing a successful key for a different event returns key-conflict. Rejected requests are not stored. This intentionally simplified rule is not an assertion about any HTTP status code or real payment provider.
Sequence Expected response Current state Total committed effects
submit / s1 accepted, Placed Placed 1
pay / p1 accepted, Paid Paid 2
pay / p1 again accepted, Paid (recorded result) Paid 2
ship / p1 key-conflict, Paid Paid 2
pay / p2 rejected, Paid Paid 2
ship / s2 accepted, Shipped Shipped 3A stored result is a snapshot of the original request, not necessarily the current order state after later transitions. A real API must specify key scope, retention, payload comparison, ordering and atomic persistence. A memory-only teaching model cannot demonstrate protection against concurrent duplicate charges.
Run the reference model locally
Save as order_state_model.py and run python3 order_state_model.py. It checks all 20 state/event combinations, then the retry sequence. Expected output: Transitions: 5 accepted, 15 rejected; replay checks: passed. These checks verify this teaching model; test the application separately against the same declared rules.
STATES = ("Draft", "Placed", "Paid", "Shipped", "Cancelled")
EVENTS = ("submit", "pay", "ship", "cancel")
TRANSITIONS = {
("Draft", "submit"): "Placed", ("Draft", "cancel"): "Cancelled",
("Placed", "pay"): "Paid", ("Placed", "cancel"): "Cancelled",
("Paid", "ship"): "Shipped",
}
class Order:
def __init__(self, state="Draft"):
self.state, self.effects, self.saved = state, 0, {}
def apply(self, event, key):
if key in self.saved:
old_event, old_result = self.saved[key]
return old_result if event == old_event else ("key-conflict", self.state)
target = TRANSITIONS.get((self.state, event))
if target is None:
return ("rejected", self.state)
self.state = target
self.effects += 1
result = ("accepted", target)
self.saved[key] = (event, result)
return result
accepted = 0
for state in STATES:
for event in EVENTS:
order = Order(state)
target = TRANSITIONS.get((state, event))
expected = ("accepted", target) if target else ("rejected", state)
assert order.apply(event, "unique") == expected
assert order.state == (target or state)
assert order.effects == int(target is not None)
accepted += target is not None
order = Order()
assert order.apply("submit", "s1") == ("accepted", "Placed")
assert order.apply("pay", "p1") == ("accepted", "Paid")
assert order.apply("pay", "p1") == ("accepted", "Paid")
assert order.effects == 2
assert order.apply("ship", "p1") == ("key-conflict", "Paid")
assert order.apply("pay", "p2") == ("rejected", "Paid")
assert order.effects == 2
assert order.apply("ship", "s2") == ("accepted", "Shipped")
assert order.effects == 3
print(f"Transitions: {accepted} accepted, {20-accepted} rejected; replay checks: passed")For a real system, expand the sequence coverage: timeout between payment and persistence, duplicate callbacks, stale versions and cancellation racing with payment. Define what should happen before writing the assertions. Five covered transitions do not prove all multi-step paths or failure recoveries work.
Continue learning
Use decision tables for transition guards