Mert Özen Further With Every Line
Testing & Quality Test Design Techniques — #04

Decision Table Testing: Rule Combinations, Collapsing the Table and From Rule Table to Tests

Mert Özen Nov 15, 2026 14 min 8 views
Decision Table Testing: Rule Combinations, Collapsing the Table and From Rule Table to Tests

Decision table testing lays out every combination for rules whose outcome depends on several conditions, collapses the table and turns each column into a test. For Bookstore's shipping fee, four green tests written one per sentence missed the contradiction in a Plus member's international shipping and seven undefined coupon cases; the table showed them all before any test was written. Limited and extended-entry tables, "–" and N/A, return approval, and not copying tests from the code's table.

Version note: The code in this post was built and run with .NET 10 and xUnit.net v3 4.0.3.

Guess First

In Bookstore, the online bookstore used as the shared example across this site's series, the shipping fee is being reworked. Selin's story has four sentences: Plus members always ship free. Orders with a discounted subtotal of 500 TRY or more ship free. A free shipping coupon brings the shipping fee to zero, and the coupon counts as used. Free shipping doesn't apply to international shipments; domestic shipping is 49.90 TRY, international 349.90 TRY.

Instead of nested ifs, Emre has written a rule table, as in the Refactoring series' "From a Pile of Conditionals to Readable Design" post. The first matching rule wins:

namespace Bookstore.Shipping;

public enum Destination
{
    Domestic,
    International,
}

public sealed record ShippingRequest(
    bool IsPlusMember, decimal DiscountedSubtotal, Destination Destination, bool HasFreeShippingCoupon);

public sealed record ShippingQuote(decimal Fee, bool CouponConsumed, bool CouponRejected);

public static class ShippingFeeCalculator
{
    public const decimal DomesticFee = 49.90m;
    public const decimal InternationalFee = 349.90m;

    private sealed record Rule(Func<ShippingRequest, bool> Applies, Func<ShippingRequest, ShippingQuote> Quote);

    // The first matching rule wins
    private static readonly Rule[] Rules =
    [
        new(r => r.IsPlusMember, _ => new(0m, false, false)),
        new(r => r.Destination == Destination.International,
            r => new(InternationalFee, false, r.HasFreeShippingCoupon)),
        new(r => FreeShippingPolicy.Qualifies(r.DiscountedSubtotal), _ => new(0m, false, false)),
        new(r => r.HasFreeShippingCoupon, _ => new(0m, true, false)),
        new(_ => true, _ => new(DomesticFee, false, false)),
    ];

    public static ShippingQuote Quote(ShippingRequest request) =>
        Rules.First(rule => rule.Applies(request)).Quote(request);
}

And one test per sentence in the story:

// One test per sentence in the story
[Fact]
public void Plus_members_ship_free()
{
    ShippingQuote quote = ShippingFeeCalculator.Quote(new(true, 120m, Destination.Domestic, false));
    Assert.Equal(0m, quote.Fee);
}

[Fact]
public void Orders_from_500_try_ship_free()
{
    ShippingQuote quote = ShippingFeeCalculator.Quote(new(false, 650m, Destination.Domestic, false));
    Assert.Equal(0m, quote.Fee);
}

[Fact]
public void Free_shipping_coupon_is_consumed()
{
    ShippingQuote quote = ShippingFeeCalculator.Quote(new(false, 120m, Destination.Domestic, true));
    Assert.Equal(new ShippingQuote(0m, true, false), quote);
}

[Fact]
public void International_shipping_is_never_free()
{
    ShippingQuote quote = ShippingFeeCalculator.Quote(new(false, 650m, Destination.International, false));
    Assert.Equal(349.90m, quote.Fee);
}

All four tests pass. How much does a Plus member sending a book to Germany pay for shipping? What does the code say, and what does the story say?

The Answer: The Story Contradicts Itself

The code says 0 TRY, because the Plus rule is in the first row of the table. The story gives two answers: by the first sentence a Plus member "always" ships free, by the fourth there is no free shipping abroad. While writing the rules, Emre made a decision without noticing: the order of the table chose which side of the contradiction wins. Nobody approved that decision, because nobody saw the contradiction.

The tests didn't see it either. Writing one test per sentence means trying each sentence independently of the others. But the rules aren't independent; they touch each other: Plus and international, coupon and the 500 TRY threshold, coupon and international. The bugs are exactly at those intersections. Testing sentence by sentence never asks about the intersections.

