Decision Tables & State Transitions
Equivalence partitions and boundaries work well for single inputs. But many bugs come from combinations ("Gold member and small order") and from sequences ("cancel after the parcel has shipped"). Two black-box techniques handle these: decision tables for combinations of conditions, and state transition testing for behaviour that depends on what happened before.
In this lesson you will build a decision table, let Python generate every rule for you, draw a state table and test both valid and invalid transitions.
Decision tables
A decision table lists the conditions (inputs that are true or false) and the actions (what the system should do) for every combination. Each column, called a rule, is one combination and becomes at least one test case.
Here is a rule from a food delivery app in Pune: "Gold members always get free delivery. Other customers get free delivery only when the order is ₹499 or more and the restaurant is within 5 km."
| Conditions and action | R1 | R2 | R3 | R4 | R5 | R6 | R7 | R8 |
|---|---|---|---|---|---|---|---|---|
| Gold member | Y | Y | Y | Y | N | N | N | N |
| Order of ₹499 or more | Y | Y | N | N | Y | Y | N | N |
| Within 5 km | Y | N | Y | N | Y | N | Y | N |
| Free delivery | Y | Y | Y | Y | Y | N | N | N |
With three yes/no conditions there are 2 x 2 x 2 = 8 rules. The table makes gaps and contradictions in the requirement obvious, which is useful even before any code exists. Testing every column gives full decision table coverage.
Let code build the table
Writing combinations by hand is error-prone. itertools.product produces every combination, and comparing the code with the rule finds defects at once:
from itertools import product
def free_delivery(gold, big_order, nearby):
# Rule: Gold members always get free delivery; others need
# an order of Rs 499 or more AND a distance within 5 km.
if gold:
return True
return big_order or nearby # defect: should be "and"
def expected(gold, big_order, nearby):
return gold or (big_order and nearby)
def yn(flag):
return "Y" if flag else "N"
print("Rule Gold >=499 <=5km Expected Actual")
for number, (gold, big, near) in enumerate(product([True, False], repeat=3), start=1):
exp = expected(gold, big, near)
act = free_delivery(gold, big, near)
flag = "" if exp == act else " <-- FAIL"
print(f"R{number:<4} {yn(gold):<5} {yn(big):<6} {yn(near):<6} {yn(exp):<9} {yn(act)}{flag}")Rule Gold >=499 <=5km Expected Actual R1 Y Y Y Y Y R2 Y Y N Y Y R3 Y N Y Y Y R4 Y N N Y Y R5 N Y Y Y Y R6 N Y N N Y <-- FAIL R7 N N Y N Y <-- FAIL R8 N N N N N
The developer typed or instead of and. A tester who only tried a Gold member and one big nearby order (R1 and R5) would never see it.
Collapsing the table
Rules R1 to R4 all give free delivery, whatever the other two conditions are. A collapsed (minimised) table merges them into one rule with "-" meaning "does not matter":
| Conditions and action | R1 | R2 | R3 | R4 |
|---|---|---|---|---|
| Gold member | Y | N | N | N |
| Order of ₹499 or more | - | Y | Y | N |
| Within 5 km | - | Y | N | - |
| Free delivery | Y | Y | N | N |
Collapsing saves tests when there are many conditions (ten conditions would mean 1,024 full rules). The risk is that you assume a condition "does not matter" when the code does use it, so keep the full table for high-risk rules such as payments.
State transition testing
Some systems behave differently depending on their state, the situation they are in now. An online order can be placed, paid, shipped, delivered, returned or cancelled, and "cancel" means something different in each. State transition testing models:
- States: the situations (PLACED, PAID, SHIPPED...).
- Events: what happens (pay, ship, cancel...).
- Transitions: the move from one state to another caused by an event.
- Guards: conditions on a transition, such as "only within 7 days of delivery".
A state table lists every state and every event, including combinations that must not be allowed:
| State | Event | pay | ship | deliver | cancel | return |
|---|---|---|---|---|---|
| PLACED | PAID | invalid | invalid | CANCELLED | invalid |
| PAID | invalid | SHIPPED | invalid | CANCELLED | invalid |
| SHIPPED | invalid | invalid | DELIVERED | invalid | invalid |
| DELIVERED | invalid | invalid | invalid | invalid | RETURNED |
Each "invalid" cell is a test idea. Bugs often live there: can a shipped order still be cancelled for a full refund? Here is the order model with tests for a valid path and three invalid transitions:
TRANSITIONS = {
("PLACED", "pay"): "PAID",
("PLACED", "cancel"): "CANCELLED",
("PAID", "ship"): "SHIPPED",
("PAID", "cancel"): "CANCELLED",
("SHIPPED", "deliver"): "DELIVERED",
("DELIVERED", "return"): "RETURNED",
}
class Order:
def __init__(self):
self.state = "PLACED"
def handle(self, event):
key = (self.state, event)
if key not in TRANSITIONS:
raise ValueError(f"Cannot {event} an order that is {self.state}")
self.state = TRANSITIONS[key]
return self.state
# Valid path: every state reached, every arrow on this path used
order = Order()
for event in ["pay", "ship", "deliver", "return"]:
print(f"{event:>8} -> {order.handle(event)}")
# Invalid transitions must be refused
for state, event in [("PLACED", "ship"), ("SHIPPED", "cancel"), ("CANCELLED", "pay")]:
order = Order()
order.state = state
try:
order.handle(event)
print("FAIL: allowed", event, "from", state)
except ValueError as error:
print("PASS:", error) pay -> PAID
ship -> SHIPPED
deliver -> DELIVERED
return -> RETURNED
PASS: Cannot ship an order that is PLACED
PASS: Cannot cancel an order that is SHIPPED
PASS: Cannot pay an order that is CANCELLEDCoverage levels
- All states coverage: every state is visited at least once.
- Valid transitions coverage: every valid arrow is used at least once (the most common target).
- All transitions coverage: every valid transition is used and every invalid one is tried. Use this for critical systems.
The valid path above did not use the two cancel arrows, so you would add tests for "cancel from PLACED" and "cancel from PAID" to reach full valid transitions coverage.
Counting events: a PIN lock
States are also hidden inside counters. An ATM card is blocked after three wrong PINs, and stays blocked even if the right PIN comes next:
function createLogin(correctPin) {
let state = "READY";
let wrong = 0;
return {
get state() { return state; },
enter(pin) {
if (state === "LOCKED") return "Card blocked, visit the branch";
if (pin === correctPin) {
state = "SIGNED_IN";
wrong = 0;
return "Welcome";
}
wrong += 1;
if (wrong === 3) {
state = "LOCKED";
return "Card blocked, visit the branch";
}
state = "RETRY";
return `Wrong PIN, ${3 - wrong} attempt(s) left`;
},
};
}
// Test: three wrong PINs lock the card, even if the right PIN comes next
const atm = createLogin("4821");
for (const pin of ["1111", "2222", "3333", "4821"]) {
console.log(`${pin} -> ${atm.enter(pin)} [${atm.state}]`);
}
// Test: a correct PIN after two wrong ones signs in
const atm2 = createLogin("4821");
for (const pin of ["1111", "2222", "4821"]) {
console.log(`${pin} -> ${atm2.enter(pin)} [${atm2.state}]`);
}1111 -> Wrong PIN, 2 attempt(s) left [RETRY] 2222 -> Wrong PIN, 1 attempt(s) left [RETRY] 3333 -> Card blocked, visit the branch [LOCKED] 4821 -> Card blocked, visit the branch [LOCKED] 1111 -> Wrong PIN, 2 attempt(s) left [RETRY] 2222 -> Wrong PIN, 1 attempt(s) left [RETRY] 4821 -> Welcome [SIGNED_IN]
The sequence matters: the same PIN 4821 gives "Welcome" in one test and "Card blocked" in the other. Testing single inputs could never show this.
Common mistakes
- Testing only the happy path through a workflow. The invalid transitions are where money leaks.
- Forgetting to reset state between tests. Each test above creates a fresh object, so one test cannot affect the next.
- Collapsing decision tables too eagerly. Merge rules only when you are sure a condition truly has no effect.
Key points
- Decision tables list every combination of conditions and the expected action; each rule becomes a test.
- With n yes/no conditions a full table has 2 to the power n rules; collapsing merges rules where a condition does not matter.
- State transition testing models states, events, transitions and guards.
- A state table shows invalid transitions as well as valid ones, and both need tests.
- Coverage targets include all states, all valid transitions and all transitions.
Exercise
Add a guard to the order model: a DELIVERED order can be returned only within 7 days. Change handle() so it takes an extra argument days_since_delivery=0, and refuse a return after 7 days with the message "Return window closed". Test day 7 (allowed) and day 8 (refused).
Show solution
TRANSITIONS = {
("PLACED", "pay"): "PAID", ("PLACED", "cancel"): "CANCELLED",
("PAID", "ship"): "SHIPPED", ("PAID", "cancel"): "CANCELLED",
("SHIPPED", "deliver"): "DELIVERED", ("DELIVERED", "return"): "RETURNED",
}
class Order:
def __init__(self, state="PLACED"):
self.state = state
def handle(self, event, days_since_delivery=0):
key = (self.state, event)
if key not in TRANSITIONS:
raise ValueError(f"Cannot {event} an order that is {self.state}")
if event == "return" and days_since_delivery > 7:
raise ValueError("Return window closed")
self.state = TRANSITIONS[key]
return self.state
print("Day 7:", Order("DELIVERED").handle("return", days_since_delivery=7))
try:
Order("DELIVERED").handle("return", days_since_delivery=8)
except ValueError as error:
print("Day 8:", error)Day 7: RETURNED Day 8: Return window closed
Days 7 and 8 are the boundary values of the guard, so this combines state transition testing with boundary value analysis.
Quick Quiz
1. A decision table has four yes/no conditions. How many rules does the full table have?
Each condition doubles the combinations: 2 x 2 x 2 x 2 = 16.
2. What does "-" mean in a collapsed decision table?
A dash marks a "do not care" condition, which lets several full rules merge into one.
3. Which test checks an invalid transition for an online order?
Invalid transitions are events the system must refuse in the current state.
4. Which coverage target uses every valid arrow in the state model at least once?
Valid transitions coverage requires every allowed transition to be exercised at least once.
Learn Software Testing with a mentor
Free lessons get you started. Our Software Testing training adds real projects and code reviews from working developers.
- Project-based learning
- Mentor code reviews
- Free counselling call
