a clean conceptual split
Anyone who blindly treats every collaborator as a Mock loses the semantic expressiveness of their tests. Stub, Fake, Spy, Mock and Dummy have clearly defined roles, and whoever confuses these roles ends up writing tests that hide bugs instead of surfacing them.
Table of Contents
- 1. Why distinguishing Test Doubles matters
- 2. Dummy: the inconsequential placeholder object
- 3. Stub: controlled return values without verification
- 4. Fake: a functional implementation as a substitute
- 5. Spy: recording instead of verifying
- 6. Mock: behavior verification with expectations
- 7. PHPUnit's Test Double API at a glance
- 8. Common mistakes when using Test Doubles
- 9. Comparison: which Test Double when?
- 10. Summary
- 11. FAQ
1. Why distinguishing Test Doubles matters
The vocabulary around Test Doubles originates from Gerard Meszaros' book "xUnit Test Patterns." It clearly distinguishes between five different kinds: Dummy, Stub, Fake, Spy and Mock. In PHP practice, however, a problematic linguistic blur has taken hold: developers call every collaborator replacement simply a "Mock," regardless of what semantics it actually fulfills. This causes tests to unintentionally verify more than intended, or conversely less than necessary, because Stubs are mistaken for Mocks and no expectations are ever formulated.
This conceptual separation has practical consequences. A test that uses a genuine Mock with expects($this->once()) fails if the method under test never calls the collaborator at all. A Stub without an expectation, on the other hand, never fails due to missing calls. Anyone who mixes up both terms either writes tests that are too fragile and break on every refactoring, or tests that are too permissive and let real bugs slip through. Understanding these differences is the first step toward a test suite that actually provides confidence.
PHPUnit offers an API with createMock(), createStub(), getMockBuilder() and, since PHPUnit 10, createMockForIntersectionOfInterfaces() that partially supports these distinctions in its naming. Still, it remains the developer's responsibility to pick the right tool for each situation. This article explains every Test Double type with concrete examples from real-world PHP practice.
2. Dummy: the inconsequential placeholder object
A Dummy is an object that exists only to fill a parameter list. It is never called; its methods are never executed. That sounds trivial, but Dummies have an important place in the test suite: they make it explicit that a certain collaborator is irrelevant for the path under test. If a service's constructor requires three dependencies but the path being tested only uses one of them, the other two are Dummies.
In PHPUnit, the simplest way to create a Dummy is $this->createMock(Interface::class), left unconfigured, with no return values and no expectations. The difference from an explicit Stub: a Dummy object that gets called unexpectedly returns null without throwing an error. That is intentional when the call simply does not happen along the test path. Anyone who names Dummies clearly, for example as $unusedLogger or $dummyEventDispatcher, communicates directly in the test which dependencies are irrelevant for this case.
3. Stub: controlled return values without verification
A Stub delivers pre-configured answers to method calls. The decisive difference from a Mock: a Stub makes no statement about whether or how often its methods are called. It only returns the configured value when it happens to be invoked. PHPUnit Stubs are created with createStub() (available since PHPUnit 9) or with createMock() plus method()->willReturn(). createStub() is the semantically cleaner choice, since it signals clearly in the test code that no interaction verification is taking place.
Stubs are ideally suited for query methods, meaning methods that return data without triggering side effects. A repository that returns a fixed product list in a test is a classic Stub. A price calculator service that always returns the same price is a Stub. Important: Stubs should not formulate expectations. As soon as you add expects($this->once()) or expects($this->exactly(2)), the Stub turns into a Mock, which fundamentally changes what the test is actually asserting.
<?php
// Stub: provides controlled return values, no interaction verification
use PHPUnit\Framework\TestCase;
class PriceCalculatorTest extends TestCase
{
public function testCalculatesTotalWithTaxFromStub(): void
{
// Stub: we only care about the return value, not HOW OFTEN it's called
$taxProvider = $this->createStub(TaxProviderInterface::class);
$taxProvider->method('getTaxRate')
->willReturn(0.19);
$calculator = new PriceCalculator($taxProvider);
$total = $calculator->calculateGross(100.00);
// Assert state, not interaction
$this->assertSame(119.00, $total);
}
public function testReturnsZeroForEmptyCart(): void
{
// Dummy: taxProvider won't be called at all for empty cart
$unusedTaxProvider = $this->createStub(TaxProviderInterface::class);
$calculator = new PriceCalculator($unusedTaxProvider);
$total = $calculator->calculateGross(0.00);
$this->assertSame(0.00, $total);
}
}
4. Fake: a functional implementation as a substitute
A Fake is a fully working, but simplified, implementation of an interface. Unlike Stubs and Mocks, a Fake is not created through the PHPUnit mock API but written as an actual PHP class. The classic Fake is an in-memory repository: it fully implements the repository interface with save(), findById() and findAll(), but stores the data in an array instead of a database. This allows complex scenarios to be tested without laboriously configuring Mocks.
Fakes are particularly valuable for integration tests and for scenarios where multiple methods of the same collaborator are called and state transitions need to be correctly represented. A Mock repository that always returns a fixed value on every findById() call cannot test whether a previously saved object is retrieved correctly. A Fake repository can, because it genuinely stores and retrieves. The downside: Fakes need to be maintained when the interface changes. For long-lived tests in larger projects, however, this effort pays off substantially.
5. Spy: recording instead of verifying
A Spy is a Test Double that records calls without expecting them ahead of time. Unlike a Mock, with a Spy you do not formulate expectations before the test run; instead, you inspect the recorded calls afterward. PHPUnit does not support the Spy pattern natively as its own API, but it can be elegantly implemented with a getMockBuilder() object that collects calls via a public variable. Alternatively, libraries such as Mockery support Spies explicitly.
In practice, the Spy is especially useful when you want to test which arguments were passed on a particular call without pinning down the exact moment of the call. An event dispatcher used as a Spy records all dispatched events; after the test, you can then check whether the right event was sent with the right data. This is less restrictive than a Mock with expects($this->once())->with($this->isInstanceOf(OrderCreatedEvent::class)), because the Spy still allows meaningful assertions even when the order or count of calls is beside the point.
6. Mock: behavior verification with expectations
A Mock is the most restrictive Test Double: it formulates explicit expectations before the test run about which methods will be called, how often, and with which arguments. PHPUnit verifies these expectations automatically at the end of the test. If an expected method is not called, the test fails, even if every assertion inside the test method itself is satisfied. This makes Mocks the right tool for command methods: methods that trigger side effects, where the correct call itself is the aspect being tested.
A classic Mock scenario: an OrderService should send exactly one email when an order is completed. Here, the correct call to the mailer is what is being tested, not a return value. The Mock ensures that send() is called exactly once. If the developer accidentally removes the mailer call, the test fails. A Stub would not catch that, because it knows no expectations. Mocks are thus the most direct tool for verifying command-query separation at the unit test level.
<?php
// Mock: verifies that send() is called exactly once with the right argument
use PHPUnit\Framework\TestCase;
class OrderServiceTest extends TestCase
{
public function testSendsConfirmationEmailOnOrderCompletion(): void
{
// Mock: we EXPECT this method to be called, test fails if it isn't
$mailer = $this->createMock(MailerInterface::class);
$mailer->expects($this->once())
->method('send')
->with($this->callback(function (Email $email): bool {
return $email->getTo() === 'customer@example.com'
&& str_contains($email->getSubject(), 'Order confirmation');
}));
// Stub: we only need the order data, no interaction verification
$orderRepo = $this->createStub(OrderRepositoryInterface::class);
$orderRepo->method('findById')
->willReturn(new Order(id: 42, customerEmail: 'customer@example.com'));
$service = new OrderService($mailer, $orderRepo);
$service->complete(orderId: 42);
// PHPUnit automatically verifies mock expectations after the test
}
}
7. PHPUnit's Test Double API at a glance
PHPUnit 10 and 11 have significantly cleaned up the Test Double API. The deprecated getMock() method is gone; createMock() and createStub() are the preferred entry points. createMock() creates an object whose methods all return null by default and on which expectations can be formulated with expects(). createStub() creates the very same object, but signals semantically that no interaction verification is taking place; the difference lies in the expression, not in the behavior.
For more complex scenarios, getMockBuilder() offers full control: you can disable the constructor (disableOriginalConstructor()), mock only specific methods (onlyMethods([])), or add extra methods (addMethods([])). Since PHPUnit 10, willReturnCallback() is the recommended way to produce dynamic return values, since returnCallback() was removed as a standalone method. The willThrowException() method allows simulating exceptions, which is indispensable for error-handling tests.
8. Common mistakes when using Test Doubles
The most common mistake is over-mocking: every dependency gets configured as a Mock, even when only return values are needed. This leads to tests that break on every internal refactoring, because Mocks are pinned to implementation details (call count, order). The rule is: only mock what genuinely needs to be verified as a command. Everything else is a Stub or a Fake.
A second classic: assertions inside the Mock instead of in the test body. When with() becomes too complex, it is better to move the check into a dedicated assertion block after the Act step, using a Spy-like approach with a callback and a stored variable. A third mistake: mocking value objects and entities. Values like Money, Email or OrderId are not services; they have no dependencies and should always be instantiated as real objects. Anyone who mocks them is testing PHPUnit instead of their own code.
<?php
// Common mistake: over-mocking, mocking value objects and simple collaborators
class BadTest extends TestCase
{
public function testBad(): void
{
// WRONG: Money is a value object, create it for real
$price = $this->createMock(Money::class);
$price->method('getAmount')->willReturn(100);
// WRONG: using mock where stub is correct, no interaction to verify
$repo = $this->createMock(ProductRepository::class);
$repo->expects($this->once())->method('findById')->willReturn($product);
// If the implementation calls findById twice for caching, test breaks for wrong reason
}
}
class GoodTest extends TestCase
{
public function testGood(): void
{
// CORRECT: value object instantiated for real
$price = new Money(amount: 100, currency: 'EUR');
// CORRECT: stub, we need the return value, not interaction count
$repo = $this->createStub(ProductRepository::class);
$repo->method('findById')->willReturn(new Product(id: 1, price: $price));
$service = new PricingService($repo);
$result = $service->getDisplayPrice(productId: 1);
$this->assertSame('100,00 €', $result);
}
}
9. Comparison: which Test Double when?
Choosing the right Test Double is not an academic exercise; it has a direct impact on the maintainability and expressiveness of the test suite. The table below summarizes which Test Double is right for which situation.
| Test Double | Purpose | Verifies Interaction? | Example Use |
|---|---|---|---|
| Dummy | Fill a parameter list | No | Irrelevant dependency in the constructor |
| Stub | Deliver fixed return values | No | Repository for a query test |
| Fake | Fully working substitute implementation | No | In-memory repository for integration tests |
| Spy | Record calls, verify afterward | Afterward | Check an event dispatcher for events fired |
| Mock | Expect and verify calls upfront | Yes, upfront | Check a mailer for an exact send call |
The rule of thumb for choosing between Mock and Stub follows command-query separation: queries (read methods without side effects) get stubbed, commands (methods with side effects) get mocked. Fakes come into play when several methods of the same interface need to work together and a Stub can no longer keep the complexity manageable. Dummies are always named explicitly when the intent needs to be communicated that a dependency plays no role in this test case.
Mironsoft
PHPUnit consulting, test architecture and code quality for PHP projects
Test suites that actually give you confidence?
We analyze existing PHPUnit tests, identify over-mocking and misapplied Test Doubles, and refactor the test suite into a maintainable, expressive collection you can rely on for deployments.
Test Review
Analysis of the existing test suite for over-mocking, missing assertions and misapplied Test Doubles
Refactoring
Switching to the correct Test Doubles, introducing Fake repositories and a clean split between Stub and Mock
Training
Team workshop on Test Doubles, CQS in the testing context, and test architecture for PHP projects
10. Summary
Distinguishing Test Doubles by their role is not an academic hobby; it is a practical tool for maintainable test suites. Dummies communicate which dependencies are irrelevant. Stubs deliver controlled data without interaction verification, ideal for query methods. Fakes are fully working substitute implementations for complex scenarios with state transitions. Spies record calls and allow verification after the fact, without expectations fixed in advance. Mocks verify command methods with exact expectations about calls, count and arguments.
The most important principle: use command-query separation as the guide for choosing a Test Double. Queries get stubbed, commands get mocked. Anyone who introduces Fakes for complex collaborators reduces reliance on PHPUnit's mock API and writes tests that are more resilient to internal refactorings. Over-mocking is the biggest quality problem in many PHPUnit suites, and the first thing that stands out in a test review.
Test Doubles in PHPUnit: the essentials at a glance
Stub vs. Mock
A Stub delivers data without expectations. A Mock verifies command calls upfront. Never use Mocks for query methods; that makes tests fragile.
Fake for complexity
Write in-memory repositories as Fakes when several interface methods interact. More maintainable than convoluted Mock configurations.
createStub() vs. createMock()
Prefer createStub() since PHPUnit 9 for Stubs; it signals semantically that no interaction verification is taking place.
Never mock value objects
Always create Money, Email, OrderId and other value objects as real instances. Mocking them means testing PHPUnit instead of your own code.