The technique for rules like these is decision table testing. First we see every combination in one table, then we collapse the table and turn each column into a test.

Conditions, Actions and the Full Table

According to the ISTQB syllabus, a decision table has two parts: the conditions, and the actions the system takes in response. Each column is a rule, that is, a combination of conditions and the actions for that combination. If the conditions can only be true or false, the table is limited-entry (T or F in every cell); if they take multiple values such as ranges or partitions, it is extended-entry. In the notation, T means the condition holds, F means it doesn't, "–" means the condition is irrelevant for that rule, and N/A means the combination is infeasible.

The shipping rule has four conditions: is it international, is the customer a Plus member, is the discounted subtotal 500 TRY or more, is there a free shipping coupon. The actions are the shipping fee and what happens to the coupon. Four boolean conditions make 2⁴ = 16 combinations. The syllabus draws the rules as columns; here we transpose the table and put each rule on a row so it's easier to read:

RuleInternationalPlus500 TRY or moreCouponFeeWhat happens to the coupon?
1FTTT0Undefined
2FTTF0–
3FTFT0Undefined
4FTFF0–
5FFTT0Undefined
6FFTF0–
7FFFT0Used
8FFFF49.90–
9TTTTContradictionUndefined
10TTTFContradiction–
11TTFTContradictionUndefined
12TTFFContradiction–
13TFTT349.90Undefined
14TFTF349.90–
15TFFT349.90Undefined
16TFFF349.90–

Filling in the table took half an hour, and three things came out of it. In four rules (9–12) two sentences give different fees: a contradiction. In seven rules the coupon's fate is undefined. If shipping is already free for another reason, does the coupon count as used (1, 3, 5)? What happens to a coupon abroad: is it used, rejected, is the customer told anything (9, 11, 13, 15)? And Emre's four tests touch only four of the sixteen rules (4, 6, 7, 14).

This is where the syllabus sees the strength of a decision table: because it lays out every combination of conditions systematically, it also exposes gaps and contradictions in the requirement. These findings came out before writing a test or running a single line of code.

Closing the Missing and Contradictory Rules

The table goes to Selin, and Selin makes three decisions. A Plus member's international shipping isn't free but half price: 174.95 TRY. If shipping is already free thanks to Plus or the 500 TRY threshold, the coupon doesn't count as used; the customer keeps it. A coupon is invalid for international shipments: it isn't used, and the customer is told that the coupon doesn't apply abroad.

These decisions also change the sentences of the story itself. "Always free" goes; "free at home, half price abroad" takes its place. That is where a decision table sits between requirement and test: while designing the tests, it fixes the requirement. So it's worth thinking of the table not just as a test team document but also as an output of the example mapping session in the Requirements Analysis series. The table's counterpart of the red-card question "what happens to the coupon when shipping is already free?" is the "Undefined" cell.

Collapsing the Table

You don't need to test each of the sixteen rules. The syllabus defines two simplifications: deleting the columns of infeasible combinations, and merging with "–" the columns that give the same action for different values of a condition that doesn't affect the outcome.

This table has no infeasible combinations; the four conditions are independent of each other. Merging, however, gains a lot. At home, the subtotal and the coupon are irrelevant for a Plus member: rules 1–4 shrink to one. Abroad, the 500 TRY threshold changes nothing, because after Selin's decision there is no threshold abroad. The result is eight rules:

RuleInternationalPlus500 TRY or moreCouponFeeCoupon usedCoupon rejected
R1FT––0
R2FFT–0
R3FFFT0X
R4FFFF49.90
R5TT–F174.95
R6TT–T174.95X
R7TF–F349.90
R8TF–T349.90X

In decision table testing the coverage items are the columns with feasible combinations, and 100 percent coverage means every column is exercised by at least one test. For the collapsed table that is eight tests. As the number of conditions grows, the number of rules grows exponentially: ten boolean conditions make 1,024 combinations. At that point you use either an aggressively collapsed table or columns chosen by risk. There are systematic algorithms for finding which columns to merge, but the syllabus leaves them out of scope; in day-to-day work, merging by hand as above is usually enough.

