One Method, Twenty Cases: Static Data Providers With yield
You tested a tax calculator with one input, then copied the method four times for zero, negative, fractional, and huge rates. The body is identical; only three literals differ. The fifth copy is where the bug enters — you change the rounding rule, update three clones, and miss the fourth. Everything you learned about picking the right assertion in Writing Assertions still applies; the problem now is duplication of the method, not of the check.
A data provider decouples the check from the inputs. One test method, many rows, each row running that method once:
<?php
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
final class TaxCalculatorTest extends TestCase
{
#[DataProvider('provideRates')]
public function testAppliesRateToAmountInCents(int $amount, float $rate, int $expected): void
{
$this->assertSame($expected, Tax::apply($amount, $rate));
}
public static function provideRates(): iterable
{
yield 'zero rate' => [1000, 0.0, 1000];
yield 'standard rate' => [1000, 0.20, 1200];
yield 'reduced rate' => [1000, 0.05, 1050];
yield 'fractional cents' => [ 999, 0.075, 1074];
yield 'single cent' => [ 1, 1.0, 2];
}
}
How the pieces fit:
- The attribute points at the method.
#[DataProvider('provideRates')]replaces the old@dataProvider provideRatescomment. The annotation form still parses in PHPUnit 10 and 11 but is deprecated, and PHPUnit 12 removed metadata annotations entirely — attributes are the only forward-compatible spelling. - The provider is
public static. PHPUnit 10 deprecated non-static providers; they are an error after that. The reason is architectural: providers run while tests are being collected, before any TestCase object exists, so$thishas nothing meaningful to point at. A provider that needs helpers must call them asself::someHelper(), not$this->someHelper(). yieldmakes the rows a generator. Eachyieldemits one dataset; PHPUnit iterates it and invokes the test method withfunc_get_args-style unpacking —[1000, 0.20, 1200]becomestestAppliesRateToAmountInCents(1000, 0.20, 1200). Row length must match the parameter list; a short row produces anArgumentCountErrorattributed to that dataset, and a provider that throws is reported asThe data provider specified for TaxCalculatorTest::testAppliesRateToAmountInCents is invalid.- Generators let you load rows from data. Reading a CSV of edge cases inside the provider keeps the input table editable by non-PHP people:
public static function provideZipCodes(): iterable
{
foreach (file(__DIR__ . '/zipcodes.csv', FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) as $line) {
[$input, $valid] = str_getcsv($line);
yield $input => [$input, $valid === 'valid'];
}
}
A provider may return a plain array as well — the pre-generator style still works because the contract is iterable:
public static function provideRates(): array
{
return [
'zero rate' => [1000, 0.0, 1000],
'standard rate' => [1000, 0.20, 1200],
];
}
yield earns its place anyway: rows are produced one at a time instead of materializing the whole table in memory, and tables can be composed. A shared set of base cases lives in one method; a test-specific edge case is appended with yield from:
yield from self::baseCases(); // shared with sibling tests
yield 'over refund limit' => [100_000, 0.20, 120_000];
Because each dataset is an independent test run with a fresh TestCase instance, a failure in one row never masks another — you get one report entry per input, not one method that dies at the first mismatch. The summary counts them honestly as well: one method with seven rows reports OK (7 tests, 14 assertions), so a single provider quietly accounts for seven of your suite's tests.
The rows are only useful if you can tell them apart in the output, which is a naming problem.
Dataset Names Decide How You Read a Failure
Leave the keys off your provider and the failure output tells you this:
1) TaxCalculatorTest::testAppliesRateToAmountInCents with data set #3
Failed asserting that 1073 matches expected 1074.
data set #3 sends you back to the provider to count yield statements — and the count shifts the moment somebody inserts a row above it. Name the keys and the same failure reads:
1) TaxCalculatorTest::testAppliesRateToAmountInCents with data set "fractional cents"
Failed asserting that 1073 matches expected 1074.
Named datasets are the whole value of parameterized tests — the dataset key is the diagnosis, and it is stable no matter where rows are inserted. The name also flows into --testdox, so the human-readable report shows ✔ Applies rate to amount in cents with data set "reduced rate" and a reviewer can see which behaviors are covered without opening the provider.
Good names describe the input class, not the row number:
| Input type | Datasets worth naming |
|---|---|
| numeric | zero, negative, max int, beyond float precision |
| string | empty, whitespace only, 4 KB payload, emoji — äöü 😀, sql-ish ' OR 1=1 |
| collection | empty list, one item, duplicate keys |
| date/time | leap day, DST spring forward, epoch |
| identifiers | nil UUID, already taken, case variant |
Each name is a bet about what breaks in production. whitespace only and case variant are not academic — they are the inputs that reach your validator in a real queue.
One silent failure deserves its own warning, because it removes tests instead of reporting them:
return [
'zero rate' => [1000, 0.0, 1000],
'standard rate' => [1000, 0.20, 1200],
'zero rate' => [ 999, 0.0, 999], // duplicate key
];
With a returned array the second 'zero rate' overwrites the first: three rows become two, no error, no warning, one edge case quietly evaporated. Generators are immune — each yield emits a row as it comes, so a duplicated label cannot delete its sibling. A duplicated key in an array-backed provider is the rare bug that makes the suite smaller while the file looks bigger.
Debugging a single row is also cheaper than it looks. Run vendor/bin/phpunit --filter testAppliesRateToAmountInCents to execute just that method, and the failure output names the dataset — with data set "fractional cents" — directly above the diff, so you rarely need to isolate one row by hand. Add --testdox and every dataset name prints as a sentence, turning a full provider run into a readable checklist of the input classes you actually cover.
Two habits keep the output honest. First, keep each row to a handful of arguments; a dataset with six positional values and no names is unreadable at failure time — if you need that much context, yield a named associative structure and unpack it inside the test. Second, do not smuggle assertions into the provider. It runs during collection; a failed assertion there surfaces as an invalid-provider error for the entire method, killing all twenty rows instead of reporting the one bad input.
Remember what the numbers mean. Ten datasets report as ten tests, which is visibility, not validation — a suite can grow from 12 tests to 240 tests by data-branching alone while asserting nothing new. Coverage and mutation metrics are the antidote, and they are exactly what you meet in Coverage Reports and Thresholds later in this phase.
Before you reach for a provider on every test, though, know the three cases where it is the wrong tool.
When a Data Provider Is the Wrong Tool
A provider shines when the assertion shape is identical and only the values change. Break that condition and the provider starts fighting you. Three mistakes show up repeatedly.
1. Reaching for $this inside the provider. Providers execute at collection time, statically, before setUp() runs:
public function provideInvoices(): iterable // deprecated in 10, error later
{
yield [$this->invoiceRepo->latest()]; // $this has no state to offer here
}
Nothing has been initialized, so the fixture you wanted is not there — and PHPUnit reports the provider as invalid, taking the whole method down with it. Anything the rows need must be constructed statically (or the object built inside the test method from plain values). The failure line to memorize: The data provider specified for X::y is invalid.
2. Varying the assertion, not the input. If different rows must assert different things — row A expects an InvalidArgumentException, row B expects a returned null, row C expects a side effect — you are encoding branching logic inside a test:
if ($expected === 'throws') {
$this->expectException(...); // conditionals in tests are a design smell
}
Split them into separate methods with descriptive names (testRejectsExpiredCoupon, testReturnsNullForUnknownCustomer). A reader scanning method names learns the behaviors; a reader scanning if statements learns nothing.
3. Hiding setup that belongs elsewhere. Because providers cannot see instance state, a common workaround is to build expensive objects in the provider and cache them in a static property. Now twenty test methods share mutable static state, order starts to matter, and tests fail when run with --filter alone. Expensive shared fixtures belong in setUpBeforeClass(), which is the lifecycle hook designed for exactly that.
There is also a lighter option for two or three inline cases, where a whole provider method is overhead:
#[TestWith([0.0, 1000])]
#[TestWith([0.20, 1200])]
public function testAppliesRate(float $rate, int $expected): void { ... }
And when several test classes share the same rows — every serializer testing the same ten payloads — move the provider out with #[DataProviderExternal(SerializerCases::class, 'providePayloads')]. One table, many consumers, no duplication.
| Situation | Tool |
|---|---|
| Same assertion, many inputs | #[DataProvider] with named yield |
| Two or three constant cases | #[TestWith] |
| Rows shared across classes | #[DataProviderExternal] |
| Different expectations per case | separate test methods |
| Rows loaded from a file/DB | static generator in the provider |
The trade-off is honest: parameterized tests compress code and multiply report entries, but they add a layer of indirection that single-case tests do not have. Use them where the shape really is constant.
One timing fact surprises people: providers run during test collection, even when a filter later discards their tests. --filter decides which tests execute, but the suite is assembled first — the provider method is called, the file is read, every row is built. A provider that parses a 5 MB CSV pays that cost on every vendor/bin/phpunit invocation, including the run where you wanted a single method. Keep providers cheap: emit rows quickly and push expensive fixture work into setUpBeforeClass(), where it happens only for tests that actually run.
The failure mode of the conditional-provider pattern looks less intimidating than it reads. Separate methods remain the honest choice when expectations differ:
public function testRejectsExpiredCoupon(): void
{
$this->expectException(ExpiredCouponException::class);
$this->checkout->applyCoupon('EXPIRED-2024');
}
public function testReturnsNullForUnknownCustomer(): void
{
$this->assertNull($this->checkout->customerFor('nobody@example.com'));
}
Two methods, two sentences under --testdox, zero conditionals — a reader learns both rules without unpacking a provider. Reach for the provider only when the body would otherwise be duplicated.
Providers give you input variety. Mocking Dependencies gives you output variety — deciding what your unit under test is allowed to talk to.
Quiz
1. A provider method references $this to load a fixture from the test object. What happens in PHPUnit 10 and later?
2. A provider yields rows with numeric keys only. What does a failure look like and why does it hurt?
3. Every row of your provider must assert something different: one expects an exception, another a null return, a third a stored record. What is the best restructuring?
4. Three test classes must run the same table of twenty payload fixtures. Which approach avoids duplicating the table?
Flashcards
Question
How does a data provider deliver inputs to a test method?
Click to reveal answer
Answer
The #[DataProvider('name')] attribute names a static method that yields rows; PHPUnit iterates the rows and invokes the test method once per row, unpacking each row's array as the method's arguments. Row length must match the parameter list.
Question
Why must a data provider be static in modern PHPUnit?
Click to reveal answer
Answer
Providers execute during test collection, before TestCase instances exist and before setUp() runs, so there is no valid instance state to read. PHPUnit 10 deprecated non-static providers and later versions reject them, reporting the provider as invalid and failing every dataset of that method.
Question
What do the keys of an associative provider array become?
Click to reveal answer
Answer
They become the dataset name shown in failure output ('with data set "fractional cents"') and in --testdox. Numeric keys degrade to data set #N, which is positional and shifts whenever a row is inserted above.
Question
When is a data provider the wrong tool?
Click to reveal answer
Answer
When the assertion shape differs per case (different exception classes, different side effects), when rows depend on instance state or setUp(), or when two or three constant cases would do — use separate named methods, build fixtures in the test or setUpBeforeClass(), or use #[TestWith] respectively.
Question
How do several test classes share one set of rows?
Click to reveal answer
Answer
Declare the rows once in a static method on any class and reference them from each consumer with #[DataProviderExternal(SharedCases::class, 'provideRows')] — one authoritative table, no copy-paste drift. Inline single cases can use #[TestWith([...])] instead of a provider method.
Revision Notes
Key Takeaways
- 1. A provider decouples the check from the inputs: one method, one yield per dataset, each row invoked as a separate test with a fresh TestCase instance
- 2. Providers must be static because they run at collection time — before setUp() — so reaching for $this invalidates the provider and fails every row of the method
- 3. The yield key becomes the dataset name in failure output and --testdox; named rows turn 'data set #3' into a diagnosis that survives reordering
- 4. Providers pay off only when the assertion shape is constant — different expectations per case belong in separately named test methods
- 5. More datasets means more test counts, not more correctness: quantity of rows is invisible to coverage quality, which is what mutation testing later measures
Interview Tips
- • "What does a data provider actually do?" — a static method yielding rows; PHPUnit runs the test method once per row, unpacking the row as arguments; mention that providers execute during collection, which is why they must be static
- • "Why did my provider fail with 'is invalid'?" — it threw an exception or referenced $this; walk the lifecycle: collection happens before any test instance exists, so the fix is static helpers or constructing fixtures inside the test
- • "How do you keep failing-input tests readable?" — name every dataset after the input class ('whitespace only', 'leap day') so the failure line and --testdox read like a spec instead of positional numbers
- • "When would you NOT use a data provider?" — when expectations differ per row, when rows need instance state, or when 2-3 inline cases are clearer with #[TestWith]; showing you weigh indirection against duplication reads as judgment, not pattern memorization
Cheat Sheet
Parameterized Tests with dataProvider
Canonical shape:
#[DataProvider('provideRates')]
public function testAppliesRate(int $amount, float $rate, int $expected): void
{
$this->assertSame($expected, Tax::apply($amount, $rate));
}
public static function provideRates(): iterable // public + static
{
yield 'zero rate' => [1000, 0.0, 1000];
yield 'standard rate' => [1000, 0.20, 1200];
yield 'fractional' => [ 999, 0.075, 1074];
}
Version rules:
| Form | Status |
|---|---|
#[DataProvider('m')] |
current |
@dataProvider m annotation |
deprecated (10/11), removed in 12 |
| non-static provider | deprecated in 10, error after |
#[TestWith([...])] |
current, inline constant rows |
#[DataProviderExternal(C::class,'m')] |
current, shared across classes |
Reading a failure:
TaxCalculatorTest::testAppliesRate with data set "fractional cents"
Failed asserting that 1073 matches expected 1074.
Named key = diagnosis. data set #3 = go count yields.
Error lines to recognize:
The data provider specified for X::y is invalid→ provider threw or used$this; kills all rowsArgumentCountErroron one dataset → row length ≠ parameter list
Wrong tool? Different expectations per row → separate methods · expensive shared fixture → setUpBeforeClass() · 2–3 constant cases → #[TestWith] · rows shared by 3 classes → #[DataProviderExternal].