levelmax World 4 Testing Properly
  1. 1
  2. 2
  3. 3
  4. 4
  5. 5
  6. B
L
Level 4.2 ~14 min Target: PHPStan max

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 teststestUkOrder, testUkOrder2, testUkOrderAgain
One data provider'just under £50', 'exactly £50', 'an empty basket'
Same assertions. Now you can see what's missing.

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

Before
<?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'));
    }
}
After
<?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));
    }
}

Quick check

1. A shipping rule says "free at £50 or more". Which pair of cases catches >= written as >?
2. A provider yields [5000, 'GB', Shipping::fee(5000, 'GB')]. What does that case prove?
3. Why give a provider's cases string keys?

CHALLENGE

Shipping::fee() (read only) has three tests, each a copy of the last. They pass, but they'd also pass if the fee were wrong.

In tests/ShippingTest.php, replace the copies with one test and a data provider that yields named cases. Then add cases until your tests catch every planted bug. Your tests are analysed at max too. Once your tests pass, Test runs them against each bug and lists the ones they miss.

REFERENCE
use PHPUnit\Framework\Attributes\DataProvider;

/** @return iterable<string, array{int<0, max>, non-empty-string, int}> */
public static function orders(): iterable
{
    yield 'a name for the case' => [$subtotal, $country, $fee];
}

/**
 * @param int<0, max> $subtotal
 * @param non-empty-string $country
 */
#[DataProvider('orders')]
public function testShippingFee(int $subtotal, string $country, int $fee): void
Running on PHP 8.3…

Run the tests, run PHPStan, or submit for both. Ctrl/⌘ + Enter submits from the editor.