Collapsing has a cost: every column merged with "–" is now represented by a single test. R1 claims the same outcome whether or not the Plus member uses a coupon; the test shows the claim holds for the chosen representative, not for every combination. That's why we fill a "–" with the riskiest value rather than a random one.

From the Table to TheoryData

Each row is a test. The representatives we chose for the "–" cells pay attention to two things: even when the coupon is irrelevant we pick a cart with a coupon, because the coupon not being used is an action too and needs checking; and in every row that involves the 500 TRY threshold we use the boundary values from the previous post, 500.00 and 499.99.

namespace Bookstore.Shipping.Tests;

public sealed class ShippingFeeCalculatorTests
{
    private const bool Plus = true;
    private const bool Coupon = true;

    // Collapsed decision table: one test per column; representative values for "-" conditions
    public static IEnumerable<TheoryDataRow<bool, decimal, Destination, bool, ShippingQuote>> DecisionTable =>
    [
        new(Plus, 120.00m, Destination.Domestic, Coupon, new(0m, false, false)) { Label = "R1" },
        new(!Plus, 500.00m, Destination.Domestic, Coupon, new(0m, false, false)) { Label = "R2" },
        new(!Plus, 499.99m, Destination.Domestic, Coupon, new(0m, true, false)) { Label = "R3" },
        new(!Plus, 499.99m, Destination.Domestic, !Coupon, new(49.90m, false, false)) { Label = "R4" },
        new(Plus, 650.00m, Destination.International, !Coupon, new(174.95m, false, false)) { Label = "R5" },
        new(Plus, 120.00m, Destination.International, Coupon, new(174.95m, false, true)) { Label = "R6" },
        new(!Plus, 650.00m, Destination.International, !Coupon, new(349.90m, false, false)) { Label = "R7" },
        new(!Plus, 120.00m, Destination.International, Coupon, new(349.90m, false, true)) { Label = "R8" },
    ];

    [Theory]
    [MemberData(nameof(DecisionTable))]
    public void Shipping_quote_follows_the_decision_table(
        bool isPlusMember, decimal subtotal, Destination destination, bool hasCoupon, ShippingQuote expected)
    {
        ShippingQuote quote = ShippingFeeCalculator.Quote(new(isPlusMember, subtotal, destination, hasCoupon));

        Assert.Equal(expected, quote);
    }
}

The expected value is a ShippingQuote record; thanks to records' value equality, a single Assert.Equal checks all the actions together: the fee, whether the coupon was used and whether it was rejected. Every action column of a table row is in the test too. The Plus and Coupon constants make the rows look like the table: !Plus reads more easily than an F in a cell. We run it against the old code; this run also includes the five-row test copied from the code's table, which we'll show in Common Pitfalls:

failed Bookstore.Shipping.Tests.ShippingFeeCalculatorTests.Shipping_quote_follows_the_decision_table [R5] (1ms)
  Assert.Equal() Failure: Values differ
  Expected: ShippingQuote { Fee = 174.95, CouponConsumed = False, CouponRejected = False }
  Actual:   ShippingQuote { Fee = 0, CouponConsumed = False, CouponRejected = False }
failed Bookstore.Shipping.Tests.ShippingFeeCalculatorTests.Shipping_quote_follows_the_decision_table [R6] (0ms)
  Assert.Equal() Failure: Values differ
  Expected: ShippingQuote { Fee = 174.95, CouponConsumed = False, CouponRejected = True }
  Actual:   ShippingQuote { Fee = 0, CouponConsumed = False, CouponRejected = False }

Test run summary: Failed!
  total: 18
  failed: 2
  succeeded: 16
  skipped: 0

Both reds are in the rows with the contradiction. R6 also shows that the coupon isn't rejected abroad: because the Plus rule comes first, the international rule is never reached. The fix is to reorder the rule table and add the Plus international rule:

// The first matching rule wins; international rules come before free shipping rules
private static readonly Rule[] Rules =
[
    new(r => r.Destination == Destination.International && r.IsPlusMember,
        r => new(InternationalFee / 2, false, r.HasFreeShippingCoupon)),
    new(r => r.Destination == Destination.International,
        r => new(InternationalFee, false, r.HasFreeShippingCoupon)),
    new(r => r.IsPlusMember, _ => new(0m, false, false)),
    new(r => FreeShippingPolicy.Qualifies(r.DiscountedSubtotal), _ => new(0m, false, false)),
    new(r => r.HasFreeShippingCoupon, _ => new(0m, true, false)),
    new(_ => true, _ => new(DomesticFee, false, false)),
];
Test run summary: Passed!
  total: 13
  failed: 0
  succeeded: 13
  skipped: 0
  duration: 307ms

