Table-Driven Testing Skill
Use this skill when several test scenarios exercise the same behaviour with different data, in ecosystems where the TableTest library is not available.
Java/Kotlin: stop here. If the project is Java or Kotlin (Maven/Gradle), use the tabletest skill instead — the TableTest library is the target idiom on the JVM, even when the request says "table-driven" or "parameterised" tests.
The Table Model
Whatever the framework, a table-driven test has three parts:
- Rows: one scenario per row — its inputs and its expected outputs together, readable left to right as a complete example of the rule.
- A single test body that arranges, acts, and asserts. It contains no
if/switch/guard, no loops over cases, and no computation of expected values — every decision lives in the data, not the body. - A name per row describing the condition being tested, visible in test output so a failure identifies its scenario.
Everything below applies this model. Framework mechanics differ; the design principles do not.
Framework Mechanics
pytest (Python)
Rows are pytest.param entries; the id names the scenario:
@pytest.mark.parametrize(
("income", "filing_status", "rate"),
[
pytest.param(15_000, FilingStatus.SINGLE, 0.10, id="bottom bracket"),
pytest.param(55_000, FilingStatus.SINGLE, 0.22, id="middle bracket"),
pytest.param(55_000, FilingStatus.JOINT, 0.12, id="joint middle bracket"),
],
)
def test_tax_bracket_by_income_and_status(income, filing_status, rate):
assert bracket_for(income, filing_status).rate == rate
Always name cases — pytest.param(..., id="...") or an ids= argument. Auto-generated ids like 15000-SINGLE-0.1 force the reader to decode values; a written id states the condition — only the condition: at_the_limit, never at_the_limit-surcharge_applied.
"Regardless of" inputs: put them in the same case list, varying together. Stacking a second @pytest.mark.parametrize multiplies the decorators into a cartesian product, which is four visible cases for one claim — see Generating "Regardless Of" Combinations.
Expected exceptions: a case list mixing pytest.raises cases with return-value cases needs branching in the body — forbidden. Give rejection cases their own parametrized test built around pytest.raises. Exception: an accept/reject boundary is one rule and stays in one table — see Model Rejection as an Expected Column.
Swift Testing (Swift)
Rows are labelled tuples (or a small row struct) passed to @Test(arguments:):
@Test("Standing by completed credit hours", arguments: [
(creditHours: 29, standing: Standing.freshman),
(creditHours: 30, standing: Standing.sophomore),
(creditHours: 59, standing: Standing.sophomore),
(creditHours: 60, standing: Standing.junior),
])
func standingByCreditHours(creditHours: Int, standing: Standing) {
#expect(Standing(creditHours: creditHours) == standing)
}
Cartesian footgun: passing two collections — arguments: inputs, expectations — produces every combination, not paired rows. Pair with labelled tuples in one collection, a row struct, or zip — including for "regardless of" inputs, which vary together in one collection rather than crossed; see Generating "Regardless Of" Combinations.
Expected exceptions: a separate @Test with #expect(throws:) — never sentinel values or branching in a parameterised body. Exception: an accept/reject boundary is one rule and stays in one table — see Model Rejection as an Expected Column.
Jest / Vitest (JavaScript / TypeScript)
The tagged-template form of test.each is a literal table with headers — prefer it:
test.each`
hours | rate | fee
${1} | ${3} | ${0}
${4} | ${3} | ${6}
${7} | ${3} | ${21}
`('parking fee for $hours hours at rate $rate', ({ hours, rate, fee }) => {
expect(parkingFee(hours, rate)).toBe(fee);
});
The test title interpolates row values — write it so each generated name reads as a condition. Expected rejections use expect(() => ...).toThrow(...) in their own test.each block. Exception: an accept/reject boundary is one rule and stays in one table — see Model Rejection as an Expected Column.
Go
Rows are a slice of structs with a name field; each runs as a named subtest:
tests := []struct {
name string
creditHours int
standing Standing
}{
{"top of freshman range", 29, Freshman},
{"bottom of sophomore range", 30, Sophomore},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := StandingFor(tt.creditHours); got != tt.standing {
t.Errorf("StandingFor(%d) = %v, want %v", tt.creditHours, got, tt.standing)
}
})
}
The loop over the case slice is the framework mechanic here — the rule against loops applies inside the subtest body. Error-returning cases go in a separate table whose rows expect a specific error, not a mixed table with wantErr bool alongside unrelated expected values. A wantErr error field on an accept/reject boundary table is the sanctioned exception, and is idiomatic Go — see Model Rejection as an Expected Column.
xUnit (C#)
[Theory] with [InlineData] rows, or TheoryData<...> when rows need real types. [InlineData] has no per-row name — put the condition in a leading string argument or use MemberData with self-describing row objects. Expected exceptions use Assert.Throws<T> in their own theory. Exception: an accept/reject boundary is one rule and stays in one table — see Model Rejection as an Expected Column.
Table Design
One Rule, One Axis
A table is one rule varying along one axis. The axis is what the cases change; everything else is either held constant or collapsed into a value set. Most decomposition questions are that one question asked again — what is this table's axis, and does every column and case serve it?
If you cannot name a behaviour without using "and", it is two concerns. Split them, and give each its own table.
The naming test passes on a conjunction, and a conjunction is still several rules. A rule of the form "X holds only if C1 and C2 and C3", where the conditions do not mention one another, names cleanly in one breath — "sorts waste into bins" — while being three independent claims. Give each condition its own table, holding the others satisfied. Crossing them instead multiplies cases without adding a claim, and no case then isolates the condition it was meant to show.
The test is whether the rule can be stated about each condition alone — not whether the inputs are separate. Several inputs that are each a contribution to one answer are one rule, however separately they arrive: quantities that are weighted and summed, amounts that accumulate into a total, parts that combine into a whole. There is no claim to make about one of them by itself, because the answer is the combination. Splitting those gives one table per input, each holding the others at nothing, and no table then shows them combining — which is the only interesting case. Keep them in one table with a column each, and let some of its cases carry several contributions at once. Those cases belong to that table, which owns the combining rule; they are not a second table run end to end — see A Combining Table Needs Its Own Rule.
Hold the inputs belonging to other concerns at one obviously-valid value. An input this rule claims not to affect the outcome is the opposite situation and has to vary — see Show That an Input Does Not Change the Outcome.
Separate tables reduce cases by avoiding unnecessary permutations, and the table count guides the implementation: five concern tables suggest five functions.
# Two concerns, two tests. "Duty eligibility AND rest credit" fails the "and" test.
@pytest.mark.parametrize(("hours_since_rest", "max_duty_hours", "fit_to_fly"), [
pytest.param(13, 13, True, id="at the limit"),
pytest.param(14, 13, False, id="past the limit"),
])
def test_fitness_to_fly(hours_since_rest, max_duty_hours, fit_to_fly):
assert is_fit_to_fly(hours_since_rest, max_duty_hours) == fit_to_fly
Include All Outputs of a Concern
When an operation produces several observable outputs, include them all as expectation columns in one table. Each case then gives the complete picture of what happens for that scenario. Splitting the outputs of one concern forces the reader to cross-reference several tables to understand one behaviour.
Separate tables are for separate concerns, never for separate outputs of the same concern.
Every expectation column must be exercised by the cases the table varies. A column that is constant down every case, or that changes only as a side effect of another column, is not being tested. Two repairs, and which is right depends on the rule:
- Give it cases that vary it, when the column does belong to this table's axis and the cases were missing.
- Move it to the table whose axis varies it, and drop it here rather than keeping it "for completeness".
@Test("Climate response by humidity and temperature", arguments: [
(humidity: 80, temp: 28, vent: Vent.open, heater: false, alert: String?.none),
(humidity: 80, temp: 8, vent: Vent.closed, heater: true, alert: "Condensation risk"),
(humidity: 55, temp: 21, vent: Vent.closed, heater: false, alert: String?.none),
])
func climateResponse(humidity: Int, temp: Int, vent: Vent, heater: Bool, alert: String?) {
let response = ClimateController().respond(humidity: humidity, temp: temp)
#expect(response.vent == vent)
#expect(response.heaterOn == heater)
#expect(response.alert == alert)
}
Decompose When You See These Signs
One Rule, One Axis gives the first test — a behaviour you cannot name without "and" is two concerns. These are the signs that show up later, once the table exists:
- Some cases need columns that other cases leave at a placeholder value throughout.
- Scenario ids need qualifiers — "…for eligibility" against "…for pricing".
- The table has two groups of expectation columns that never both apply in the same case.
Missing concern: an input to one rule is itself derived from raw data. The derivation has its own edge cases and needs boundary cases of its own. The rule's table then takes the derived value as a direct input column, not the raw data. Two tables, not one.
Ask of every input column where its value comes from. Either it arrives from outside, or a rule computes it — and a rule that computes it is a table you have not written yet. Two shapes say you skipped it, and they look nothing alike: the raw data is a column and the derived value is nowhere, so the derivation happens inside the cases where no case can put a boundary on it; or both are columns of the same table, so the derived one restates a value already present and the rule connecting them is legible only by reading the cases against each other. Split either way — the deriving rule takes the raw data and reports the value, and this table takes that value as an input column and never sees the raw data. That the value must be visible is not the question; which table it is a column of is.
A column at a placeholder value throughout for most of its cases is a column decision before it is a table decision. Ask what the sparse columns feed. Several feeding the same expectation column are one family: collapse them into one column keyed by member, and the table stays whole. Feeding different expectation columns, they are different concerns and split into separate tables.
Do not over-split either. Several tables that fix the same setup, each vary one sub-rule, and all report the same expectation column are one concern scattered — one table per adjustment, per option, per flag. That shape is the symptom; the cause is a family you did not name.
If you can name what several tables have in common in one term, they are one concern — that term is the table, and its members are a column. This is the mirror of the "and" test. Renal impairment, low body weight and an interacting drug all adjust the standard dose: three rules, one family, one table with an adjustment column. Naming the members instead commits to the split before a single case exists, which is why this is decided when you name the table.
Members of a family compute differently, and that is not a reason to split. One adjustment is a flat reduction, another a percentage, another a recalculation. The differing computation is what the cases show; it is not what makes them separate tables.
Collapsing a family means one table, not necessarily one column. Where the members arrive as separate inputs the system reads independently, a single column keyed by member cannot feed them — routing one value to the right input would put a decision in the test itself, which is never the answer. Give each member its own column in the one table, and leave it at a placeholder value throughout on the cases where that member does not apply. The family is still stated as one rule, the members still sit side by side, and the sparse columns are what shows which member each case exercises. Reach for the keyed column when the members are values one input takes; reach for a column each when they are inputs of their own. Splitting into a table per member is the wrong answer in both cases — and it is the tempting one, because it needs no decision.
Collapse on a family, never on a bag. A family is a domain category, not "everything that affects the answer". The check: the family name works as a column header with the members as its values. Where no such name exists the tables are genuinely distinct and belong apart — and so they do where collapsing would cross-multiply, or leave cases whose purpose is no longer legible.
public static TheoryData<string, Adjustments, int> DoseCases => new() {
{ "no adjustment", new(), 500 },
{ "renal impairment", new(Renal: Severe), 250 },
{ "low body weight", new(WeightKg: 20), 200 },
{ "interacting drug", new(Interaction: true), 400 },
{ "renal and interacting drug", new(Renal: Severe, Interaction: true), 200 },
};
One family, one theory. Three separate theories fixing the same setup would be the over-split.
A Combining Table Needs Its Own Rule
Once every rule has a table, the pull is to add one more that runs the whole feature end to end. It re-proves what the single-rule tables already established, and it reads as redundant however clean those tables are.
A table that combines concerns earns its place only where the combination behaves in a way neither concern shows alone — a precedence, an ordering, an interaction whose result neither parent table produces — and then it carries only the cases that show it. A table proving that a weight-based dose is computed before the daily maximum caps it is a real table: the question is which rule applies first, and its expected values appear in no other table. A table whose cases re-run each dose band through the public entry point is not.
Collapsing several same-fixture tables into one is not a combining table, and Decompose When You See These Signs requires it. The difference is what the merged table states: a family table states one rule with its members as a column, while a combining table re-runs rules other tables have already established. The first has a rule of its own; the second is a second pass over the ladder.
Two symptoms:
- The description gives it away. If the description you would write is "end-to-end scenarios combining the rules from the tables above", the table has no rule of its own. Delete it.
- Wiring is not a rule. Reaching a rule through the public API rather than the unit under test does not make it a new rule. If the wiring genuinely needs showing, that is one case, not a second pass over the ladder.
Salvage its cases before you delete it — and then delete it. One or two cases of an end-to-end table often reach a case no single-rule table does. Deleting the table takes those with it and nothing reports the loss, so list the obligations only its cases discharge and move each into the table that owns its rule.
This is a salvage step, not a reprieve — no outcome of it keeps the table. A case worth keeping is worth keeping somewhere else. Nor does shrinking the table save it: a single test that runs the whole feature to re-prove one already-proven total is the same combining table with fewer cases.
// Earns its place: the cap-versus-weight precedence appears in no other test.
@Test("Daily maximum caps the weight-based dose", arguments: [
(weightKg: 40, dailyMax: 400, dose: 200),
(weightKg: 120, dailyMax: 400, dose: 400),
])
func dailyMaximumCaps(weightKg: Int, dailyMax: Int, dose: Int) {
#expect(Dosing.daily(weightKg: weightKg, dailyMax: dailyMax) == dose)
}
Separate Rules from Arithmetic
Tables specify the interesting decisions — classifications, eligibility rules, tier lookups, state transitions — not that multiplication works.
The symptom is an expectation cell you cannot predict in one step. If reading a case means classifying first and then computing, the table has fused two rules and states neither.
Give the classification its own table, whose expectation columns are the classification. Give the arithmetic its own, taking the classification as an input. Each table then states one rule, and every cell is predictable from its case.
This usually needs a narrower function to call. A table that can only reach the fused result means the seam is missing, not that the table must fuse.
Putting the classification in a column of the fused table satisfies this test without splitting anything. With the classified value beside the raw data, every cell is predictable in one step again — and the rule that produces it has still not been stated anywhere. One-step predictability is necessary, not sufficient; Decompose When You See These Signs asks the second question.
Where you may not add the seam, name it. Code you cannot change still has the boundary in its behaviour, and a table that fuses two rules without saying why reads as a design choice. One sentence on a published surface fixes that — "the intermediate score is not observable, so the decision and the amount are verified together; an accessor for it would allow two tables." Whether the gap gets closed in the code or bridged here is then the reader's decision to make, which it cannot be while the gap is invisible.
// Test 1 — the classification
test.each`
dutyHours | normalHours | extendedHours
${8} | ${8} | ${0}
${13} | ${13} | ${0}
${14} | ${13} | ${1}
`('$dutyHours duty hours divide into normal and extended', ({ dutyHours, normalHours, extendedHours }) => {
expect(splitDutyHours(dutyHours)).toEqual({ normalHours, extendedHours });
});
// Test 2 — the arithmetic, taking the classification as input
test.each`
normalHours | extendedHours | restCredit
${13} | ${0} | ${13.0}
${13} | ${1} | ${15.0}
`('rest credit for $normalHours normal and $extendedHours extended', ({ normalHours, extendedHours, restCredit }) => {
expect(restCreditFor(normalHours, extendedHours)).toBe(restCredit);
});
Give Each Obligation Exactly One Case
The obligation list comes from the inputs, and producing it is the step most often skipped. Before counting cases, take each input the rule reads and ask three questions of it. Where an answer is not obvious from the requirement, it names cases nothing else will.
- Counted, or read in bands? A rule reading an input in bands gives two different values the same answer, and the only way to state that is two cases differing in that input and agreeing in the expectation. A table whose every value of it carries a different expectation has stated "the answer rises with this input", which is a different rule from the one you meant.
- Per unit of it, or once for having any? Two cases — none and one — show a difference and are equally consistent with both readings, because a flat charge for having any at all is an ordinary rule. A third consecutive value is what decides between them. Without it the table leaves ambiguous the very rule it was written to state.
- Does its effect depend on another input? Where it does, the pair showing that effect has to appear on both sides of the other input's boundary. One pair, however well chosen, states an effect that is wrong wherever the other input differs.
Answer all three before writing cases. The questions cost nothing, and the cases they produce are exactly the ones a reader cannot infer from the others.
The right number of cases is a covering problem. List the concern's obligations — the distinct behaviours the rule must demonstrate — then write the smallest set of cases that covers all of them. Both errors are real and they do not read alike: a missing obligation lets a wrong implementation pass, while a repeated one costs the reader time and suggests a distinction that is not there.
The test for a redundant case, and it is decidable inside the table in front of you: where two cases share an expectation, ask whether swapping one's differing input for the other's would change an expectation cell in this table. If it would not, they are one case — and a value set is how you say so.
"Exactly one" is a floor as well as a ceiling, and consolidating is where the floor gets broken. Trimming a table is the moment to re-read the obligation list, because the cases that look most redundant are often the ones carrying an obligation of their own. Three shapes account for nearly every obligation dropped that way:
- A second input in a different state, mistaken for a larger value of the same one. Acting on something already populated is not a bigger version of acting on something fresh — it is the case where existing content has to survive, and nothing else shows it.
- The transition that empties or fills. Removing the last member, filling the final slot: the case looks like the ordinary case with smaller numbers, and it is the only one that reaches the boundary of the container.
- A distinct branch that shares its expectation with a neighbour. Two cases agreeing on the
answer are not redundant when they reach it by different routes — but the routes have to differ
in what this table expects, not in what its rule mentions, which is what the test above decides.
A value the rule names is not thereby a branch: enumerating the members is how a rule gets
stated, and the table's job is to show which of them the answer turns on. Kinds of a thing that
another rule tells apart are the usual false positive: three cases for three kinds, where the
rule under test reads only whether the thing was valid.
Collapsing means the value set, not the delete key. Put every kind in the surviving cell —
{percentage, fixed, product-specific}— because the description will still claim the kind makes no difference, and deleting the cases leaves that claim with nothing behind it.
When you cut a case, say which surviving case discharges its obligation. If none does, keep it — but a value set discharges every obligation its members carried, because it expands into one case per value. Collapsing cases into a value set is not cutting them, and the floor is not in play.
Two closed sets of inputs are where the floor gets misread. With m values of one input and n of another, every one of the m×n combinations is a case the rule names, so every one looks like an obligation of its own and the cases grow to the full cross-product. The obligations are the distinct answers, not the combinations: group the combinations that share an expectation, give each group one case, and let the value sets carry the members. This is a case count, not a table count — one rule still means one table, however its inputs multiply.
And three shapes account for nearly every genuinely redundant case:
- Further past the same boundary. A pair that straddles a boundary earns both its cases: the outcomes differ, and that is the rule. A second case on the same side does not, and the same holds for rejections — one case just past a limit rejects, and a case further past it rejects for no new reason. It earns its place only where the point is that two inputs collapse to one behaviour, and then a value set says that in one case; keep two and the scenario ids have to carry why.
- A larger n in the same direction. If two incompatible items force a batch into separate streams, three incompatible items force it for the same reason. One obligation, one case.
- A value the rule ignores. The redundancy test above, applied directly: one case carrying a value set. Where the value that differs sits inside a composite cell, the collapse needs the column reshaped first — a value set varies a whole cell, never one part of one. Ask what else in that cell this table reads. If nothing does, the object does not belong in the cell and the value does: give it a column of its own and fix the object's other parts outside the table — bar any part a surface makes a claim about, which has to stay visible (Assume the Table Is Published). If other parts are read too, add a further element instead, so one case carries every state and the near-duplicate pair never arises.
One value can carry two obligations, in two different tables. A value that is a boundary for one rule is often the subject of another. A zero duty period is both the accepted end of "duty hours cannot be negative" and the input that should produce no rest requirement whatever the crew size — two rules, two questions, two cases in two tables. Showing the value once, in whichever table you reached first, feels like coverage and is not. Count obligations per rule, never per value.
tests := []struct {
name string
dutyHours float64
extraRestRequired bool
}{
{"at the duty limit", 13, false},
{"just past the duty limit", 13.5, true},
// {"well past the duty limit", 20, true}, <- redundant: 13.5 already proved it
}
Cover Every Tier and Both Sides of Every Boundary
When inputs map to tiers — rate bands, size categories, standings — every tier appears in the cases, and every boundary is exercised from both sides: the last value inside a tier and the first value of the next.
Middle-tier boundaries are the ones most often skipped. Outer edges alone do not pin down where the middle tiers change.
Pick the pair's unit from the finest distinction the rule draws, before writing either value.
Where the rule separates 29 days 23 hours from 30 days 1 hour, whole-day cases of 30 and 31
straddle nothing — the column is Hours Ago and not Days Ago. A boundary drawn in a unit coarser
than the rule is not drawn at all, however many cases surround it.
And express it as an offset from the reference point, not as an absolute value restated in every
case. Hours Ago is the whole example: it fixes the unit and keeps the case readable, where
absolute instants pin the same boundary while making the reader subtract before the rule is visible.
Assume the Table Is Published sends the reference point itself to a column; this rule owns the unit,
and one choice satisfies both. A boundary win bought with an unreadable cell has been paid for twice.
A formula behind the tiers does not reduce the tiers. If you find yourself arguing that two tiers and the delta between them determine the rest, that is the formula talking: the table pins the tiers the rule names, and identifying the formula is the implementation's job. Nine tiers stay nine cases.
And it does not reduce the boundaries. Where the tier is decided by a formula over several inputs, every input still has a value at which the outcome flips, and that pair is what the cases have to straddle — one just below it, one just above, the other inputs held. Sampling that input at two comfortable values instead exercises the arithmetic and leaves the boundary untested.
A ladder repeated across several classes states its boundaries once. Where one ladder is priced or graded differently per class — the same usage bands on every tariff, the same age brackets in every region — exercise both sides of every boundary in one class, and give each other class one case per tier:
Standard, at band 1 limit | standard | 100 | 12.00
Standard, band 2 begins | standard | 100.01 | 18.00
Standard, at band 2 limit | standard | 500 | 18.00
Standard, band 3 begins | standard | 500.01 | 30.00
Economy, band 1 | economy | 50 | 9.00
Economy, band 2 | economy | 300 | 13.50
Economy, band 3 | economy | 800 | 22.50
The check is whether the boundary positions differ between classes. If they differ, each class has its own ladder and owes its own straddling pairs. If they do not, the positions belong to the ladder and the classes differ only in their values — which is what their one-case-per-tier lines state.
Duplicated implementation is not a reason to repeat the pairs. That the same comparison is written out once per class is a property of the code, and Design Black-Box Tables is where that argument stops: the table states the rule the specification gives, and a specification with shared bands declares one ladder. Without this, the obligation above reads as boundaries × classes and the case count multiplies with nothing added.
Where a tier is a range rather than a single value, a value set spanning it carries its own boundaries — provided its first and last members are the tier's own first and last values. The straddling pair is then already written: the last member of one case's set and the first member of the next case's. State the tier's edges, not two comfortable values inside it — a set of middle values straddles nothing and the explicit pair is still owed. Done that way a separate "tier begins" case discharges nothing the "tier holds" case has not, and one case per tier covers the whole ladder and every boundary in it. That is economy inside a case and buys no licence to drop cases: shortening each tier to one cell makes the ladder look repetitive long before it is complete.
This is the coverage half of Give Each Obligation Exactly One Case, and the two meet at a boundary: the straddling pair is required here and earns both its cases there. A third case further past the same boundary is what the other rule removes.
test.each`
haemoglobin | band
${124} | ${'DEFER'}
${125} | ${'STANDARD'}
${159} | ${'STANDARD'}
${160} | ${'REVIEW'}
`('haemoglobin $haemoglobin falls in the $band band', ({ haemoglobin, band }) => {
expect(donationBand(haemoglobin)).toBe(band);
});
Show That an Input Does Not Change the Outcome
You have just concluded that some input does not affect this rule. That conclusion is a rule too, and it needs a case that could contradict it: put every value the rule ignores in the cell, as a value set.
Dropping the input states nothing. Leaving the column out reads exactly like having forgotten it — nothing on the page tells a reader which happened, and no case can contradict a claim the table never makes. A blank is no better and wrongly suggests the field is absent. The value set makes the claim explicit — this rule holds for all these values — and one case states it more precisely than two near-identical ones.
The clearest sign you want one: a column that could carry every one of its values on every case without changing anything. That is the rule saying, in data, that it does not read the column.
If the operation does not take the input, fix that before the table. A rule cannot say it ignores what never reaches it, and leaving the parameter out publishes nothing: the reader sees an operation that was never offered the value, which is silence rather than a claim. A column the code never receives will not do instead — that route is open to a threshold, which only has to be readable (Make Thresholds Visible), and closed to this rule, whose claim has to be exercised to be contradictable. Decide it by asking who supplies the value: if a caller hands it over with the request it is an input, so keep it and let the table vary it, even where the code will not read it.
If the ignored value sits inside a composite cell, the value set is the wrong tool — and it is not
the only one. {a, b} written inside a cell adds no cases: expansion varies a whole cell, never
one part of one. Two routes remain, and the cheap one is easy to miss. Where the cell holds a list,
vary the ignored value across its elements — one case, three entries, three different values,
nothing reshaped. Where it holds a single object, give the value its own column if this table reads
nothing else in that cell (Give Each Obligation Exactly One Case).
Fixing the value in the converter is the move to catch yourself making. It is the cheapest thing to write and it drops the claim altogether: what reaches the reader is one hard-coded value they cannot tell from an oversight, and a sentence about a rule that no case exercises. The reshape is worth it because the claim is; if the claim is not worth a column, it was not worth the sentence either.
A value set cannot vary an expectation. It expands the case into one case per value, and every expanded case keeps the same expectation cells. Where the answer differs per value, those are ordinary distinct cases.
Every value in the set must produce the same result. If the results differ, the input does matter and belongs as ordinary distinct cases. Never use a value set as shorthand for "test several values".
Two different situations, two different treatments. An input that another rule owns is held at one obviously-valid value. An input that this rule claims not to affect has to vary across the values it ignores — otherwise no case could ever contradict the claim.
Value sets work on two axes — check both. Within a case, group input values that produce the same outcome. Across cases, collapse duplicates: when two input values produce the same expectation cells in this table, one case carrying both replaces two identical ones. It is easy to apply one axis and miss the other.
Judge that per table, not across the whole class. Two values that this rule treats alike collapse here even if a neighbouring rule tells them apart — grouping them says this rule does not distinguish them, which is exactly what the neighbouring table then contradicts, on the record. Ask only whether the expectation cells match in the cases in front of you. A category you have named as a catch-all is the easy case and gets collapsed almost automatically; the one that gets missed is two values you think of as distinct that this particular rule happens to treat the same.
# One case per ignored value, varying together — not stacked generators, which cross them.
@pytest.mark.parametrize(("haemoglobin", "recent_travel"), [(125, True), (140, False)])
def test_under_age_donor_is_ineligible_regardless(haemoglobin, recent_travel):
assert not is_eligible(age=16, haemoglobin=haemoglobin, recent_travel=recent_travel)
Age alone decides it, and the two inputs it ignores each take both their values, so a case could contradict the claim.
Frame Stateful Features as Transition Rules
When a feature involves state — queues, workflows, inventories — frame each case as a state transition rule: the state before, the action, the state after, and any message or result.
Each case is independent: given this state, when this action happens, expect this result. No case depends on a previous one having run.
Include the before and after columns even when the description states the operation procedurally.
A sequential path — step 1, then step 2, then step 3 — creates case dependencies and is not a table at all.
tests := []struct {
name string
binBefore Bin
action Action
binAfter Bin
message string
}{
{"accept a labelled item", Bin{}, Deposit("cardboard"), Bin{"cardboard": 1}, "Accepted"},
{"fill to the bulk limit", Bin{"cardboard": 1}, Deposit("cardboard"), Bin{"cardboard": 2}, "Accepted"},
{"reject a mismatched item", Bin{"cardboard": 1}, Deposit("solvent"), Bin{"cardboard": 1}, "Wrong stream"},
}
Assume the Table Is Published
Write every table as if a reader will meet it in a published report, never having seen the code. Only three surfaces reach that reader, and they divide the work:
| Surface | Carries |
|---|---|
| the test function name, or the display name the framework shows | the rule, as an action the code performs |
| the test's docstring or leading comment | the apparatus that cannot be a column — what is held constant, where the data came from |
| the table | the variations the rule ranges over |
Whatever the table holds constant is silently promoted into the rule. Readers generalise from what varies, so a value that never varies is read as part of the rule: a duty-limit table whose every case assumes a two-pilot crew states, to its reader, a rule about two-pilot crews.
So a constant the outcome depends on is a column wherever it can be one — and a value the rule turns on, such as a threshold or a limit, always can be. The other two surfaces carry what a column cannot: where the data came from, what the fixture fixes, an assumption the cases cannot state.
Once a value is a column, it is declared — check the table before writing about it or adding to it. A sentence in the test's docstring or leading comment naming a value the cases already show tells the reader nothing they cannot read off the table, and a column that does not vary is declared just as well as one that does. Being constant is not on its own a reason to add a case varying it: where a constant column hides a second axis, Make Thresholds Visible owns that question — and when the cases are given to you, by a conversion or a supplied set of examples, adding one to vary the constant changes the question you were asked. Boundary and tier cases are a separate obligation and are never what this paragraph is about.
Which table it is a column of is a separate question, and this rule does not answer it. Where another rule derives the value, it is an input column here and an expectation column there — see Decompose When You See These Signs, which owns that split. Making a value visible is never a reason to absorb the rule that produces it.
If the declaration says the value does not matter, declaring it is not enough. "Held empty throughout, and it makes no difference" is not apparatus — it is a claim about the rule, and a claim no case can contradict is not stated in the table at all. Vary it instead, across the values it ignores; see Show That an Input Does Not Change the Outcome. Write a fixture into the the test's docstring or leading comment only for what the rule genuinely reads and the cases cannot show.
Making a value a column does not force everything measured from it into the same form. Once a reference point is declared — a clock, an origin
…(truncated)