0

Test double — any object standing in for a real dependency in a test

Beginner5 min read·eng-17-004
interview

Concept

Test double — any object that stands in for a real dependency during testing. The term "test double" is the umbrella category — mocks, stubs, spies, and fakes are all specific types of test doubles.

Etymology: "Double" as in "stunt double" — a substitute that plays a role in place of the real thing.

Why test doubles exist: If OrderService depends on a real PaymentGateway that calls Stripe's API, you can't run tests without a network connection, real Stripe credentials, and risk of actual charges. A test double replaces the real PaymentGateway with something controlled.

The five types (Gerard Meszaros, xUnit Test Patterns):

  1. Dummy: Passed but never used. Just fills a parameter that must be present.
  2. Stub: Returns predefined values. No behavior verification.
  3. Fake: A real but simplified implementation.
  4. Spy: Records calls for later assertion.
  5. Mock: Has expectations set in advance — verifies that specific calls were made.

In PHP/PHPUnit: $this->createMock(ClassName::class) creates a mock. PHPUnit's mock objects can serve as mocks (with expectations) or stubs (with willReturn() but no expectation).

In Laravel: SomeClass::fake() — replaces the real implementation with a test-friendly fake. Mail::fake(), Event::fake(), Storage::fake(), Http::fake().

Code Example

php
<?php
// THE FIVE TYPES illustrated:

// 1. DUMMY — passed but never used
class OrderServiceTest extends \PHPUnit\Framework\TestCase
{
    public function test_order_total(): void
    {
        $dummyLogger = $this->createMock(LoggerInterface::class); // never called, just fills the param
        $realRepo    = new InMemoryOrderRepository();

        $service = new OrderService($realRepo, $dummyLogger);
        // Test logic that never touches the logger
    }
}

// 2. STUB — returns hardcoded values, no expectation on calls
$stubGateway = $this->createMock(PaymentGateway::class);
$stubGateway->method('charge')
            ->willReturn(new PaymentResult(success: true, transactionId: 'tx_123'));
// stub returns the same value regardless of what args are passed
// We don't care if it's called once, twice, or not at all

// 3. FAKE — real but simplified implementation
class FakePaymentGateway implements PaymentGateway
{
    private array $charges = [];

    public function charge(int $amount, string $currency, string $token): PaymentResult
    {
        // Real logic but no actual Stripe call
        if ($token === 'fail') return new PaymentResult(success: false, transactionId: null);
        $id = 'fake_' . uniqid();
        $this->charges[] = compact('amount', 'currency', 'id');
        return new PaymentResult(success: true, transactionId: $id);
    }

    public function getCharges(): array { return $this->charges; }
}

// 4. SPY — records calls, assert after the fact
$spyLogger = $this->createMock(LoggerInterface::class);
// Run the code
$service->createOrder([...]);
// Then assert what was logged
$spyLogger->expects($this->atLeastOnce())->method('log'); // verify it was called

// 5. MOCK — expectations set BEFORE the call
$mockMailer = $this->createMock(MailerInterface::class);
$mockMailer->expects($this->once())       // EXACTLY once
           ->method('send')
           ->with($this->isInstanceOf(OrderConfirmation::class)); // specific argument expected

$service->createOrder([...]); // runs — if send() not called exactly once → test fails

// LARAVEL FAKES — built-in test doubles
\Mail::fake();
\Event::fake();
\Queue::fake();
\Storage::fake('s3');
\Http::fake(['https://api.stripe.com/*' => \Http::response(['id' => 'ch_123'], 200)]);

// After running code that should have triggered those:
\Mail::assertSent(OrderConfirmation::class);
\Event::assertDispatched(OrderPlaced::class);
\Queue::assertPushed(ProcessPaymentJob::class);
\Http::assertSent(fn($request) => str_contains($request->url(), 'stripe.com'));