In a table where the first matching rule wins, the order is not part of the code but part of the business rule. That's why the test makes no assumption about order: each row of the decision table is independent of the others and asserts a single outcome.

An Extended-Entry Table: Return Approval

Not every condition has to be true or false. The rule for return approval in Bookstore's back office goes like this: if an e-book has been downloaded, the return is rejected; an e-book that hasn't been downloaded is approved within 14 days of delivery and rejected after that. A printed book damaged in shipping is approved automatically within 30 days and goes to manual review after that. An undamaged printed book is approved within 14 days, goes to manual review between 15 and 30 days, and is rejected after 30 days.

Here "days since delivery" isn't boolean; it's a condition with partitions: 14 and below, 15–30, above 30. Such tables are called extended-entry. Two combinations are also infeasible: an e-book isn't damaged in shipping, and a printed book isn't downloaded. These cells are N/A and aren't tested. The table has eight feasible rules: three for e-books (E1–E3) and five for printed books (P1–P5). The code is a switch expression:

namespace Bookstore.Ordering;

public enum BookFormat
{
    Printed,
    EBook,
}

public enum ReturnDecision
{
    Approve,
    ManualReview,
    Reject,
}

public sealed record ReturnRequest(BookFormat Format, int DaysSinceDelivery, bool DamagedInShipping, bool Downloaded);

public static class ReturnApprovalPolicy
{
    public static ReturnDecision Decide(ReturnRequest request) => request switch
    {
        { Format: BookFormat.EBook, Downloaded: true } => ReturnDecision.Reject,
        { Format: BookFormat.EBook, DaysSinceDelivery: <= 14 } => ReturnDecision.Approve,
        { Format: BookFormat.EBook } => ReturnDecision.Reject,
        { DamagedInShipping: true, DaysSinceDelivery: <= 30 } => ReturnDecision.Approve,
        { DamagedInShipping: true } => ReturnDecision.ManualReview,
        { DaysSinceDelivery: <= 14 } => ReturnDecision.Approve,
        { DaysSinceDelivery: <= 30 } => ReturnDecision.ManualReview,
        _ => ReturnDecision.Reject,
    };
}

An extended-entry table naturally combines decision tables with boundary value analysis. In every column the day condition is a partition; if we pick that partition's representative from the boundary, a single test covers both the rule and the boundary. P4's partition is 15–30 and has two boundaries, so it gets two rows:

namespace Bookstore.Ordering.Tests;

public sealed class ReturnApprovalPolicyTests
{
    // Extended-entry table: day-count partitions represented by their boundary values
    public static IEnumerable<TheoryDataRow<BookFormat, int, bool, bool, ReturnDecision>> DecisionTable =>
    [
        new(BookFormat.EBook, 14, false, false, ReturnDecision.Approve) { Label = "E1" },
        new(BookFormat.EBook, 15, false, false, ReturnDecision.Reject) { Label = "E2" },
        new(BookFormat.EBook, 3, false, true, ReturnDecision.Reject) { Label = "E3" },
        new(BookFormat.Printed, 30, true, false, ReturnDecision.Approve) { Label = "P1" },
        new(BookFormat.Printed, 31, true, false, ReturnDecision.ManualReview) { Label = "P2" },
        new(BookFormat.Printed, 14, false, false, ReturnDecision.Approve) { Label = "P3" },
        new(BookFormat.Printed, 15, false, false, ReturnDecision.ManualReview) { Label = "P4 lower" },
        new(BookFormat.Printed, 30, false, false, ReturnDecision.ManualReview) { Label = "P4 upper" },
        new(BookFormat.Printed, 31, false, false, ReturnDecision.Reject) { Label = "P5" },
    ];

    [Theory]
    [MemberData(nameof(DecisionTable))]
    public void Return_decision_follows_the_decision_table(
        BookFormat format, int daysSinceDelivery, bool damagedInShipping, bool downloaded, ReturnDecision expected)
    {
        ReturnDecision decision = ReturnApprovalPolicy.Decide(
            new ReturnRequest(format, daysSinceDelivery, damagedInShipping, downloaded));

        Assert.Equal(expected, decision);
    }
}
Test run summary: Passed!
  total: 59
  failed: 0
  succeeded: 59
  skipped: 0
  duration: 418ms

