Decision Table Testing: Systematically Test Complex Business Logic

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 path

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

Read more

Start now free