Skip to content

State Transition Testing

Concept

State transition testing verifies that a system moves correctly between states. It tests both valid transitions (allowed paths) and invalid transitions (should be rejected).

State A --[event]--> State B
State A --[invalid event]--> stays in State A (rejected)

Key Elements

Element Description
State A condition the system can be in
Event / Trigger An action or input that causes a transition
Transition A move from one state to another
Guard A condition that must be true for the transition
Action What happens during a transition

State Transition Diagram: Order Lifecycle

pending → confirmed → shipped → delivered
    ↓           ↓
cancelled   cancelled

Valid transitions: - pendingconfirmed, cancelled - confirmedshipped, cancelled - shippeddelivered - delivered → (none) - cancelled → (none)


Implementation + Tests

class Order:
    TRANSITIONS: dict[str, list[str]] = {
        "pending":   ["confirmed", "cancelled"],
        "confirmed": ["shipped",   "cancelled"],
        "shipped":   ["delivered"],
        "delivered": [],
        "cancelled": [],
    }

    def __init__(self) -> None:
        self.state = "pending"

    def transition(self, new_state: str) -> bool:
        if new_state in self.TRANSITIONS[self.state]:
            self.state = new_state
            return True
        return False


class TestOrderStateTransition:
    def test_valid_full_flow(self) -> None:
        order = Order()
        assert order.transition("confirmed")
        assert order.transition("shipped")
        assert order.transition("delivered")
        assert order.state == "delivered"

    def test_cannot_ship_pending_order(self) -> None:
        order = Order()
        assert not order.transition("shipped")
        assert order.state == "pending"

    def test_cannot_reverse_delivered(self) -> None:
        order = Order()
        order.transition("confirmed")
        order.transition("shipped")
        order.transition("delivered")
        assert not order.transition("pending")

    def test_cancel_from_pending(self) -> None:
        order = Order()
        assert order.transition("cancelled")
        assert order.state == "cancelled"

    def test_cancel_from_confirmed(self) -> None:
        order = Order()
        order.transition("confirmed")
        assert order.transition("cancelled")
        assert order.state == "cancelled"

    def test_cannot_cancel_after_shipped(self) -> None:
        order = Order()
        order.transition("confirmed")
        order.transition("shipped")
        assert not order.transition("cancelled")

Coverage Levels

Level What to Cover Effort
0-switch All states visited Low
1-switch All valid transitions Medium
2-switch All pairs of transitions High
Invalid All invalid transitions Must have

For most production systems, 1-switch + all invalid transitions gives strong coverage.


State Transition Table

Current State Target State Result Valid
pending confirmed confirmed Yes
pending shipped pending No
pending cancelled cancelled Yes
confirmed shipped shipped Yes
confirmed cancelled cancelled Yes
confirmed delivered confirmed No
shipped delivered delivered Yes
shipped cancelled shipped No
delivered any delivered No
cancelled any cancelled No

When to Use

Scenario Use STT
Order / booking / workflow status Yes
Authentication flow (login → verified → active) Yes
Game states / UI wizard steps Yes
Simple CRUD without status field No
Stateless API endpoints No

Risks

Risk Description Mitigation
Missing invalid transitions Only testing happy paths Build full state transition table
Shared order instance Tests pollute each other Fresh instance per test
Undocumented states States exist in code but not in spec Map state machine from code, not just docs