Test double — any object standing in for a real dependency in a test
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):
- Dummy: Passed but never used. Just fills a parameter that must be present.
- Stub: Returns predefined values. No behavior verification.
- Fake: A real but simplified implementation.
- Spy: Records calls for later assertion.
- 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
// 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'));