Automating Decision Table Tests
Decision tables are a test design artifact. Their value is only fully realized when the rules they define become automated tests that run on every build. A decision table sitting in a spreadsheet catches nothing — it's a plan, not a verification.
This post covers how to turn a decision table into automated tests: data-driven test patterns in Python and Java, table-driven tests in Go, and higher-level frameworks like FitNesse and Cucumber that let non-developers maintain the tables directly.
The Core Pattern: Data-Driven Testing
Every row in a decision table is a test case. The conditions are inputs; the actions are expected outputs. This structure maps directly to parameterized tests — tests that share a single test body but run with different input/output data sets.
The pattern:
- Store the decision table as test data (list of tuples, CSV, or YAML)
- Write one test function that accepts one row of inputs and one set of expected outputs
- Parametrize the test so the framework generates one test instance per table row
This gives you readable test output (each rule is a named test case), easy maintenance (add a rule to the data, the test appears automatically), and clear traceability between the table and the test suite.
Python: pytest Parametrize
pytest's @pytest.mark.parametrize decorator is the cleanest way to implement data-driven tests in Python.
The Decision Table
Using the shipping rules example from an earlier post:
| Rule | Member tier | Order total | Speed | Expected rate |
|---|---|---|---|---|
| R1 | Gold | 150.00 | Standard | Free |
| R2 | Gold | 30.00 | Express | 12.99 |
| R3 | Silver | 120.00 | Standard | Free |
| R4 | Silver | 60.00 | Standard | 5.99 |
| R5 | None | 110.00 | Standard | Free |
| R6 | None | 40.00 | Standard | 5.99 |
| R7 | None | 40.00 | Express | 12.99 |
| R8 | None | 40.00 | Overnight | 24.99 |
pytest Implementation
import pytest
from shipping import calculate_shipping_rate # the function under test
# Decision table as test data — each tuple is one rule
shipping_rules = [
# (member_tier, order_total, speed, expected_rate, rule_id)
("Gold", 150.00, "Standard", 0.00, "R1"),
("Gold", 30.00, "Express", 12.99, "R2"),
("Silver", 120.00, "Standard", 0.00, "R3"),
("Silver", 60.00, "Standard", 5.99, "R4"),
("None", 110.00, "Standard", 0.00, "R5"),
("None", 40.00, "Standard", 5.99, "R6"),
("None", 40.00, "Express", 12.99, "R7"),
("None", 40.00, "Overnight", 24.99, "R8"),
]
@pytest.mark.parametrize(
"member_tier, order_total, speed, expected_rate, rule_id",
shipping_rules,
ids=[r[4] for r in shipping_rules], # names each test R1, R2, etc.
)
def test_shipping_rate(member_tier, order_total, speed, expected_rate, rule_id):
actual_rate = calculate_shipping_rate(
member_tier=member_tier,
order_total=order_total,
speed=speed,
)
assert actual_rate == pytest.approx(expected_rate), (
f"Rule {rule_id}: expected ${expected_rate} for "
f"{member_tier}/{order_total}/{speed}, got ${actual_rate}"
)Running pytest -v produces output like:
test_shipping::test_shipping_rate[R1] PASSED
test_shipping::test_shipping_rate[R2] PASSED
test_shipping::test_shipping_rate[R3] FAILED
...Each rule is a named, independently reported test. One rule can fail without affecting others. CI shows exactly which rule broke.
Storing Tables as YAML
For tables managed by non-engineers, store the data in YAML and load it in the test file:
# shipping_rules.yaml
- rule: R1
member_tier: Gold
order_total: 150.00
speed: Standard
expected_rate: 0.00
- rule: R2
member_tier: Gold
order_total: 30.00
speed: Express
expected_rate: 12.99import yaml, pytest
with open("shipping_rules.yaml") as f:
rules = yaml.safe_load(f)
@pytest.mark.parametrize("rule", rules, ids=[r["rule"] for r in rules])
def test_shipping_rate(rule):
actual = calculate_shipping_rate(
member_tier=rule["member_tier"],
order_total=rule["order_total"],
speed=rule["speed"],
)
assert actual == pytest.approx(rule["expected_rate"])A product manager can now edit the YAML file to add or change rules. The test code stays untouched.
Java: JUnit 5 Parameterized Tests
JUnit 5's @ParameterizedTest with @MethodSource is the equivalent pattern in Java.
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.MethodSource;
import java.util.stream.Stream;
import static org.junit.jupiter.api.Assertions.assertEquals;
class ShippingRuleTest {
// Decision table as a stream of Arguments
static Stream<Arguments> shippingRules() {
return Stream.of(
// memberTier, orderTotal, speed, expectedRate, ruleId
Arguments.of("Gold", 150.00, "Standard", 0.00, "R1"),
Arguments.of("Gold", 30.00, "Express", 12.99, "R2"),
Arguments.of("Silver", 120.00, "Standard", 0.00, "R3"),
Arguments.of("Silver", 60.00, "Standard", 5.99, "R4"),
Arguments.of("None", 110.00, "Standard", 0.00, "R5"),
Arguments.of("None", 40.00, "Standard", 5.99, "R6"),
Arguments.of("None", 40.00, "Express", 12.99, "R7"),
Arguments.of("None", 40.00, "Overnight", 24.99, "R8")
);
}
@ParameterizedTest(name = "{4}: {0}/{1}/{2} → ${3}")
@MethodSource("shippingRules")
void testShippingRate(
String memberTier, double orderTotal, String speed,
double expectedRate, String ruleId
) {
ShippingCalculator calc = new ShippingCalculator();
double actual = calc.calculateRate(memberTier, orderTotal, speed);
assertEquals(expectedRate, actual, 0.001,
"Rule " + ruleId + " failed");
}
}The @ParameterizedTest(name = "...") annotation gives each test a readable name in the test report.
Go: Table-Driven Tests
Go has a built-in idiom for data-driven testing called table-driven tests. It uses a slice of structs and a range loop.
package shipping_test
import (
"testing"
"github.com/yourorg/shipping"
)
func TestShippingRate(t *testing.T) {
// Decision table as a slice of test cases
tests := []struct {
name string
memberTier string
orderTotal float64
speed string
expectedRate float64
}{
{"R1: Gold standard large order", "Gold", 150.00, "Standard", 0.00},
{"R2: Gold express small order", "Gold", 30.00, "Express", 12.99},
{"R3: Silver standard large order", "Silver", 120.00, "Standard", 0.00},
{"R4: Silver standard mid order", "Silver", 60.00, "Standard", 5.99},
{"R5: None standard large order", "None", 110.00, "Standard", 0.00},
{"R6: None standard small order", "None", 40.00, "Standard", 5.99},
{"R7: None express small order", "None", 40.00, "Express", 12.99},
{"R8: None overnight small order", "None", 40.00, "Overnight", 24.99},
}
for _, tt := range tests {
tt := tt // capture range variable
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got := shipping.CalculateRate(tt.memberTier, tt.orderTotal, tt.speed)
if got != tt.expectedRate {
t.Errorf("got %.2f, want %.2f", got, tt.expectedRate)
}
})
}
}This is idiomatic Go. t.Run creates a named sub-test for each row. t.Parallel() runs them concurrently. The struct fields map directly to the decision table columns.
FitNesse: Decision Tables as Living Documentation
FitNesse is a wiki-based testing framework where decision tables are written in wiki syntax and executed directly. The table IS the test — there's no separate code to maintain the mapping.
A FitNesse decision table page for the shipping rules:
!| ShippingRateFixture |
| memberTier | orderTotal | speed | expectedRate? |
| Gold | 150.00 | Standard | 0.00 |
| Gold | 30.00 | Express | 12.99 |
| Silver | 120.00 | Standard | 0.00 |
| Silver | 60.00 | Standard | 5.99 |
| None | 110.00 | Standard | 0.00 |
| None | 40.00 | Standard | 5.99 |
| None | 40.00 | Express | 12.99 |
| None | 40.00 | Overnight | 24.99 |FitNesse calls a ShippingRateFixture class (written by a developer once) that maps columns to method calls. The business analyst or QA lead maintains the table; the developer maintains the fixture. This separation is FitNesse's key value proposition.
The tradeoff: FitNesse requires a running server and XML/HTML-based wiki editing. It's more overhead than a YAML file and has largely been superseded in greenfield projects.
Cucumber: Decision Tables in Gherkin
Cucumber's Scenario Outline feature implements decision tables in Gherkin syntax:
Feature: Shipping Rate Calculation
Scenario Outline: Calculate shipping rate for different customer tiers
Given a customer with "<member_tier>" membership
And an order total of $<order_total>
When they select "<speed>" shipping
Then the shipping rate should be $<expected_rate>
Examples:
| member_tier | order_total | speed | expected_rate | rule |
| Gold | 150.00 | Standard | 0.00 | R1 |
| Gold | 30.00 | Express | 12.99 | R2 |
| Silver | 120.00 | Standard | 0.00 | R3 |
| Silver | 60.00 | Standard | 5.99 | R4 |
| None | 110.00 | Standard | 0.00 | R5 |
| None | 40.00 | Standard | 5.99 | R6 |
| None | 40.00 | Express | 12.99 | R7 |
| None | 40.00 | Overnight | 24.99 | R8 |Each row in the Examples table generates one scenario. Cucumber runs each scenario independently, reports each one separately, and shows the table in failure output so you can immediately see which rule failed.
Cucumber works best when stakeholders are comfortable reading Gherkin. The feature file can be reviewed by non-technical team members, making the decision table visible to the whole team.
CI/CD Integration
Decision table tests need to run on every commit. The pattern is identical to any other automated test, but a few notes:
Run tests in parallel. Decision table tests are typically fast and independent — run them with maximum parallelism. In pytest: pytest -n auto. In Go: t.Parallel() (shown above). In JUnit 5: configure junit.jupiter.execution.parallel.enabled=true in junit-platform.properties.
Tag decision table tests. In pytest, use @pytest.mark.decision_table. In Cucumber, use @decision-table on the feature. This lets you run them selectively or report on them separately.
Fail fast with clear output. Configure your test runner to output which rule ID failed, the inputs, expected output, and actual output. A failure that says "test_shipping_rate[R6] FAILED: expected $5.99, got $0.00" tells you exactly what to investigate. A failure that says "AssertionError" tells you nothing.
Track test data changes as code changes. The YAML or CSV files containing decision table data should be in version control. Changes to test data get code review the same way code changes do. A PR that adds a new rule to the table is a testable specification change — it should be reviewed as such.
Traceability: Linking Tables to Code
For each function that implements a decision table rule, add an annotation pointing to the test:
# @decision-table: shipping_rules.yaml
# @rules: R1, R2, R3, R4, R5, R6, R7, R8
def calculate_shipping_rate(member_tier: str, order_total: float, speed: str) -> float:
...When the function changes, grep for the annotation and run the named test suite. When you add a new rule to the table, update the annotation. This keeps the code, the table, and the tests aligned.
With HelpMeTest, you can run these parameterized test suites continuously — new table rows become new monitored scenarios automatically, without writing new test scripts.
When Not to Use This Approach
Data-driven testing for decision tables works well when:
- The function under test is pure or close to pure (same inputs → same outputs, no side effects)
- The test cases are independent
- The setup for each test case is identical
It works poorly when:
- Each rule requires different database state or external service mocks
- The system under test has significant state between calls
- Rules have complex setup/teardown that varies per test case
In those situations, keep the decision table as a design artifact and write individual test cases per rule, sharing setup through fixtures rather than through parametrization.
The goal is always the same: every rule in the decision table has a corresponding automated test that runs on every commit. How you get there depends on your stack.