Data providers
Three tests check the shipping fee: a big UK order, a small UK order, an EU order. Each one is a copy of the last with two numbers changed. They all pass. And none of them notices when someone changes >= to >, so an order of exactly £50 starts paying for shipping.
Copy-pasted tests hide what they don't check. Put the cases in a table and the gaps become obvious.
As in 4.1, the exercise plants bugs in the code under test, and your tests have to catch every one.
One test, many cases
A data provider is a public static method that returns the cases. #[DataProvider] points a test at it, and PHPUnit runs the test once for each case, passing the values as arguments:
use PHPUnit\Framework\Attributes\DataProvider; /** @return iterable<string, array{int<0, max>, non-empty-string, int}> */ public static function orders(): iterable { yield 'a small UK order' => [1200, 'GB', 399]; yield 'exactly £50 to the UK' => [5000, 'GB', 0]; } /** * @param int<0, max> $subtotal * @param non-empty-string $country */ #[DataProvider('orders')] public function testShippingFee(int $subtotal, string $country, int $fee): void { self::assertSame($fee, Shipping::fee($subtotal, $country)); }
Name every case. The key appears in the failure, so you see with data set "exactly £50 to the UK" instead of with data set #3.
Your tests are analysed at max like any other code. The @return type says what a case holds, just as the array shapes in 2.3 did. PHPStan doesn't carry it over to the test's parameters, though. Shipping::fee() asks for an int<0, max> and a non-empty-string, so the test needs @param tags saying it passes them on.
For two or three short cases, #[TestWith([5000, 'GB', 0])] puts the data on the test itself. Once there are more cases than that, or they need names, use a provider.
| Copy-pasted tests | testUkOrder, testUkOrder2, testUkOrderAgain |
|---|---|
| One data provider | 'just under £50', 'exactly £50', 'an empty basket' |
Choosing the cases
The bugs worth catching sit where the code makes a decision. For each one, ask:
| Question | Shipping cases |
|---|---|
| Does every branch run at least once? | A UK order, an EU order, an order going elsewhere |
| Is each boundary tested from both sides? | 4999 and 5000 |
| What about nothing at all? | An empty basket |
>= and > only disagree at the boundary itself. A test at 6000 can't tell them apart, but a test at 5000 can.
Power-up
<?php use PHPUnit\Framework\TestCase; final class ShippingTest extends TestCase { public function testABigUkOrderShipsFree(): void { self::assertSame(0, Shipping::fee(6000, 'GB')); } public function testASmallUkOrderPaysTheUkRate(): void { self::assertSame(399, Shipping::fee(1200, 'GB')); } public function testAnotherSmallUkOrderPaysTheUkRate(): void { self::assertSame(399, Shipping::fee(2500, 'GB')); } }
<?php use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; final class ShippingTest extends TestCase { /** @return iterable<string, array{int<0, max>, non-empty-string, int}> */ public static function ukOrders(): iterable { yield 'an empty basket' => [0, 'GB', 0]; yield 'just under £50' => [4999, 'GB', 399]; yield 'exactly £50' => [5000, 'GB', 0]; } /** * @param int<0, max> $subtotal * @param non-empty-string $country */ #[DataProvider('ukOrders')] public function testShippingFee(int $subtotal, string $country, int $fee): void { self::assertSame($fee, Shipping::fee($subtotal, $country)); } }