0

Test doubles — stubs, mocks, spies, fakes, dummies

Intermediate5 min read·php-14-004
interview

Concept

Test doubles are objects that stand in for real collaborators during tests. The term comes from Martin Fowler's seminal essay "Mocks Aren't Stubs," and the taxonomy matters because each type has a different purpose, different implementation cost, and different failure mode. Using the wrong type of double is one of the most common mistakes in PHP testing.

A dummy is an object passed to fulfill a type signature but never actually used in the test. It can return null from every method, throw on any call—it doesn't matter. PHPUnit's createStub() with no setup produces a usable dummy.

A stub provides canned answers to calls made during the test. It has no behavior of its own—it just returns what you told it to return. Stubs are for querying: "when findUser(42) is called, return this User object." Stubs make no assertions; they don't care if methods are called or not.

A mock is a stub with expectations. You pre-program the mock with assertions about which methods get called, how many times, and with which arguments. If those expectations aren't met, the test fails. Mocks are for verifying behavior—they answer "did my code call the right collaborator in the right way?"

A spy is like a mock but with assertions deferred to after the act phase. You let the code run, then ask the spy what happened. This is often cleaner because the assertion is in the Assert phase, not the Arrange phase. Mockery (a popular alternative to PHPUnit's built-in mocks) makes spies easy.

A fake is a working simplified implementation of a collaborator—like an in-memory repository that actually stores and retrieves data. Fakes are the most expensive to write but the most resilient tests to maintain because they don't break when implementation details change.

DoubleVerifies calls?Returns data?Has real logic?
DummyNoNoNo
StubNoYes (canned)No
MockYes (pre-programmed)Yes (canned)No
SpyYes (post-act)Yes (canned)No
FakeNoYes (real-ish)Yes

Code Example

php
<?php
declare(strict_types=1);

namespace Tests\Unit;

use App\Repositories\UserRepositoryInterface;
use App\Services\UserNotifier;
use App\Models\User;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;

final class TestDoubleExamplesTest extends TestCase
{
    // STUB: provides canned data, makes no assertions
    #[Test]
    public function stub_provides_canned_return_value(): void
    {
        $stub = $this->createStub(UserRepositoryInterface::class);
        $stub->method('find')
             ->willReturn(new User(id: 1, name: 'Alice', email: 'alice@example.com'));

        $notifier = new UserNotifier($stub);
        $result = $notifier->getGreeting(userId: 1);

        $this->assertSame('Hello, Alice', $result);
        // No assertion on whether find() was called — stubs don't care
    }

    // MOCK: verifies that the right method was called with the right args
    #[Test]
    public function mock_verifies_save_is_called_once_with_correct_user(): void
    {
        $mock = $this->createMock(UserRepositoryInterface::class);
        // Expectation set BEFORE the act phase
        $mock->expects($this->once())
             ->method('save')
             ->with($this->callback(fn(User $u) => $u->email === 'bob@example.com'));

        $service = new UserRegistrationService($mock);
        $service->register(name: 'Bob', email: 'bob@example.com');
        // PHPUnit verifies expectations automatically after the test method
    }

    // DUMMY: satisfies type requirement but is never used
    #[Test]
    public function dummy_satisfies_constructor_signature(): void
    {
        // This test is about pure calculation — repository is never touched
        $dummy = $this->createStub(UserRepositoryInterface::class);
        $calculator = new FeeCalculator($dummy); // requires UserRepositoryInterface
        $this->assertSame(9.99, $calculator->calculateFee(orderTotal: 99.90));
    }

    // FAKE: a real working in-memory implementation
    #[Test]
    public function fake_repository_stores_and_retrieves_users(): void
    {
        $fake = new InMemoryUserRepository(); // real logic, fake storage
        $service = new UserRegistrationService($fake);

        $service->register(name: 'Carol', email: 'carol@example.com');
        $found = $fake->findByEmail('carol@example.com');

        $this->assertNotNull($found);
        $this->assertSame('Carol', $found->name);
    }
}

// The fake implementation
final class InMemoryUserRepository implements UserRepositoryInterface
{
    private array $users = [];

    public function save(User $user): void
    {
        $this->users[$user->email] = $user;
    }

    public function find(int $id): ?User
    {
        foreach ($this->users as $user) {
            if ($user->id === $id) return $user;
        }
        return null;
    }

    public function findByEmail(string $email): ?User
    {
        return $this->users[$email] ?? null;
    }
}

Interview Q&A

Q: What is the difference between a stub and a mock according to Martin Fowler's definitions, and why does the distinction matter in practice?

A stub provides canned return values to satisfy queries made by the code under test—it has no opinion about whether or how often its methods are called. A mock is pre-programmed with expectations about which calls it will receive; if those expectations aren't met, the mock fails the test. The distinction matters because using a mock where a stub is appropriate couples your test to implementation details. If your test fails because a method was called twice instead of once, you need to ask: is that really the behavior I'm specifying, or is it an implementation detail? Over-mocking leads to brittle tests that break on every refactor.


Q: When would you use a fake over a mock, and what is the main cost of fakes?

Use a fake when the collaborator has enough real behavior that setting up canned responses becomes complex and brittle—for example, a repository where multiple tests need to find, save, and query data. A fake in-memory implementation handles all that naturally. The cost of a fake is that you must maintain it alongside the real implementation: when you add a new method to the interface, you must implement it in both the real class and the fake. For small interfaces (2-3 methods), fakes are often the best choice. For large interfaces, mocks or stubs are more practical.


Q: What is the spy pattern and how does it differ from PHPUnit's built-in mock expectations?

A spy records what happened during execution and lets you query that record after the act phase. PHPUnit's built-in mocks use pre-programmed expectations (expects($this->once()) before calling the code), which means assertions are in the Arrange phase. With a spy, you call $spy->getRecordedCalls('save') after the act and assertCount(1, ...) in the Assert phase. This makes tests read more naturally in AAA order. PHPUnit doesn't have first-class spy support, but Mockery does with spy() and shouldHaveReceived(). You can simulate a spy in PHPUnit by using $this->exactly(1) carefully, or by tracking calls in a custom test double.