We don't test the N/A combinations, but ReturnRequest can represent them: new ReturnRequest(BookFormat.EBook, 5, true, false) compiles. If an infeasible combination can be expressed in the type, someone will create it one day. Using separate records for printed and digital returns removes these combinations at the compiler level; that's a design decision, and it comes before the tests.

Production Notes

Keep the decision table with the story. The table is the most precise form of the requirement: it says what the rule does for every combination. When the rule changes, the table changes first, then the tests, and the code last. Using the same IDs in the table and in the test class's TheoryDataRow rows (R1–R8) lets you get from a red test to the table row in one step.

A new condition doubles the table. If a "same-day delivery" option arrives tomorrow, the number of conditions goes to five and the full table to 32 combinations. When adding the new condition, rebuild the full table first, then collapse it; adding a column to the existing collapsed table means skipping the new condition's intersections with the old rules.

Rule order should be reviewed like code in production too. In a table where the first matching rule wins, swapping two rows is a behaviour change that neither the compiler nor most code reviews notice. Decision table tests may be the only thing that catches it.

Log the actions. Writing which rule applied (such as R6) to the order record or the log lets Gül in customer service answer "why did this customer pay 174.95 TRY?" without looking at the code.

Common Pitfalls

Deriving the test table from the implementation. Since Emre's code uses a rule table, generating the test from the same table looks tempting. If you read the code's table and turn it into test rows in order, you get this:

// Copied from the code's rule table, in the same order
[Theory]
[InlineData(true, 650.00, Destination.International, false, 0.00)]
[InlineData(false, 650.00, Destination.International, false, 349.90)]
[InlineData(false, 650.00, Destination.Domestic, false, 0.00)]
[InlineData(false, 120.00, Destination.Domestic, true, 0.00)]
[InlineData(false, 120.00, Destination.Domestic, false, 49.90)]
public void Fee_matches_the_rule_table(
    bool isPlusMember, decimal subtotal, Destination destination, bool hasCoupon, decimal expectedFee)
{
    Assert.Equal(expectedFee, ShippingFeeCalculator.Quote(new(isPlusMember, subtotal, destination, hasCoupon)).Fee);
}

These five rows passed green against the old code; the first row "confirms" that a Plus member shipping abroad pays 0 TRY. The same bug gets written twice: once in the code, once in the test. A decision table is built from the requirement and turned into tests without ever being compared with the code's table.

Writing one test per sentence and skipping combinations. If the story's sentences aren't independent, one test per sentence never tries the intersections. Both bugs in this post were at an intersection.

Putting a random value in a "–" cell. The value of the irrelevant condition is where the claim that it doesn't affect the outcome gets tested. If you pick a cart without a coupon in a row where you treat the coupon as irrelevant, you never test the "coupon not used" action.

Building the table once and forgetting it. If the table isn't rebuilt when a condition is added or a rule changes, the tests keep protecting the old combinations and the new intersections stay untested.

Pouring everything into a decision table. Building a four-row table for a rule with two conditions is needless ceremony. The technique pays off where the conditions really touch each other and the outcome depends on the combination.

A Small Experiment

★☆☆ In the fixed code, move the r => r.IsPlusMember rule back to the top of the table. Which rows turn red? What do the tests generated from the copied table say?

★★☆ Add a condition to the return rule: returns of institutional orders always go to manual review. Rebuild the full table, mark the N/A and "–" cells, collapse it and update the tests. How many new columns came out?

★★★ Add a "same-day delivery" condition to the shipping rule: it is only possible at home, costs an extra 39.90 TRY and is charged to Plus members too. Build the full 32-combination table, delete the infeasible ones, collapse it and work out how many tests you need. Also list the questions you would ask Selin.

A decision table looks at combinations of conditions, but each combination is evaluated at a single moment. In the next post we move to rules whose outcome also depends on history: state transition testing. We'll derive test cases from the order's Created, Paid, Shipped, Delivered, Cancelled and Returned states, collect the invalid transitions in a table, and see the difference between 0-switch and 1-switch coverage.