Test Doubles

A test double stands in for a real dependency; type the dependency as an interface and you can pass a fake or a mock instead.

Syntax$mock = $this->createMock(Dependency::class); $mock->expects($this->once())->method('name')->with($arg)->willReturn($value);

Some dependencies must not run in a test. A mailer would send real email. A payment gateway would charge a card. A card charged a thousand times by a test suite is a problem. A test double is an object that stands in for one of those: same methods, no consequences.

The interface is the seam

A class that creates its own dependency cannot be handed a different one. Accept it as a constructor parameter, typed as an interface, and a test can pass anything implementing that interface. That is the whole mechanism. The tooling below is convenience on top of it.

Fake, stub, mock

A fake is a small working class you write yourself, built for convenience: an in-memory mailer that appends to an array. A stub returns canned values and verifies nothing. A mock is generated by PHPUnit and also records expectations about how it was called:

$mailer = $this->createMock(Mailer::class);
$mailer->expects($this->once())
    ->method('send')
    ->with('[email protected]', 'Confirm your address');

$this->createStub(Mailer::class) gives the same generated object without the expectations.

Choosing

Use a mock when the call itself is the behaviour under test: that an email was sent at all. Use a stub when you only need the dependency to answer. Use a hand-written fake when the test needs to read back what happened, or when the same double is wanted in twenty tests.

A mock's expectations are assertions written at the top and checked when the test ends. Over-specify them and the test fails on every rename, proving only that the code still calls what it used to.

Example

Try it yourself
Loading editor…
Press Run to execute the code.

When to use it

  • Test a signup flow without sending mail to a real address.
  • Pin an exchange rate or a weather response so the assertion does not change with the outside world.
  • Freeze the clock so a token-expiry test gives the same answer at any hour.
  • Assert that a failed payment is logged exactly once, using a mock with expects($this->once()).

More examples

The same check with a PHPUnit mock

No FakeMailer class to maintain: PHPUnit generates an implementation of the interface at runtime. There is no assert call in the body because expects() is the assertion, verified after the test method returns. Alongside $this->once() there are never(), exactly(int $count), atLeastOnce(), atLeast(int) and atMost(int).

Example · php
<?php
declare(strict_types=1);

use PHPUnit\Framework\TestCase;

final class SignupTest extends TestCase
{
    public function test_it_sends_one_confirmation_email(): void
    {
        $mailer = $this->createMock(Mailer::class);
        $mailer->expects($this->once())
            ->method('send')
            ->with('[email protected]', 'Confirm your address');

        (new Signup($mailer))->register('[email protected]');
    }
}

A stub that only needs to answer

The behaviour being tested is the arithmetic, not the lookup, so the rate source is pinned to fixed numbers and nothing about the call is verified. willReturnMap takes rows of arguments-then-return-value; willReturn($value) covers the single-answer case and willThrowException($e) makes the dependency fail on demand.

Example · php
<?php
declare(strict_types=1);

use PHPUnit\Framework\TestCase;

interface ExchangeRates
{
    public function rate(string $from, string $to): float;
}

final class ConverterTest extends TestCase
{
    public function test_it_converts_using_the_given_rate(): void
    {
        $rates = $this->createStub(ExchangeRates::class);
        $rates->method('rate')->willReturnMap([
            ['USD', 'GBP', 0.8],
            ['EUR', 'GBP', 0.85],
        ]);

        $converter = new Converter($rates);

        self::assertSame(800, $converter->toPence(1000, 'USD'));
        self::assertSame(850, $converter->toPence(1000, 'EUR'));
    }
}

A shared fake kept with the tests

Time is the dependency people forget to make swappable, and a test that calls the real clock fails at midnight or in another timezone. A hand-written fake wins here over a generated mock because it has behaviour of its own: advance() lets one test move time forward. Keep such doubles in tests/Doubles so they never ship.

Example · php
<?php
declare(strict_types=1);

namespace App\Tests\Doubles;

use App\Contracts\Clock;
use DateTimeImmutable;

// tests/Doubles/FrozenClock.php
final class FrozenClock implements Clock
{
    public function __construct(private DateTimeImmutable $now)
    {
    }

    public static function at(string $iso8601): self
    {
        return new self(new DateTimeImmutable($iso8601));
    }

    public function now(): DateTimeImmutable
    {
        return $this->now;
    }

    public function advance(string $interval): void
    {
        $this->now = $this->now->modify($interval);
    }
}

// Used in a test:
// $clock = FrozenClock::at('2026-01-01 09:00:00');
// $token = $issuer->issue($clock);
// $clock->advance('+2 hours');
// self::assertTrue($token->hasExpired($clock));

Discussion

  • Be the first to comment on this lesson.