Decision Table Testing: Systematically Test Complex Business Logic
Business logic is where bugs are most expensive. A bug in your discount calculation, insurance eligibility rule, or loan approval logic doesn't just throw an error—it silently gives the wrong answer, affecting real customers and real money.
Decision table testing is a systematic technique for deriving test cases from complex conditional logic. It ensures you test every meaningful combination of business conditions—not just the paths you thought of.
What Is a Decision Table?
A decision table maps all combinations of input conditions to the expected actions or outputs. It's a structured way to express "if these conditions are true, then do this."
Anatomy of a Decision Table
CONDITIONS | Rule 1 | Rule 2 | Rule 3 | Rule 4
─────────────────────────────────────────────────────────────
Member status = Gold? | Y | Y | N | N
Order > $100? | Y | N | Y | N
Has coupon code? | Y | Y | N | N
ACTIONS
─────────────────────────────────────────────────────────────
Apply 20% discount | ✓ | | |
Apply 15% discount | | ✓ | ✓ |
Apply coupon discount | ✓ | ✓ | |
Free shipping | ✓ | ✓ | ✓ |
No discount | | | | ✓Each column is a test case. Each "Rule" represents a unique combination of conditions and the resulting actions.
Why Decision Tables Beat Ad-Hoc Test Design
When testing complex business rules without a decision table:
- You test the cases you can think of (happy path + a few errors)
- You miss combinations that weren't obvious
- You have duplicate test cases covering the same logic
- It's unclear whether you've covered all combinations
With a decision table:
- All meaningful combinations are enumerated upfront
- Every column becomes a test case (no gaps)
- No duplicate tests
- The table itself documents the business rules
Building a Decision Table Step by Step
Step 1: Identify Conditions
Start by listing all the conditions (inputs) that affect the outcome:
For a loan application:
- Credit score: Excellent (750+) / Good (650-749) / Poor (<650)
- Debt-to-income ratio: Low (<36%) / High (36%+)
- Employment: Employed / Self-employed / Unemployed
- Loan amount: Standard (<$250k) / Jumbo (≥$250k)Step 2: Identify Actions
List all possible outcomes:
- Approve at standard rate
- Approve at premium rate
- Approve with conditions (requires cosigner)
- Decline (eligible to reapply in 6 months)
- Decline (ineligible)Step 3: Create All Combinations
With 3 conditions having 3/2/3/2 values: 3×2×3×2 = 36 possible combinations.
Not all are meaningful. Reduce by:
- Eliminating impossible combinations
- Combining rules with the same action (using "don't care" values)
Step 4: Reduced Decision Table
CONDITION | R1 | R2 | R3 | R4 | R5 | R6 | R7
─────────────────────────────────────────────────────────────
Credit score | Ex | Ex | Go | Go | Go | Po | Po
DTI ratio | Lo | Hi | Lo | Lo | Hi | Lo | Hi
Employment |Any |Any |Em |SE |Any |Em |Any
Loan amount |Any |Any |St |Ju |Any |Any |Any
ACTIONS
─────────────────────────────────────────────────────────────
Standard rate approval | ✓ | | | | | |
Premium rate approval | | | ✓ | | | |
Approve with cosigner | | ✓ | | ✓ | | |
Decline (reapply in 6m) | | | | | ✓ | ✓ |
Decline (ineligible) | | | | | | | ✓(Ex=Excellent, Go=Good, Po=Poor, Lo=Low, Hi=High, Em=Employed, SE=Self-employed, St=Standard, Ju=Jumbo, Any=Either)
From Decision Table to Test Cases
Each column in the decision table becomes a test case:
# tests/test_loan_decision.py
import pytest
from myapp.loan_service import LoanDecisionEngine
class TestLoanDecisionTable:
"""
Decision table tests for loan approval logic.
Each test corresponds to a column in the decision table.
Rules derived from business_rules/loan_approval_v2.xlsx
"""
def test_rule_1_excellent_credit_low_dti_standard_rate(self):
"""R1: Excellent credit + Low DTI → Standard rate approval"""
result = LoanDecisionEngine.evaluate(
credit_score=780,
dti_ratio=0.25,
employment="employed",
loan_amount=200000
)
assert result.decision == "approved"
assert result.rate_tier == "standard"
def test_rule_2_excellent_credit_high_dti_cosigner(self):
"""R2: Excellent credit + High DTI → Approve with cosigner"""
result = LoanDecisionEngine.evaluate(
credit_score=760,
dti_ratio=0.45,
employment="employed",
loan_amount=200000
)
assert result.decision == "approved"
assert result.conditions == ["cosigner_required"]
def test_rule_3_good_credit_low_dti_employed_standard(self):
"""R3: Good credit + Low DTI + Employed → Premium rate"""
result = LoanDecisionEngine.evaluate(
credit_score=700,
dti_ratio=0.30,
employment="employed",
loan_amount=200000
)
assert result.decision == "approved"
assert result.rate_tier == "premium"
def test_rule_4_good_credit_low_dti_self_employed_jumbo(self):
"""R4: Good credit + Low DTI + Self-employed + Jumbo → Cosigner"""
result = LoanDecisionEngine.evaluate(
credit_score=700,
dti_ratio=0.30,
employment="self_employed",
loan_amount=400000
)
assert result.decision == "approved"
assert result.conditions == ["cosigner_required"]
def test_rule_5_good_credit_high_dti_decline(self):
"""R5: Good credit + High DTI → Decline, eligible to reapply"""
result = LoanDecisionEngine.evaluate(
credit_score=700,
dti_ratio=0.45,
employment="employed",
loan_amount=200000
)
assert result.decision == "declined"
assert result.reapply_eligible_after_days == 180
def test_rule_7_poor_credit_high_dti_ineligible(self):
"""R7: Poor credit + High DTI → Decline ineligible"""
result = LoanDecisionEngine.evaluate(
credit_score=600,
dti_ratio=0.45,
employment="employed",
loan_amount=200000
)
assert result.decision == "declined"
assert result.reapply_eligible_after_days is None # Ineligible, no reapply pathParameterized Decision Table Tests
For large decision tables, use parameterized tests:
import pytest
LOAN_DECISION_TABLE = [
# (credit_score, dti, employment, loan_amount, expected_decision, expected_tier, expected_conditions)
(780, 0.25, "employed", 200000, "approved", "standard", []),
(760, 0.45, "employed", 200000, "approved", "premium", ["cosigner_required"]),
(700, 0.30, "employed", 200000, "approved", "premium", []),
(700, 0.30, "self_employed", 400000, "approved", "premium", ["cosigner_required"]),
(700, 0.45, "employed", 200000, "declined", None, []),
(600, 0.30, "employed", 200000, "declined", None, []),
(600, 0.45, "employed", 200000, "declined", None, []),
]
@pytest.mark.parametrize(
"credit_score,dti,employment,loan_amount,exp_decision,exp_tier,exp_conditions",
LOAN_DECISION_TABLE
)
def test_loan_decision_table(credit_score, dti, employment, loan_amount,
exp_decision, exp_tier, exp_conditions):
result = LoanDecisionEngine.evaluate(
credit_score=credit_score,
dti_ratio=dti,
employment=employment,
loan_amount=loan_amount
)
assert result.decision == exp_decision
if exp_tier:
assert result.rate_tier == exp_tier
if exp_conditions:
assert set(result.conditions) == set(exp_conditions)Decision Tables for UI Testing
Decision tables work for UI behavior too:
CONDITION | T1 | T2 | T3 | T4 | T5
────────────────────────────────────────────────
User logged in? | Y | Y | Y | N | N
Has active subscription| Y | Y | N | - | -
Feature flag enabled? | Y | N | Y | Y | N
ACTIONS
────────────────────────────────────────────────
Show premium feature | ✓ | | | |
Show "Upgrade" prompt | | | ✓ | |
Show login prompt | | | | ✓ | ✓
Show "Coming soon" msg | | ✓ | | |@pytest.mark.parametrize("logged_in,has_subscription,flag_enabled,expected_ui", [
(True, True, True, "premium_feature"),
(True, True, False, "coming_soon"),
(True, False, True, "upgrade_prompt"),
(False, None, True, "login_prompt"),
(False, None, False, "login_prompt"),
])
def test_feature_visibility(logged_in, has_subscription, flag_enabled, expected_ui, page):
setup_state(page, logged_in=logged_in, subscription=has_subscription, flag=flag_enabled)
page.goto("/premium-feature")
if expected_ui == "premium_feature":
expect(page.locator("#premium-content")).to_be_visible()
elif expected_ui == "coming_soon":
expect(page.locator("#coming-soon-banner")).to_be_visible()
elif expected_ui == "upgrade_prompt":
expect(page.locator("#upgrade-cta")).to_be_visible()
elif expected_ui == "login_prompt":
expect(page.locator("#login-modal")).to_be_visible()Common Decision Table Mistakes
Including redundant rules: If two rules have the same actions and one condition differs between them but doesn't affect the outcome, they can be merged.
Missing impossible combinations: Document why certain combinations can't occur (and add tests that verify they're prevented).
Not validating the table with business stakeholders: Decision tables are excellent documentation. Have the business team review them before you write tests.
Treating conditions as always binary: A credit score isn't just "good/bad"—you need to define the exact thresholds that trigger different rules.
Decision Table Testing Checklist
Creating the table:
- All input conditions identified
- All possible values for each condition documented
- All possible actions/outcomes documented
- All meaningful combinations enumerated
- Impossible/invalid combinations excluded
- Redundant rules merged where appropriate
- Table reviewed by business/product stakeholders
Writing tests:
- Each column in the decision table has a test case
- Test names reference the rule being tested
- Boundary values used for numeric conditions
- Tests are independent (no shared state between rules)
Conclusion
Decision table testing transforms business rule testing from "write tests until it seems covered" to "derive tests systematically from the rules themselves."
The resulting test suite is:
- Complete: Every meaningful combination is covered
- Non-redundant: Each test case covers a unique rule
- Documented: The table explains why each test exists
- Maintainable: When rules change, update the table and tests together
Start with your most complex business logic: pricing rules, eligibility criteria, discount calculations, approval workflows. Create a decision table, derive your tests, and you'll catch bugs that would otherwise make it to production.
The discipline of building the table—before writing a single test—is where most of the value comes from. It forces clarity about what the rules actually are, reveals ambiguities, and often surfaces bugs before any code is run.