Parameterised Scenarios
One step definition, a table of examples, then property-based tests.
The second near-duplicate scenario is the prompt to parameterise. The fifth row of an Examples table is the prompt to ask whether the property would take the load. The discipline is the same one F3 Don't Repeat Yourself enforces on production code, on the testing surface.
Opinion
I've cleaned up too many feature files where the same scenario was copy-pasted forty times for forty currencies, each with the value hard-coded into the step text. The first developer wrote five scenarios because that's what the spec asked for. The next developer copied the pattern. Six sprints later there are 200 scenarios and one bug-fix needs the same change applied 200 times. The rule is the same one F3 DRY enforces on production code: when the second near-duplicate appears, parameterise.1 TS3 is the application of that rule inside the testing folder.
The two Cucumber primitives are siblings rather than the same thing. A parameterised step definition (Given I am on the {string} page) collapses N near-duplicate step definitions into one, with the values inline.2 A Scenario Outline with an Examples table collapses N near-duplicate scenarios into one outline, with the values in a table.3 Both belong. Both pull duplication out of the feature file at different altitudes. Most teams use one without thinking about the other.
The third rung is property-based testing (QuickCheck in 2000, Hypothesis in 2015, fast-check tracking both) and it is the rung most teams never climb.4 The Examples table is a hand-curated set of property points. A property-based test generates them. The team that writes 200 hand-rolled currency rows could write one property: for any positive amount in any supported currency, the round-trip through conversion and formatting preserves the precision the regulator requires . The generator finds the edge case the developer would not have written. Same axis, different rung.
Copy a note and link
Grab this short comment and drop it into a PR comment or an LLM chat to prompt the right change.
Generic steps, table-driven data, properties when the table is infinite. One parameterised step replaces five near-duplicate scenarios. One Scenario Outline beats five copy-pasted features. One property-based test beats a thousand hand-written rows. Reach for the next rung when the current one starts costing more than it saves. /tenet/parameterised-scenarios/TS3
AI eyes only
Rule: parameterise the step, table the data, then generate the rows.
Reject: copy-paste scenarios with one-row variations. Reject: writing the fortieth currency scenario instead of an Outline. Reject: parameterising things that were duplicated for a reason.
Generate: at the second near-duplicate, convert to a Scenario Outline with an Examples table. At an open-ended set of cases, write a property-based test.
Diagnostic: count near-duplicate scenarios. The second is the trigger to parameterise. Don't wait for a third.
Why?
- Two hundred copy-pasted scenarios collapse into one outline plus a table. The diff that added the fortieth currency is one row, not forty PR comments.
- The Examples table reads as the documentation. The header is the schema. Each row is a scenario. The table doubles as a reference for non-developers.
- Same operating principle as F3 Don't Repeat Yourself on the testing surface. The second near-duplicate is the trigger. Waiting for a third lets the duplication compound.
- Property-based testing is the next rung up. When the Examples table would be infinite, the generator produces the rows the developer wouldn't have written and the shrinker reports the minimal counter-example.
- Parameterised step definitions reduce step-file noise. One
I am on the {string} pagestep replaces twelve near-identical step methods. - Coding agents reach for parameterisation when the rule is in front of them. The second near-duplicate triggers a refactor rather than another forty.
- The combinatorial coverage that hand-rolling cannot produce. A thirty-six-row table delivers thirty-six scenarios in the space of one outline. A property-based test delivers thousands.
Origins
The Gherkin Scenario Outline keyword arrived in Cucumber 0.1.13, eleven weeks after the first gem release (Aslak Hellesøy, 2008) and is documented in Cucumber's reference grammar:3 “the keyword can be used to run the same Scenario multiple times, with different combinations of values.” The Examples table is part of the same primitive;<>-delimited parameters reference column headers, and Cucumber substitutes values before the step matches a definition. The mechanism is older than most Gherkin teams realise.
Parameterised step definitions predate Cucumber Expressions: regular-expression capture groups carried them from 2008. The Cucumber Expressions overhaul (2016–2017) arrived with {string}, {int}, {float}, and user-defined parameter types in the step signature.2 Wynne and Hellesøy's Cucumber Book7 is the canonical reference. Their guidance is to organise step definitions by domain concept, then parameterise inside each concept to avoid duplication.
Property-based testing, the third rung, predates BDD by half a generation. Koen Claessen and John Hughes's 2000 ICFP paper introduced QuickCheck for Haskell.4 David MacIver's Hypothesis (2015) ported the model to Python with shrinking-driven minimal counterexamples.8 fast-check tracks both for the JavaScript ecosystem.9 The model is consistent across implementations: where the example-based test asserts a specific input-output pair, the property-based test asserts a relation that holds for any input drawn from a generator.
The cross-pillar lineage runs through F3 DRY. The second near-duplicate is the trigger; TS3 is that rule applied to the testing surface, with three rungs scaled to the cost of the fix at each tier.
Quotes
The Scenario Outline keyword can be used to run the same Scenario multiple times, with different combinations of values.
Property based testing is the construction of tests such that, when these tests are fuzzed, failures in the test reveal problems with the system under test that could not have been revealed by direct fuzzing of that system.
In QuickCheck, assertions are written about logical properties that a function should fulfill. Then QuickCheck attempts to generate a test case that falsifies such assertions.
Avoid writing similar step definitions, as they can lead to clutter.
Evidence
15 external sources, ranked by author authority. The first five are the canon; expand to see the rest, including the qualifiers and the named opposers. Each links out to its primary source.
- 01The canonical reference for the Scenario Outline keyword and the Examples table. “The Scenario Outline keyword can be used to run the same Scenario multiple times, with different combinations of values.”
- 02Cucumber ExpressionsSupportsDocuments the parameterised-step half: `{string}`, `{int}`, `{float}`, user-defined parameter types. The mechanism that turns N near-duplicate step definitions into one.
- 03What is Property-Based Testing?Supports“Property based testing is the construction of tests such that, when these tests are fuzzed, failures in the test reveal problems with the system under test that could not have been revealed by direct fuzzing of that system.” The idea TS3 uses for the third rung.
- 04The original property-based testing paper. The idea TS3’s third rung inherits: a property is a relation that holds for any input drawn from a generator.
- 05The reference text on operating parameterised steps. Hellesøy designed Cucumber Expressions; Wynne’s chapter is the canonical guidance on when to parameterise.
- 06Names the failure mode that parameterisation prevents: steps welded to one feature, single steps that join two actions with `and`. The boundary between SBE and step-script imitation.
- 07Step OrganizationSupports“Avoid writing similar step definitions, as they can lead to clutter.” The shortest in-canon statement of the parameterise-on-duplication rule.
- 08Hypothesis library: overviewSupportsThe mainstream Python implementation. Notable for shrinking-driven minimal counterexamples. The failure message is a single canonical input, not a tuple.
- 09The mainstream JavaScript implementation. Tracks both QuickCheck’s combinator API and Hypothesis’s shrinking model. The third-rung tool for Vitest and Jest projects.
- 10Increment magazine essay. The accessible mainstream introduction to the third rung. The right link to send a developer who hasn’t reached property-based testing yet.
- 11JUnit 5 ParameterizedTestSupportsThe JUnit-side equivalent of the Examples table. `@ParameterizedTest` with `@CsvSource` or `@MethodSource` turns one method into N tests with the data inline or external.
- 12pytest.parametrizeSupportsPython’s native parameterised-test primitive. Same shape as JUnit’s ParameterizedTest. The test name in the reporter shows the cell values, so the parameterisation stays readable.
- 13Sharpens the parameter-explosion smell. A parameterised test that hides the variation behind data plumbing produces failure messages that are tuples rather than sentences. The trigger to escalate to property-based, not retreat to copy-paste.
- 14Testing vs. CheckingQualifiesChecking confirms existing beliefs; testing looks for new information. Applied to parameterisation: a table that passes is a regression net rather than a probe. Reach for property-based testing or exploratory practice to restore the questioning view.
- 15The paper behind JUnit 4.4’s `@Theory` annotation (2007): theories express properties over potentially infinite sets of values. JUnit 4.0’s `Parameterized` runner (2006) came first.
Fifteen sources, three rungs. Cucumber's docs and Wynne & Hellesøy hold the in-feature mechanics. The MacIver / Hughes / Claessen line holds the property-based rung: QuickCheck in 2000, Hypothesis in 2015, fast-check tracking both. The qualifying voices further down hold the over-parameterisation steelman the reply addresses.
Examples
Before five near-duplicate tests. One row per tier, hand-rolled.
it("rejects rescue when sanctuary at capacity 10", () => { expect(rejectsAtCapacity(fillSanctuary(10))).toBe(true); }); it("rejects rescue when sanctuary at capacity 20", () => { expect(rejectsAtCapacity(fillSanctuary(20))).toBe(true); }); it("rejects rescue when sanctuary at capacity 30", () => { expect(rejectsAtCapacity(fillSanctuary(30))).toBe(true); }); it("rejects rescue when sanctuary at capacity 40", () => { expect(rejectsAtCapacity(fillSanctuary(40))).toBe(true); }); it("rejects rescue when sanctuary at capacity 50", () => { expect(rejectsAtCapacity(fillSanctuary(50))).toBe(true); });
After one table-driven test. The rows are the spec.
it.each([10, 20, 30, 40, 50])( "rejects rescue when sanctuary at capacity %d", (capacity: number) => { expect(rejectsAtCapacity(fillSanctuary(capacity)).ok).toBe(true); }, );
capacity.property.spec.tsnext rung: when the table would be infinite, generate the inputsit("rejects every rescue when intake equals capacity", () => { fc.assert(fc.property(fc.integer({ min: 1 }), (n) => rejectsAtCapacity(fillSanctuary(n)).ok === false)); });
Before five near-duplicate tests. One row per tier, hand-rolled.
it("rejects rescue when sanctuary at capacity 10", () => { expect(rejectsAtCapacity(fillSanctuary(10))).toBe(true); }); it("rejects rescue when sanctuary at capacity 20", () => { expect(rejectsAtCapacity(fillSanctuary(20))).toBe(true); }); it("rejects rescue when sanctuary at capacity 30", () => { expect(rejectsAtCapacity(fillSanctuary(30))).toBe(true); }); it("rejects rescue when sanctuary at capacity 40", () => { expect(rejectsAtCapacity(fillSanctuary(40))).toBe(true); }); it("rejects rescue when sanctuary at capacity 50", () => { expect(rejectsAtCapacity(fillSanctuary(50))).toBe(true); });
After one table-driven test. The rows are the spec.
it.each([10, 20, 30, 40, 50])( "rejects rescue when sanctuary at capacity %d", (capacity: number) => { expect(rejectsAtCapacity(fillSanctuary(capacity)).ok).toBe(true); }, );
capacity.property.spec.tsnext rung: when the table would be infinite, generate the inputsit("rejects every rescue when intake equals capacity", () => { fc.assert(fc.property(fc.integer({ min: 1 }), (n) => rejectsAtCapacity(fillSanctuary(n)).ok === false)); });
Before five near-duplicate tests. One row per tier, hand-rolled.
it("rejects rescue when sanctuary at capacity 10", () => { expect(rejectsAtCapacity(fillSanctuary(10))).toBe(true); }); it("rejects rescue when sanctuary at capacity 20", () => { expect(rejectsAtCapacity(fillSanctuary(20))).toBe(true); }); it("rejects rescue when sanctuary at capacity 30", () => { expect(rejectsAtCapacity(fillSanctuary(30))).toBe(true); }); it("rejects rescue when sanctuary at capacity 40", () => { expect(rejectsAtCapacity(fillSanctuary(40))).toBe(true); }); it("rejects rescue when sanctuary at capacity 50", () => { expect(rejectsAtCapacity(fillSanctuary(50))).toBe(true); });
After one table-driven test. The rows are the spec.
it.each([10, 20, 30, 40, 50])( "rejects rescue when sanctuary at capacity %d", (capacity) => { expect(rejectsAtCapacity(fillSanctuary(capacity)).ok).toBe(true); }, );
capacity.property.spec.jsnext rung: when the table would be infinite, generate the inputsit("rejects every rescue when intake equals capacity", () => { fc.assert(fc.property(fc.integer({ min: 1 }), (n) => rejectsAtCapacity(fillSanctuary(n)).ok === false)); });
Before five near-duplicate tests. One row per tier, hand-rolled.
def test_rejects_rescue_when_sanctuary_at_capacity_10() -> None: assert rejects_at_capacity(fill_sanctuary(10)).ok is True def test_rejects_rescue_when_sanctuary_at_capacity_20() -> None: assert rejects_at_capacity(fill_sanctuary(20)).ok is True def test_rejects_rescue_when_sanctuary_at_capacity_30() -> None: assert rejects_at_capacity(fill_sanctuary(30)).ok is True def test_rejects_rescue_when_sanctuary_at_capacity_40() -> None: assert rejects_at_capacity(fill_sanctuary(40)).ok is True def test_rejects_rescue_when_sanctuary_at_capacity_50() -> None: assert rejects_at_capacity(fill_sanctuary(50)).ok is True
After one table-driven test. The rows are the spec.
@pytest.mark.parametrize("capacity", [10, 20, 30, 40, 50]) def test_rejects_rescue_when_sanctuary_at_capacity(capacity: int) -> None: assert rejects_at_capacity(fill_sanctuary(capacity)).ok is True
test_capacity_properties.pynext rung: when the table would be infinite, generate the inputs@given(n=integers(min_value=1)) def test_rejects_every_rescue_when_intake_equals_capacity(n: int) -> None: assert rejects_at_capacity(fill_sanctuary(n)).ok is False
Before five near-duplicate tests. One row per tier, hand-rolled.
@Test void rejectsRescueWhenSanctuaryAtCapacity10() { assertTrue(rejectsAtCapacity(fillSanctuary(10)).ok()); } @Test void rejectsRescueWhenSanctuaryAtCapacity20() { assertTrue(rejectsAtCapacity(fillSanctuary(20)).ok()); } @Test void rejectsRescueWhenSanctuaryAtCapacity30() { assertTrue(rejectsAtCapacity(fillSanctuary(30)).ok()); } @Test void rejectsRescueWhenSanctuaryAtCapacity40() { assertTrue(rejectsAtCapacity(fillSanctuary(40)).ok()); } @Test void rejectsRescueWhenSanctuaryAtCapacity50() { assertTrue(rejectsAtCapacity(fillSanctuary(50)).ok()); }
After one table-driven test. The rows are the spec.
@ParameterizedTest @ValueSource(ints = { 10, 20, 30, 40, 50 }) void rejectsRescueWhenSanctuaryAtCapacity(int capacity) { assertTrue(rejectsAtCapacity(fillSanctuary(capacity)).ok()); }
CapacityPropertiesTest.javanext rung: when the table would be infinite, generate the inputs@Property void rejectsEveryRescueWhenIntakeEqualsCapacity( @ForAll @IntRange(min = 1) int n) { assertFalse(rejectsAtCapacity(fillSanctuary(n)).ok()); }
Before five near-duplicate tests. One row per tier, hand-rolled.
public function testRejectsRescueWhenSanctuaryAtCapacity10(): void { $this->assertTrue(rejectsAtCapacity(fillSanctuary(10))->ok); } public function testRejectsRescueWhenSanctuaryAtCapacity20(): void { $this->assertTrue(rejectsAtCapacity(fillSanctuary(20))->ok); } public function testRejectsRescueWhenSanctuaryAtCapacity30(): void { $this->assertTrue(rejectsAtCapacity(fillSanctuary(30))->ok); } public function testRejectsRescueWhenSanctuaryAtCapacity40(): void { $this->assertTrue(rejectsAtCapacity(fillSanctuary(40))->ok); } public function testRejectsRescueWhenSanctuaryAtCapacity50(): void { $this->assertTrue(rejectsAtCapacity(fillSanctuary(50))->ok); }
After one table-driven test. The rows are the spec.
#[DataProvider('capacityTiers')] public function testRejectsRescueWhenSanctuaryAtCapacity(int $capacity): void { $this->assertTrue(rejectsAtCapacity(fillSanctuary($capacity))->ok); } public static function capacityTiers(): array { return [[10], [20], [30], [40], [50]]; }
CapacityPropertiesTest.phpnext rung: when the table would be infinite, generate the inputspublic function testRejectsEveryRescueWhenIntakeEqualsCapacity(): void { $this->forAll(Generator\pos())->then(fn($n) => $this->assertFalse(rejectsAtCapacity(fillSanctuary($n))->ok)); }
Enforcement
Apply these rules in .gherkin-lintrc. The full enforcement across every tenet lives on the implementation page.
| Rule | Tool | Catches |
|---|---|---|
| no-dupe-scenario-names | gherkin-lint | scenarios with exactly the same name. A sign that an Outline + Examples table is overdue. |
| scenario-size | gherkin-lint | scenarios over the steps-length threshold: usually a sign that a Background or a parameterised step definition is needed. |
| vitest it.each | Vitest | near-duplicate it() blocks. it.each turns the duplicates into one row per dataset entry, with the cell values printed in the reporter. |
| fast-check fc.property | fast-check | an Examples table that would be infinite. fc.property with arbitraries replaces the table with a generator; the shrinker reports the minimal counter-example. |
| fast-check fc.assert | fast-check | non-deterministic property runs. fc.assert with a fixed seed in CI keeps the property reproducible; flake-rate stays inside the budget. |
| playwright-bdd Scenario Outline binding | playwright-bdd | Outlines whose Examples table doesn’t match the parameterised step signature. Type-safe binding rejects the build before the suite runs. |
.gherkin-lintrcconfiguration snippet
{
"no-dupe-scenario-names": "on",
"no-restricted-tags": ["on", { "tags": ["@wip"] }],
"no-empty-background": "on",
"no-superfluous-tags": "on",
"scenario-size": ["on", { "steps-length": { "Scenario": 12, "Background": 5 } }],
"no-files-without-scenarios": "on",
"name-length": ["on", { "Feature": 70, "Scenario": 70, "Step": 100 }]
}Apply these rules in .gherkin-lintrc. The full enforcement across every tenet lives on the implementation page.
| Rule | Tool | Catches |
|---|---|---|
| no-dupe-scenario-names | gherkin-lint | scenarios with exactly the same name. A sign that an Outline + Examples table is overdue. |
| scenario-size | gherkin-lint | scenarios over the steps-length threshold: usually a sign that a Background or a parameterised step definition is needed. |
| vitest it.each | Vitest | near-duplicate it() blocks. it.each turns the duplicates into one row per dataset entry, with the cell values printed in the reporter. |
| fast-check fc.property | fast-check | an Examples table that would be infinite. fc.property with arbitraries replaces the table with a generator; the shrinker reports the minimal counter-example. |
| fast-check fc.assert | fast-check | non-deterministic property runs. fc.assert with a fixed seed in CI keeps the property reproducible; flake-rate stays inside the budget. |
| playwright-bdd Scenario Outline binding | playwright-bdd | Outlines whose Examples table doesn’t match the parameterised step signature. Type-safe binding rejects the build before the suite runs. |
.gherkin-lintrcconfiguration snippet
{
"no-dupe-scenario-names": "on",
"no-restricted-tags": ["on", { "tags": ["@wip"] }],
"no-empty-background": "on",
"no-superfluous-tags": "on",
"scenario-size": ["on", { "steps-length": { "Scenario": 12, "Background": 5 } }],
"no-files-without-scenarios": "on",
"name-length": ["on", { "Feature": 70, "Scenario": 70, "Step": 100 }]
}Apply these rules in round-trip.spec.ts. The full enforcement across every tenet lives on the implementation page.
| Rule | Tool | Catches |
|---|---|---|
| no-dupe-scenario-names | gherkin-lint | scenarios with exactly the same name. |
| scenario-size | gherkin-lint | scenarios over the steps-length threshold. |
| jest test.each | Jest | near-duplicate test() blocks. test.each handles the parameterisation with the dataset inline. |
| fast-check fc.property | fast-check | infinite Examples tables. |
| @cucumber/cucumber Outline binding | @cucumber/cucumber | Outlines whose Examples table doesn’t bind to a parameterised step. A row whose substituted step text matches no step definition is reported as an undefined step. |
round-trip.spec.tsconfiguration snippet
import { describe, it, expect } from 'vitest';
import fc from 'fast-check';
import { roundTrip } from './round-trip';
describe('round-trip', () => {
it('preserves any positive amount in any supported currency', () => {
fc.assert(
fc.property(
fc.double({ min: 0.01, max: 1_000_000, noNaN: true, noDefaultInfinity: true }),
fc.constantFrom('USD', 'EUR', 'GBP', 'JPY'),
(amount, currency) => {
const restored = roundTrip(amount, currency);
expect(restored).toBeCloseTo(amount, 2);
}
),
{ numRuns: 1_000 }
);
});
});Apply these rules in pyproject.toml. The full enforcement across every tenet lives on the implementation page.
| Rule | Tool | Catches |
|---|---|---|
| @pytest.mark.parametrize | pytest.parametrize | near-duplicate test functions. The decorator turns one function into N tests with the cell values in the reporter ID. |
| pytest --collect-only | pytest | missing visibility into what the parameterisation expanded to. Collect-only prints the full test ID list before the run; the table is the documentation. |
| @hypothesis.given | Hypothesis | an infinite parametrize table. The strategy combinators replace the table with a generator; the shrinker reports the minimal counter-example. |
| @hypothesis.settings | Hypothesis | non-deterministic property runs. derandomize=True or a fixed seed makes the property reproducible in CI. |
| pytest-bdd Outline binding | pytest-bdd | Outlines whose Examples table doesn’t bind to a parameterised step. The runner generates one parameterised test per row. |
| behave Scenario Outline | Behave | the same Examples-table mechanism for Behave projects. Each row in the table becomes one scenario in the report. |
pyproject.tomlconfiguration snippet
[tool.pytest.ini_options]
addopts = "-v --hypothesis-show-statistics"Apply these rules in pom.xml. The full enforcement across every tenet lives on the implementation page.
| Rule | Tool | Catches |
|---|---|---|
| @ParameterizedTest + @CsvSource | JUnit 5 ParameterizedTest | near-duplicate test methods. @CsvSource embeds the table inline; @MethodSource pulls it from a method or external file. |
| @ParameterizedTest + name template | JUnit 5 ParameterizedTest | low-fidelity reporter output. The {0}-style template makes each row in the dataset a sentence in the run. |
| @Property | jqwik | an infinite parameter set. jqwik’s arbitraries replace the table with a generator; shrinking finds the minimal failing input. |
| @ForAll + Arbitraries | jqwik | missing combinator coverage. The Arbitraries API mirrors QuickCheck’s combinator language for the JVM. |
| Cucumber-JVM Scenario Outline | Cucumber-JVM | Outlines whose Examples table doesn’t bind to a parameterised step. A row whose substituted step text matches no step definition is reported as an undefined step. |
| Gauge data tables | Gauge | the equivalent feature in Gauge: each row in the data table becomes one execution; the report renders one line per row. |
pom.xmlconfiguration snippet
<dependencies>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter-params</artifactId>
<version>5.14.4</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>net.jqwik</groupId>
<artifactId>jqwik</artifactId>
<version>1.10.1</version>
<scope>test</scope>
</dependency>
</dependencies>Apply these rules in composer.json. The full enforcement across every tenet lives on the implementation page.
| Rule | Tool | Catches |
|---|---|---|
| #[DataProvider] | PHPUnit #[DataProvider] | near-duplicate test methods. The provider returns one row per case; the test method runs once per row. |
| TestDox + parameterised name template | PHPUnit (testdox) | low-fidelity reporter output. The testdox formatter humanises the data-provider entry name; the row reads as a sentence. |
| Eris\TestTrait | Eris | an infinite data-provider table. Eris’s generators replace the provider; shrinking finds the minimal failing input. |
| Eris seed control | Eris | non-deterministic property runs. The seed knob keeps the property reproducible in CI. |
| Behat Scenario Outline | Behat | Outlines whose Examples table doesn’t match the step signature. Behat marks a step with no matching definition as undefined and skips the rest of the scenario. |
| Codeception parameterised cest | Codeception | the equivalent feature in Codeception. The #[Examples] attribute binds inline tables to Cest methods; #[DataProvider] pulls rows from a protected method. |
composer.jsonconfiguration snippet
{
"require-dev": {
"phpunit/phpunit": "^13.3",
"giorgiosironi/eris": "^1.0"
}
}AI rules
.cursor/rules/ts3-parameterised-scenarios.mdc---
alwaysApply: true
---
## Prickles TS3: Parameterised Scenarios
Replace near-duplicate scenarios with one step definition that takes `{string}` placeholders. The step definition is the noun. The parameter is the cell in the row.
Use Scenario Outlines with Examples tables for the in-Gherkin form. The header is the schema. Each row is a scenario. The table is the documentation.
Reach for property-based testing when the table would be infinite. QuickCheck, Hypothesis, fast-check generate the rows you'd never have written by hand.
Two is the trigger. The second near-duplicate is the moment to parameterise. Don't wait for a third.Repo layout, CI, and ESLint wiring for these paths live on /implementation, not repeated on every tenet.
Counter-argument
The honest steelman is parameter explosion. A Scenario Outline with twenty rows in the Examples table reads as a clean spec only until the next requirement adds a column. Now the table has 20 × 6 cells, the failure mode of any single row is harder to spot and the parameterisation that paid off at five rows is debt at twenty. Vladimir Khorikov sharpens the point for unit tests5: parameterised tests can hide the variation behind data plumbing, so the failure message reads as a tuple instead of a sentence. Michael Bolton's checking-versus-testing distinction6 applies here too: a parameterised scenario that passes the table is a regression net rather than a probe. The developer stops asking “what other rows would matter?”
Counter-argument retort
Khorikov's parameter-explosion objection5 is conceded inside its scope and refused outside it. Yes, a 20-row Examples table that grows a sixth column becomes harder to read. The answer is to escalate rather than abandon parameterisation: either split the table along the new dimension into two outlines, or promote the assertion to a property-based test that draws inputs from a generator and lets a shrinker find the minimal failing case. The over-parameterisation smell is the trigger for the next rung, not the trigger to retreat to copy-paste.
Michael Bolton's checking-versus-testing concern6 is the larger one and the reply concedes it explicitly. A parameterised scenario passes the table rather than asking new questions. Property-based testing restores the questioning view: the generator produces inputs the developer hadn't imagined and the shrinker reports the minimal counter-example. The third rung is what TS3 needs. The table is the second rung that deserves its place by paying off long enough to be worth keeping.
The over-parameterisation pathology is real and avoidable. Two heuristics keep the table honest. First, every column has to vary independently, if columns A and B always move together, they're one parameter and the table has the wrong shape. Second, the failure message has to read as a sentence, if the assert prints a tuple, parameterise the assert too, or split the outline. Both heuristics push toward the property-based form when the table reaches the ceiling of what an outline can carry.
The discipline reduces to three lines on the wall: parameterise the second near-duplicate, table the second Outline that earns it, generate when the table goes infinite. Each rung pays at a different threshold. The trigger is the cost of the next change rather than the count of the current rows. The classic “wait for three” counter is acknowledged and rejected: in the AI era, the agent reads the second near-duplicate before the human does and the cost of waiting compounds.
Notes
- [1]Andy Hunt & Dave Thomas: The Pragmatic Programmer (Addison-Wesley, 1999). Section 7 “The Evils of Duplication”: knowledge in a system should have a single, unambiguous, authoritative representation. The cross-pillar root for the parameterise-on-duplication rule.
- [2]Cucumber project: Cucumber Expressions (github.com/cucumber/cucumber-expressions, 2017+). The parameter-type system: `{string}`, `{int}`, `{float}`, user-defined types. The mechanism that collapses near-duplicate step definitions into one.
- [3]Cucumber documentation: Gherkin Reference. The canonical doc for Scenario Outline + Examples tables. “The keyword can be used to run the same Scenario multiple times, with different combinations of values.”
- [4]Koen Claessen & John Hughes: “QuickCheck: A Lightweight Tool for Random Testing of Haskell Programs” (ICFP 2000). The original property-based testing paper. It defines the property + generator pair TS3’s third rung inherits; shrinking appears only as Andy Gill’s extension in one case study.
- [5]Vladimir Khorikov: Unit Testing: Principles, Practices, and Patterns (Manning, 2020), ch. 3.5. The parameter-explosion smell: a parameterised test that hides variation behind data plumbing produces failure messages that are tuples rather than sentences.
- [6]Michael Bolton: “Testing vs. Checking” (developsense.com, 2009). Checking confirms existing beliefs; testing looks for new information. Applied to parameterisation: a table that passes is a regression net rather than a probe.
- [7]Matt Wynne & Aslak Hellesøy: The Cucumber Book, 2nd ed. (Pragmatic Bookshelf, 2017). Ch. 4 Step Definitions: From the Outside (Capturing Arguments) and ch. 5 Expressive Scenarios (Scenario Outline): the canonical guidance on when to parameterise; Hellesøy designed Cucumber Expressions.
- [8]David R. MacIver: “What is Property-Based Testing?” (Hypothesis docs, 2016+). The accessible introduction and the idea TS3 uses for the third rung.
- [9]Nicolas Dubien: fast-check (fast-check.dev, 2017+). The mainstream JavaScript implementation. It tracks QuickCheck’s combinator API and Hypothesis’s shrinking model.