Dummy, Stub, Fake, Spy and Mock explained, framework-agnostic
Mocking everything a class touches, indiscriminately, produces unit tests that break on every refactor even though the application's behavior never changed. Gerard Meszaros' taxonomy distinguishes dummy, stub, fake, spy and mock as five clearly separated roles, each with a different purpose, a different verification logic and a different degree of coupling to implementation details. This article walks through the theory behind every test double using hand-written PHP implementations, independent of any particular testing framework.
Table of Contents
- 1. Why choosing the right test double determines test quality
- 2. The taxonomy: dummy, stub, fake, spy, mock in detail
- 3. Stubs: canned answers for state verification
- 4. Mocks: behavior verification instead of state checking
- 5. Fakes: working implementations built for tests
- 6. Spies: recording interactions without prior expectations
- 7. Overmocking: when mocks block refactoring instead of enabling it
- 8. London School vs. Chicago/Detroit School of TDD
- 9. Test doubles compared side by side
- 10. Summary
- 11. FAQ
1. Why choosing the right test double determines test quality
A test double replaces a real dependency in a unit test with a controlled substitute, so the test can focus exclusively on the unit under test. But the choice of which kind of double to use, and where, determines whether a test actually builds confidence in the correctness of the code or is just an elaborate mockup of the implementation. If, for example, a simple value provider gets wrapped in a full-blown mock with strict expectation checking when a plain stub would have been entirely sufficient, the test becomes needlessly coupled to internal call sequences that have nothing to do with the behavior it is actually meant to verify.
That exact misjudgment is why teams so often end up staring at dozens of failing tests after a harmless refactor, even though nothing about the application's external behavior has changed. A test that breaks on every internal restructuring loses its value as a safety net and instead becomes a drag, tempting developers to simply adjust the test rather than take the failure seriously. Choosing the right mocking strategy, one that picks the matching test double from the dummy, stub, fake, spy and mock taxonomy for each dependency, is therefore not an academic nicety but a basic prerequisite for tests that actually support refactoring instead of blocking it.
2. The taxonomy: dummy, stub, fake, spy, mock in detail
Software engineer Gerard Meszaros established a taxonomy of five test doubles in his standard reference xUnit Test Patterns, and it remains the most precise foundation for discussing mocking strategies today. A dummy is an object passed in purely to satisfy a method signature, never actually used during the test, such as a logger object a constructor requires even though its calls are irrelevant to the specific test. A stub returns predetermined, fixed answers to method calls and puts the test into a particular state, without executing any of the real dependency's actual logic.
A fake is a working, simplified implementation of the same interface as the real dependency, such as an in-memory repository standing in for a database connection. A spy records how it was called so the test can inspect those calls afterward, without any expectations having to be defined in advance. A mock, finally, already knows its expectations before execution and fails the test the moment an expected interaction does not occur as specified. The central distinction across the five categories is whether a test double checks state after execution, as stub and fake do, or verifies behavior during execution, as spy and mock do, while the dummy is the only category that performs no verification role at all.
3. Stubs: canned answers for state verification
A stub is the simplest form of an active test double and answers method calls with fixed, predetermined values that were hard-coded in advance. The sole purpose of a stub is to put the unit under test into a specific, reproducible starting state so the actual test case can run under controlled conditions. A stub executes no logic of its own and makes no claim about how often or with what arguments it was called, it simply returns the same prepared value on every call.
This restriction to pure state verification is, at the same time, a stub's greatest strength: the test stays robust against internal refactors of the unit under test, as long as its externally visible behavior does not change. A typical example is a PriceProviderInterface whose real implementation loads prices from an external API or database. In a unit test for a discount calculation, it does not matter where the price comes from, only that a fixed, known price is available against which the calculation can be verified. A hand-written StubPriceProvider that always returns the same value fully replaces any more elaborate mocking construction here.
<?php
declare(strict_types=1);
interface PriceProviderInterface
{
public function getPrice(string $sku): float;
}
// Stub: returns a fixed, canned value, no real lookup logic involved
final class StubPriceProvider implements PriceProviderInterface
{
public function __construct(private readonly float $fixedPrice = 19.99)
{
}
public function getPrice(string $sku): float
{
return $this->fixedPrice;
}
}
final class DiscountCalculator
{
public function __construct(private readonly PriceProviderInterface $priceProvider)
{
}
public function calculateDiscountedPrice(string $sku, float $discountPercent): float
{
$price = $this->priceProvider->getPrice($sku);
return round($price * (1 - $discountPercent / 100), 2);
}
}
// Test-like usage: the stub puts the calculator into a known, fixed state
$stub = new StubPriceProvider(100.00);
$calculator = new DiscountCalculator($stub);
$result = $calculator->calculateDiscountedPrice('SKU-1', 10.0);
// $result === 90.0, verified against a known state, not against provider calls
4. Mocks: behavior verification instead of state checking
A mock differs fundamentally from a stub because it does not merely return answers, it becomes the test's own verification instance. A mock already knows, before execution, which method calls are expected, with which arguments and how many times, and the test fails the moment that expectation is not met exactly. This kind of check is called behavior verification, as opposed to the state verification of a stub or fake: instead of examining a return value or a changed state after execution, a mock checks whether a specific interaction happened at all.
A classic example is checking whether a notification was actually sent, when the underlying business logic produces no directly observable return value. A hand-written mock for a NotifierInterface records the call to notify() along with the arguments passed, and an explicit verify() method confirms that the call happened exactly once with precisely the expected values. If the call never happens, or the arguments differ, verification fails. This kind of test is valuable when the observable behavior of the unit truly consists of a side effect, such as sending a notification, and no other way exists to check that effect through a return value. In PHPUnit, createMock() handles this more conveniently, but follows the same underlying principle internally.
<?php
declare(strict_types=1);
interface NotifierInterface
{
public function notify(string $recipient, string $message): void;
}
// Hand-written mock: expectations are declared before execution
final class MockNotifier implements NotifierInterface
{
private int $callCount = 0;
/** @var array{recipient: string, message: string}|null */
private ?array $lastCall = null;
public function notify(string $recipient, string $message): void
{
$this->callCount++;
$this->lastCall = ['recipient' => $recipient, 'message' => $message];
}
/**
* @throws RuntimeException When the expected interaction did not occur.
*/
public function verifyCalledOnceWith(string $expectedRecipient, string $expectedMessage): void
{
if ($this->callCount !== 1) {
throw new RuntimeException(sprintf('Expected exactly 1 call, got %d', $this->callCount));
}
if ($this->lastCall['recipient'] !== $expectedRecipient || $this->lastCall['message'] !== $expectedMessage) {
throw new RuntimeException('notify() was called with unexpected arguments');
}
}
}
final class OrderShippedHandler
{
public function __construct(private readonly NotifierInterface $notifier)
{
}
public function handle(string $customerEmail, string $orderNumber): void
{
$this->notifier->notify($customerEmail, "Order {$orderNumber} has shipped");
}
}
// Test-like usage: expectation is verified only after the call happened
$mock = new MockNotifier();
$handler = new OrderShippedHandler($mock);
$handler->handle('customer@example.com', 'ORD-42');
$mock->verifyCalledOnceWith('customer@example.com', 'Order ORD-42 has shipped');
5. Fakes: working implementations built for tests
A fake differs from a stub and a mock in that it actually contains working logic, rather than just returning predetermined values or recording calls. A fake fully implements the same interface as the real dependency, but with a simplified internal implementation, unsuitable for production but entirely sufficient for testing purposes. The most common example is an in-memory repository that holds records in a plain array instead of a database, while still implementing real search, filter and storage logic.
An InMemoryOrderRepository that fully implements OrderRepositoryInterface behaves identically, from the caller's perspective, to a real, database-backed implementation: a saved record can subsequently be retrieved through the same interface, filter conditions are actually evaluated, and state changes persist across multiple calls. This property makes fakes especially valuable for integration tests involving several collaborating components, where stubs with their rigid return values and mocks with their strict expectations quickly reach their limits, because realistic, stateful behavior across multiple method calls is required.
<?php
declare(strict_types=1);
interface OrderRepositoryInterface
{
public function save(Order $order): void;
public function findById(int $id): ?Order;
/**
* @return Order[]
*/
public function findByCustomerId(int $customerId): array;
}
final class Order
{
public function __construct(
public readonly int $id,
public readonly int $customerId,
public readonly float $total,
) {
}
}
// Fake: a real, working implementation, simplified for test purposes
final class InMemoryOrderRepository implements OrderRepositoryInterface
{
/** @var array<int, Order> */
private array $orders = [];
public function save(Order $order): void
{
$this->orders[$order->id] = $order;
}
public function findById(int $id): ?Order
{
return $this->orders[$id] ?? null;
}
public function findByCustomerId(int $customerId): array
{
return array_values(
array_filter(
$this->orders,
static fn (Order $order): bool => $order->customerId === $customerId
)
);
}
}
// Test-like usage: real save/find logic runs, no database required
$repository = new InMemoryOrderRepository();
$repository->save(new Order(1, 42, 99.90));
$repository->save(new Order(2, 42, 15.00));
$customerOrders = $repository->findByCustomerId(42);
// count($customerOrders) === 2, filtering actually happened in the fake
6. Spies: recording interactions without prior expectations
A spy records every interaction that happens during test execution, without any expectation having to be formulated in advance. The test only decides, after the unit under test has run, which of the recorded calls are actually relevant, and forms its assertions against that recorded data. A SpyLogger, for instance, stores every log level and message called into an internal array, without the test having to specify in advance how often logging happens or with which exact text.
The decisive difference from a mock lies in the timing of the check: a mock defines its expectations before execution and fails the test during, or immediately after, a deviating call, whereas a spy first passively collects all information and leaves the actual verification entirely to the test code after execution completes. Deferring assertions this way makes spies more flexible, since a single test run can check several different aspects of the same recording, such as both the number of calls and their order, without requiring multiple differently configured mock objects.
<?php
declare(strict_types=1);
interface LoggerInterface
{
public function log(string $level, string $message): void;
}
// Spy: records every call, no expectations are set beforehand
final class SpyLogger implements LoggerInterface
{
/** @var array<int, array{level: string, message: string}> */
private array $recordedCalls = [];
public function log(string $level, string $message): void
{
$this->recordedCalls[] = ['level' => $level, 'message' => $message];
}
/**
* @return array<int, array{level: string, message: string}>
*/
public function getRecordedCalls(): array
{
return $this->recordedCalls;
}
public function hasLoggedLevel(string $level): bool
{
foreach ($this->recordedCalls as $call) {
if ($call['level'] === $level) {
return true;
}
}
return false;
}
}
final class ImportJob
{
public function __construct(private readonly LoggerInterface $logger)
{
}
/**
* @param array<int, array<string, mixed>> $rows
*/
public function run(array $rows): void
{
$this->logger->log('info', 'Import started');
foreach ($rows as $row) {
if ($row === []) {
$this->logger->log('warning', 'Skipped empty row');
continue;
}
}
$this->logger->log('info', 'Import finished');
}
}
// Test-like usage: assertions are formed after execution, against recorded data
$spy = new SpyLogger();
$job = new ImportJob($spy);
$job->run([['sku' => 'A1'], [], ['sku' => 'B2']]);
$hasWarning = $spy->hasLoggedLevel('warning'); // true
$totalCalls = count($spy->getRecordedCalls()); // 3
// No expectations were set in advance, only recorded and checked afterward
7. Overmocking: when mocks block refactoring instead of enabling it
Overmocking describes the practice of replacing every single dependency of a class with a mock, and verifying not just its externally visible behavior but internal implementation details as well, such as the exact call order of several helper methods. A test that is over-specified this way no longer checks whether the class correctly performs its job, but whether it happens to be implemented internally exactly as it was at the time the test was written. Any later, behavior-neutral restructuring of the internal flow will fail such a test, even though nothing broke from the caller's point of view.
Particularly prone to overmocking are so-called mock chains, where one mock returns another mock, which in turn returns a third mock, in order to recreate deeply nested object graphs. Such constructions, sometimes disparagingly called god mocks, couple the test extremely tightly to the concrete class structure and violate the Law of Demeter twice over, once in the production code and once in its test-double replica. The countermeasure is to consistently use fakes instead of mock chains at system boundaries, and to reserve mocks strictly for actually observable side effects at genuine collaboration boundaries, rather than constructing a separate mock object for every internal method.
<?php
declare(strict_types=1);
// Simplified pseudocode illustrating the antipattern, not a runnable framework API
// ANTIPATTERN: five collaborators mocked, internal call order verified
final class OvermockedCheckoutTest
{
public function testCheckoutOvermocked(): void
{
$cartMock = $this->createMockCart();
$taxMock = $this->createMockTaxCalculator();
$shippingMock = $this->createMockShippingCalculator();
$inventoryMock = $this->createMockInventoryService();
$paymentMock = $this->createMockPaymentGateway();
// Expectations couple the test to the exact internal call sequence
$cartMock->expectsCall('getItems')->once();
$taxMock->expectsCall('calculate')->once()->after($cartMock);
$shippingMock->expectsCall('calculate')->once()->after($taxMock);
$inventoryMock->expectsCall('reserve')->once()->after($shippingMock);
$paymentMock->expectsCall('charge')->once()->after($inventoryMock);
$checkout = new CheckoutService($cartMock, $taxMock, $shippingMock, $inventoryMock, $paymentMock);
$checkout->process();
// Passes only if internals call collaborators in exactly this order.
// Any refactor that reorders or merges steps breaks the test,
// even if the checkout still produces the correct total.
}
}
// FIXED: a single fake at the system boundary, verified through the outcome
final class CheckoutServiceTest
{
public function testCheckoutChargesCorrectTotal(): void
{
$paymentGateway = new InMemoryPaymentGateway();
$checkout = new CheckoutService(
new Cart([new CartItem('SKU-1', 2, 25.00)]),
new TaxCalculator(0.19),
new FlatRateShipping(4.90),
new InMemoryInventoryService(),
$paymentGateway
);
$checkout->process();
// Verified through the outcome at the real boundary, not internal call order
$charged = $paymentGateway->getLastChargedAmount(); // 63.40
// No expectation about which collaborator is called first or how often
}
}
8. London School vs. Chicago/Detroit School of TDD
The London School of test-driven development, also known as the mockist style, develops functionality consistently from the outside in, replacing every collaboration of a class with a mock even before the actual implementation of that collaboration exists. This approach lets the design of a class and its interfaces be driven while the tests are being written, since every required interaction has to be explicitly formulated as an expectation on a mock before any code for it exists at all. The advantage lies in very precise isolation of each individual unit and a design that is oriented, from the start, toward clearly defined, interchangeable interfaces.
The Chicago School, also called the classical or state-based approach, instead prefers testing with real objects and uses test doubles only at genuine system boundaries, such as against databases or external services. What gets checked is the resulting state after execution, not the internal interaction between collaborators. For domain logic with many collaborating value objects, the Chicago approach yields more robust, less implementation-coupled tests, while the London School pays off especially when developing new interfaces between components that do not yet exist. Most production codebases benefit from a deliberate mix rather than dogmatically committing to either school.
9. Test doubles compared side by side
The following table lines up all five categories of the taxonomy and shows, for each test double, its purpose, the kind of verification it performs, and a typical use case. It works as a quick decision aid when it is unclear, while writing a new test, which kind of double is the right choice for a given dependency.
| Test Double | Purpose | Verifies | Typical Use Case |
|---|---|---|---|
| Dummy | Satisfy a signature, never actually used | Nothing | Logger parameter never invoked in the test |
| Stub | Return fixed, canned answers | State after execution | StubPriceProvider with a fixed price |
| Fake | Simplified, working implementation | State across multiple calls | InMemoryOrderRepository |
| Spy | Record interactions | Behavior, checked after the fact | SpyLogger records every log call |
| Mock | Define expectations up front | Behavior, expected before execution | MockNotifier verifies exactly one call |
It stands out that only spy and mock actually verify behavior, while dummy, stub and fake limit themselves to providing state. Keeping this distinction in mind means choosing, for each dependency, the least restrictive double that still makes the test meaningful, rather than reflexively reaching for the most powerful mocking strategy available.
10. Summary
The most important insight for a sound mocking strategy is that not every test double plays the same role: a dummy only satisfies a signature, a stub returns fixed answers for state verification, a fake brings simplified but genuinely working logic, a spy records interactions without any prior expectation, and a mock verifies behavior against expectations defined in advance. Applying Gerard Meszaros' taxonomy consistently avoids the most common cause of brittle tests: using a more powerful, more behavior-oriented double exactly where a simple state check would have been sufficient.
Overmocking, that is, replacing every single dependency with a mock and verifying internal call orders, is the most reliable way to build tests that break on every refactor even though the application's behavior never changed. Fakes at genuine system boundaries, stubs for simple value providers, and mocks reserved strictly for actually observable side effects add up to a test suite that builds confidence instead of triggering fear of the next refactor. Whether the work follows the London School or the Chicago School is secondary to the basic rule of deliberately choosing the matching test double for each dependency.
Test Doubles and Mocking Strategies, The Key Takeaways
Meszaros' Taxonomy
Dummy, stub, fake, spy, mock: five clearly separated roles, each with a different purpose and verification logic.
State vs. Behavior
Stub and fake verify state after execution, spy and mock verify behavior during or after the interaction.
Fakes at System Boundaries
Use in-memory implementations like InMemoryOrderRepository instead of mock chains for stateful collaborations.
Avoiding Overmocking
Reserve mocks strictly for genuine, observable side effects, not for every internal method of a class.
11. FAQ: Test Doubles and Mocking Strategies
1What is a test double?
2Difference between stub and mock?
3When to use a fake instead of a mock?
4Difference between spy and mock?
5What is a dummy?
6What does overmocking mean?
7London School vs. Chicago School?
8Is a mocking framework required?
9How do I spot too many mocks?
10Which double for external APIs?
Mironsoft
Test suite reviews, test architecture, and reducing brittle mocks
Does your test suite slow down every refactor instead of enabling it?
We audit existing PHP test suites for overmocking, mock chains and poorly chosen test doubles, build a workable mocking strategy across stub, fake, spy and mock, and realign tests to verify behavior instead of implementation details.
Test Suite Review
Systematic assessment of existing tests for overmocking, mock chains and mismatched test double categories
Test Architecture
A clear mocking strategy with fakes at system boundaries instead of fragile, deeply nested mock chains
Refactoring Safety
Tests that verify behavior rather than implementation details and stay stable through internal restructuring