Unit Tests

Test one class in isolation with PHPUnit test doubles standing in for its dependencies, and no container involved.

Syntax$double = $this->createMock(SomeInterface::class);

A unit test exercises one class with nothing booted around it. You construct the class yourself, call a method, and assert on what comes back. No container, no kernel, no database. That is what makes it fast enough to run on every save.

Test doubles

Most classes need collaborators. A test double is a stand-in object you hand to the class under test instead of the real collaborator. PHPUnit builds them for you: $this->createStub(T::class) returns an object of that type whose methods return canned values, and $this->createMock(T::class) returns the same thing plus the ability to assert how it was called.

This only works if the dependency is an interface the class accepts through its constructor. A class that reaches into a container or calls a static factory cannot be doubled, which is the practical reason constructor injection on interfaces is the rule.

Expectations, long and short

Two forms configure a double, and they are not equivalent:

$double->expects(self::any())->method('rateFor')->willReturn(0.19);
$double->method('rateFor')->willReturn(0.19);          // same thing, shorter
$double->expects(self::once())->method('rateFor')->willReturn(0.19);

The first two are identical: calling method() straight on the double is shorthand for expects(self::any()). Only the third fails when the method is never called.

Staying honest

A unit test that doubles everything can pass while the real wiring is broken. Keep doubles for the boundaries you do not own, and cover the wiring with an integration test.

Example

Example · php
<?php
// tests/Pricing/InvoiceTotalTest.php

declare(strict_types=1);

namespace App\Tests\Pricing;

use App\Pricing\InvoiceTotal;
use App\Pricing\TaxRateProviderInterface;
use PHPUnit\Framework\TestCase;

final class InvoiceTotalTest extends TestCase
{
    public function testTaxIsAppliedAtTheRateForTheBillingCountry(): void
    {
        // A stand-in for the real provider, which would hit an HTTP API.
        $rates = $this->createMock(TaxRateProviderInterface::class);
        $rates->expects(self::once())
            ->method('rateFor')
            ->with('DE')
            ->willReturn(0.19);

        $total = new InvoiceTotal($rates);

        self::assertSame(11900, $total->inCents(10000, 'DE'));
    }

    public function testAZeroRateLeavesTheNetAmountUntouched(): void
    {
        $rates = $this->createStub(TaxRateProviderInterface::class);
        // No expectation needed here, so a stub is enough.
        $rates->method('rateFor')->willReturn(0.0);

        $total = new InvoiceTotal($rates);

        self::assertSame(10000, $total->inCents(10000, 'GB'));
    }
}

// Output:
// OK (2 tests, 2 assertions)

When to use it

  • Cover a pricing or scoring rule where every branch needs its own assertion.
  • Prove a service propagates an API client's failure instead of returning a wrong default.
  • Test a value object's invariants without starting the framework.
  • Keep a pre-commit check under a second by testing logic with no I/O.

More examples

Why the class is testable at all

Had this class called a static TaxRates::lookup() or pulled the provider out of a container, the test above could not exist. Reach for this shape whenever you want a class under test: one interface per collaborator, injected, typed on the interface and not the implementation.

Example · php
<?php
// src/Pricing/InvoiceTotal.php

declare(strict_types=1);

namespace App\Pricing;

final class InvoiceTotal
{
    // The dependency is an interface, taken through the constructor.
    // That is the only reason a test can substitute it.
    public function __construct(
        private readonly TaxRateProviderInterface $rates,
    ) {
    }

    public function inCents(int $netInCents, string $countryCode): int
    {
        $rate = $this->rates->rateFor($countryCode);

        // intdiv-free rounding: round half up on the cent.
        return $netInCents + (int) round($netInCents * $rate);
    }
}

// Output:
// inCents(10000, 'DE') with a 0.19 rate === 11900

One test method, many cases

A data provider is a static method returning rows; PHPUnit runs the test once per row and names each run after the row key, so a failure tells you which case broke. PHPUnit 10 and later take it from the #[DataProvider] attribute, not a @dataProvider docblock. Reach for this when the logic is one branch tested at several boundaries.

Example · php
<?php
// tests/Pricing/RoundingTest.php

declare(strict_types=1);

namespace App\Tests\Pricing;

use App\Pricing\InvoiceTotal;
use App\Pricing\TaxRateProviderInterface;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;

final class RoundingTest extends TestCase
{
    #[DataProvider('netAmounts')]
    public function testHalfCentsRoundUp(int $net, float $rate, int $expected): void
    {
        $rates = $this->createStub(TaxRateProviderInterface::class);
        $rates->method('rateFor')->willReturn($rate);

        self::assertSame($expected, (new InvoiceTotal($rates))->inCents($net, 'DE'));
    }

    public static function netAmounts(): iterable
    {
        yield 'exact cent'     => [10000, 0.19, 11900];
        yield 'rounds up'      => [999,   0.19, 1189];
        yield 'no tax'         => [999,   0.0,  999];
    }
}

// Output:
// OK (3 tests, 3 assertions)

Asserting on a thrown exception

expectException() must be called before the code that throws, and any assertion placed after the throwing line never runs. willThrowException() makes a double misbehave on demand, which is how you test the unhappy path of a collaborator you do not control, such as a timing-out HTTP client.

Example · php
<?php
// tests/Pricing/UnknownCountryTest.php

declare(strict_types=1);

namespace App\Tests\Pricing;

use App\Pricing\InvoiceTotal;
use App\Pricing\TaxRateProviderInterface;
use App\Pricing\UnknownCountry;
use PHPUnit\Framework\TestCase;

final class UnknownCountryTest extends TestCase
{
    public function testTheErrorFromTheProviderIsNotSwallowed(): void
    {
        $rates = $this->createMock(TaxRateProviderInterface::class);
        $rates->method('rateFor')
            ->willThrowException(new UnknownCountry('ZZ'));

        $total = new InvoiceTotal($rates);

        $this->expectException(UnknownCountry::class);
        $this->expectExceptionMessage('ZZ');

        $total->inCents(10000, 'ZZ');
    }
}

Discussion

  • Be the first to comment on this lesson.