State diagrams for test scenarios: mapping bug reproduction paths
When a bug lands in your issue tracker, the first thing a developer asks is "how do I reproduce it?" The answer is usually a wall of text: "log in as role X, create an object A, wait for state transition B, then try to do action C." State diagrams excel at making this precise and visual—they show which states matter, what transitions are valid, and which paths lead to the bug.
State diagrams also work as living test documentation. Every edge in your diagram is a test case; every path is a scenario. This keeps QA artifacts close to the code they test, making them easy to maintain as the product evolves.
Why state diagrams work for testing
A state diagram is a node-and-edge graph where:
- Nodes are discrete states your system can be in
- Edges are transitions triggered by actions (user input, events, timeouts)
- Paths are sequences of transitions—test cases
This maps naturally to testing. A test script is just a path through your state diagram. A regression is a path that should exist but doesn't. An edge case is often a transition you forgot to document.
Here's a simple example: a user authentication flow.
stateDiagram-v2
[*] --> Unauthenticated
Unauthenticated --> EmailSent: submit email
EmailSent --> Verified: click email link
EmailSent --> EmailSent: resend link (< 10 sec) - blocked
EmailSent --> Unauthenticated: abandon after 24h
Verified --> Authenticated: session created
Authenticated --> Unauthenticated: logout
Authenticated --> Unauthenticated: session expired
note right of EmailSent
Rate-limited to 1 per 10s
Expires after 24h
end note
Every path in this diagram is a test case:
- ✅ Login happy path: Unauthenticated → EmailSent → Verified → Authenticated
- ✅ Logout: Authenticated → Unauthenticated
- ✅ Session expires: Authenticated → Unauthenticated
- ✅ Resend link blocked: EmailSent → EmailSent (should error)
- ✅ Link expires: EmailSent → Unauthenticated (after 24h, link is invalid)
Real-world example: order checkout flow
Here's a more complex scenario—an e-commerce checkout. Notice how the diagram captures edge cases that could become bugs:
stateDiagram-v2
[*] --> Cart
Cart --> PaymentInfo: proceed to checkout
Cart --> [*]: abandon
PaymentInfo --> PaymentProcessing: submit payment
PaymentInfo --> Cart: edit cart
PaymentProcessing --> PaymentFailed: card declined / timeout
PaymentProcessing --> AwaitingCapture: payment authorized
PaymentFailed --> PaymentInfo: retry
PaymentFailed --> [*]: abandon
AwaitingCapture --> OrderConfirmed: capture succeeds
AwaitingCapture --> PaymentCancelled: auto-cancel (24h timeout)
PaymentCancelled --> PaymentInfo: retry payment
OrderConfirmed --> Shipped: fulfillment picks items
OrderConfirmed --> OrderCancelled: user cancels (< 2h)
OrderCancelled --> PaymentRefunding: initiate refund
PaymentRefunding --> PaymentRefunded: refund processed
PaymentRefunded --> [*]
Shipped --> Delivered
Delivered --> [*]
note right of AwaitingCapture
Payment authorized but not captured.
Auto-cancel after 24h to release funds.
end note
note right of OrderCancelled
Only allowed if order hasn't been
picked/packed yet (~2h window).
end note
This diagram surfaces edge cases that might not be obvious:
- Orphaned auth-only captures: if capture fails and auto-cancel doesn't trigger, funds are held indefinitely
- Race condition: user cancels while fulfillment picks—which wins?
- Refund timeout: what if the refund API hangs?
Each becomes a test case: write a scenario that triggers it and verify the system handles it correctly.
Mapping test scenarios to state paths
Every test scenario is a path (or set of paths) through the diagram. Here's how to use them together:
| Test Name | Path | Expected |
|---|---|---|
| Happy path | Cart → PaymentInfo → PaymentProcessing → OrderConfirmed → Shipped → Delivered | Order placed and fulfilled |
| Card declined | Cart → PaymentInfo → PaymentProcessing → PaymentFailed → PaymentInfo | Error shown; user can retry |
| Early cancel | Cart → PaymentInfo → PaymentProcessing → AwaitingCapture → OrderCancelled → PaymentRefunding → PaymentRefunded | Order and payment reversed |
| Late cancel | Cart → ... → Shipped | Rejection: order already shipped |
| Capture timeout | Cart → ... → AwaitingCapture (24h later) → PaymentCancelled → PaymentInfo | Payment released; user can retry |
This mapping makes it clear which scenarios you've covered and which you've missed.
Documenting bug reproduction with state diagrams
When a bug is reported, draw the exact state path that triggered it:
Bug: "Refund processed but user wasn't notified"
stateDiagram-v2
OrderConfirmed --> OrderCancelled: user clicks cancel
OrderCancelled --> PaymentRefunding: refund initiated
PaymentRefunding --> PaymentRefunded: refund succeeds
PaymentRefunded --> [*]
note right of PaymentRefunded
BUG: No email sent to user.
Should trigger "refund_completed" email.
end note
The diagram makes the bug scenario reproducible and pinpoints exactly where the system failed to act.
Complex workflows: substates and guards
For more intricate flows, nest states or add guards (conditions on transitions):
stateDiagram-v2
[*] --> Processing
Processing --> PaymentProcessing: payment_handler triggers
Processing --> ComplianceReview: high_risk_score
state PaymentProcessing {
[*] --> CardValidator
CardValidator --> FraudCheck: card valid
FraudCheck --> Approved: score < threshold
FraudCheck --> Declined: score >= threshold
Approved --> [*]
Declined --> [*]
}
state ComplianceReview {
[*] --> ManualReview
ManualReview --> Approved: reviewer approves
ManualReview --> Declined: reviewer rejects
Approved --> [*]
Declined --> [*]
}
PaymentProcessing --> Finalized: approved
PaymentProcessing --> Rejected: declined
ComplianceReview --> Finalized: approved
ComplianceReview --> Rejected: declined
Finalized --> [*]
Rejected --> [*]
This models payment processing that branches into fraud checks and compliance review. Each substate is its own mini-workflow—perfect for defining test coverage.
Tips for state diagrams in QA
-
One diagram per subsystem. Don't try to fit the entire product into one diagram. Keep it focused: payments, auth, order fulfillment, notifications.
-
Label transitions clearly. "proceed" is vague; "submit_payment_form" is testable. Use verb + object.
-
Add notes for constraints. If a transition has a timeout, rate limit, or permission check, document it. This becomes part of the test specification.
-
Keep it up-to-date. When you add a feature or fix a bug, update the diagram. It's living documentation.
-
Use it in code reviews. "Did you handle the timeout from state X to state Y?" Point to the diagram. Makes reviews faster.
-
Link it from your issue tracker. Paste the diagram (or link to it) in bug reports and feature tickets. It gives context faster than text.
FAQ
Should I diagram every state in my system?
No—only the ones that matter for behavior. Internal implementation states that don't affect visible behavior can be implicit.
Can I use state diagrams for load testing or performance tests?
State diagrams focus on which transitions happen, not how long they take. Use them for functional test coverage; track performance separately in your metrics/monitoring system.
How do I handle async operations (webhooks, background jobs)?
Model them as separate states. "AwaitingCapture" is a state where the system waits for an external event. The diagram stays the same; you just note that transitions may be delayed or timeout.
What if a state has 20 possible transitions?
It's a smell that the state is doing too much. Consider splitting it into substates or breaking the feature into smaller workflows.
Document your next test scenario visually in the MermaidCreator editor—create a state diagram that captures every path through your feature, then use it to drive your QA checklist.
Related posts
Testing Mermaid diagrams in CI/CD: catch breaking changes before merge
Automate diagram validation, render testing, and syntax checks in your pipeline. Keep diagrams accurate alongside your code.
Modeling workflows with Mermaid state diagrams
State machines clearly document how something moves between statuses. Mermaid's stateDiagram-v2 turns that logic into a version-controlled diagram.