State Transition Testing

How to test systems whose behavior depends on object status and find defects in workflow logic.

Updated:

Contents

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.

plain text
Draft --submit--> Placed --pay--> Paid --ship--> Shipped
Draft --cancel--> Cancelled
Placed --cancel--> Cancelled

Text 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.

plain text
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	0

Retries 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.

plain text
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	3

A 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.

plain text
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

Check rejected API actions without side effects

ISTQB CTFL 4.0.1: section 4.2.4, state transition testing