Test Doubles — Mocks, Stubs, Spies, Fakes, and Avoiding Over-Mocking
Clearly distinguish stubs, mocks, spies, and fakes as distinct kinds of test doubles, use PHPUnit's createMock() and related methods correctly to configure return values and verify interactions, understand the difference between state testing and interaction testing, and recognize how over-mocking produces tests that keep passing even when the real implementation is broken.
Unit tests need to isolate the thing being tested from its real collaborators, and 'test double' is the umbrella term for any stand-in object used to achieve that isolation — but not all test doubles play the same role, and using the wrong one (or leaning on doubles too heavily) is one of the most common ways a PHP test suite ends up giving false confidence. This lesson defines the four kinds of test double you'll actually use — stubs, mocks, spies, and fakes — shows each one built with PHPUnit's built-in mocking API, distinguishes state testing (checking what a method returns) from interaction testing (checking that a method was called), and spends real time on the danger every team eventually runs into: over-mocking, where a test suite full of green checkmarks is hiding a genuinely broken implementation because everything real was replaced with a double.
Stubs: canned answers, nothing more
A stub is a test double that returns pre-programmed ('canned') answers to method calls, with no concern for whether or how those methods are called — its entire job is to feed the code under test a controlled input so you can observe the output. If you're testing a ShippingCalculator that depends on a TaxRateProvider, and you want to test what happens when the tax rate is exactly 0, a stub TaxRateProvider that always returns 0.0 from getRate() lets you test that scenario deterministically, without needing a real tax service or database.
In PHPUnit, you build a stub with createMock(TaxRateProvider::class) (PHPUnit's mocking API is used for both stubs and mocks — the difference is in how you configure and use the resulting object, not which method creates it) and configure its return value with ->method('getRate')->willReturn(0.0). The test that uses a stub asserts on the state or return value of the thing under test — this is called state testing — and never asserts anything about the stub itself, because a stub's whole purpose is to be a quiet, obedient source of input data.
• Stub — returns canned answers to make the code under test behave predictably; the test asserts on the RESULT, not on the stub itself. • Built with createMock() + ->method(...)->willReturn(...) (or willReturnCallback / willThrowException for more complex canned behavior). • Used for state testing: 'given this input, does the code under test produce the right output?' • A stub is never asked 'were you called correctly?' — only 'did you feed the right data?'
Mocks: verifying an interaction actually happened
A mock goes further than a stub: in addition to (optionally) returning canned values, a mock has built-in expectations about how it should be called — which methods, how many times, with what arguments — and the test fails if those expectations aren't met, independent of what the code under test returns. Mocks exist for behavior that doesn't show up in a return value at all: verifying that a EmailSender::send() method was actually invoked when a user registers is otherwise invisible to a state-based assertion, because a successful registration might return the same 'success' result whether or not the email was actually sent.
In PHPUnit, this expectation is set with ->expects($this->once())->method('send')->with($this->equalTo($expectedEmail)) before the code under test runs — the assertion isn't a line you write after the fact, it's baked into the double itself, and PHPUnit checks it automatically when the test method finishes. This is interaction testing: instead of asking 'what did the code return?' you ask 'did the code correctly talk to its collaborator?' — a fundamentally different (and complementary) kind of correctness than state testing checks.
• Mock — has built-in expectations about calls (method, count, arguments); the TEST FAILS if the expectation isn't met, verified automatically. • Built the same way as a stub (createMock()) but configured with ->expects(...) instead of just ->willReturn(...). • Used for interaction testing: 'was this collaborator called correctly?' — for side effects with no visible return value. • $this->once(), $this->exactly(2), $this->never(), $this->atLeastOnce() — common call-count matchers.
Spies: observe first, assert later
A spy is closely related to a mock but flips the order: instead of declaring 'I expect this call' up front and having PHPUnit check it automatically, a spy records what happened during the test, and you make your own explicit assertions about that recorded history afterward, in the body of the test method itself. PHPUnit doesn't have a distinct 'spy' API the way some other frameworks do — the common pattern is a hand-written or lightweight fake object that simply logs every call it receives into an array, which the test then inspects with ordinary assertions like assertCount() or assertSame() against that log.
The practical difference from a mock matters for readability: a mock's expectation lives in setup code and PHPUnit enforces it silently, which can make a failure message less immediately clear about what exactly went wrong; a spy's assertions live directly in the test body as ordinary, explicit assertEquals()/assertCount() calls against recorded data, which some teams find easier to read and debug, at the cost of writing a little more object of your own (or a small in-project SpyLogger-style helper) rather than relying entirely on PHPUnit's built-in expectation syntax.
Fakes: a real, working, simplified implementation
A fake is different in kind from the other three: it's a genuine, working implementation of the same interface as the real dependency, just simplified in a way that makes it fast and safe for tests — the classic example is an InMemoryUserRepository that implements the same UserRepository interface as a real database-backed one, storing objects in a PHP array instead of running SQL against a database. Unlike a stub or mock, a fake isn't configured per test with canned answers or expectations — it behaves like the real thing (save() actually makes find() return the saved object afterward), just without the real dependency's cost or side effects.
Fakes are especially valuable for testing code that does several operations against the same collaborator within one test — a workflow that saves a user, then looks them up, then updates them — because a stub configured with a single willReturn() can't represent that kind of stateful, multi-step interaction realistically, while a fake naturally can, since it's actually storing and retrieving real state. The tradeoff is that a fake takes more effort to write and maintain than a one-line createMock()->willReturn() stub, so it's worth the investment specifically for a dependency your tests interact with in this stateful, multi-step way.
• Fake — a real, working implementation of the same interface, simplified (in-memory instead of a real database, for example). • Behaves like the real thing across multiple calls within a test, unlike a stub's fixed, single canned answer. • Costs more to write than a one-line stub, but pays off for dependencies exercised statefully across several operations in one test. • A well-written fake is often shared and reused across many tests, unlike a stub which is usually configured freshly per test.
The danger of over-mocking
Every test double you introduce removes a real piece of behavior from what the test actually exercises, replacing it with something you configured by hand — and if you replace too much, the test can end up verifying almost nothing about whether the real system actually works. The clearest failure mode: a test mocks a repository's save() method to return true unconditionally, mocks a validator to always return 'valid', and mocks a payment gateway to always return 'success' — every collaborator in the chain is now a hand-configured double, and the test passes not because the code under test correctly wires those collaborators together, but because every one of them was told in advance to say yes. If the real repository has a bug that silently swallows save failures, or the real validator has a broken regex, or the real payment gateway integration sends the wrong field name — none of that is caught, because none of those real implementations ever ran.
The practical guard against this is a mix of discipline and layering: use mocks and stubs to isolate the specific unit you're testing at the unit-test layer, but pair that with integration tests (the next lesson) that exercise the real collaborator — a real test database, a real (sandboxed) payment gateway client — so that the actual behavior of those pieces is verified somewhere in the suite, even if not in every unit test. A useful heuristic is to be suspicious of any test where every single dependency of the class under test has been replaced with a double and no assertion checks anything beyond 'the mocked method was called' — that's often a sign the test is validating the test's own setup rather than the system's actual behavior.
• Every mocked/stubbed dependency is a piece of real behavior the test does NOT verify — mock too much and the test proves nothing about reality. • Classic failure: every collaborator mocked to return success, so the test passes even when a real collaborator has a genuine bug. • Guard: use unit tests with doubles to isolate ONE unit, and pair them with integration tests that exercise real collaborators somewhere in the suite. • Be suspicious of a test where everything is a double and the only assertions are about mock expectations, not about actual output.
PHPUnit's test double API
createMock() builds the same underlying object for stubs and mocks — 'stub' vs 'mock' describes how you USE it (willReturn only, vs expects()), not a different constructor. ->with(...) on a mock checks the ARGUMENTS a method was called with, in addition to whether/how many times it was called. PHPUnit checks expects() expectations automatically at the end of the test method — no separate assertion call is needed for them.
// Stub — canned return value, no expectation about how it's called
$taxProvider = $this->createMock(TaxRateProvider::class);
$taxProvider->method('getRate')->willReturn(0.0);
// Mock — expects to be called exactly once, with a specific argument
$emailSender = $this->createMock(EmailSender::class);
$emailSender->expects($this->once())
->method('send')
->with($this->equalTo('[email protected]'));
// Stub that throws instead of returning
$paymentGateway = $this->createMock(PaymentGateway::class);
$paymentGateway->method('charge')->willThrowException(new PaymentDeclinedException());
// Call-count matchers for interaction testing
$this->never(); // must NOT be called
$this->once(); // exactly once
$this->exactly(3); // exactly 3 times
$this->atLeastOnce(); // one or more timesCode Examples
The first test uses a stub for PricingRules because the test only cares about the resulting discount value — a state assertion. The second test uses a mock for EmailSender because sending exactly one welcome email is a side effect with no return value to check — only an interaction assertion (expects()->method()) can verify it happened correctly. Notice each test only turns the RELEVANT collaborator into a mock — the other dependency is a plain stub (or even createStub(), PHPUnit's dedicated no-expectations double) precisely so each test stays focused on verifying one thing.
A stub configured with a single willReturn() couldn't represent this test realistically — the test saves a user, then updates it, then reads it back, and each step depends on the previous one's effect actually being reflected. The fake genuinely stores and retrieves state, so the multi-step interaction behaves exactly like it would against a real database, just without one.
Common mistake: Trying to force this scenario through mocks would require stubbing findByEmail() to return a specific User object 'after' updateName() is called — but a stub can't conditionally change its canned answer based on an earlier call in the same test, which is precisely the situation a fake is built to handle naturally.
declare(strict_types=1);
final class RegistrationServiceTest extends \PHPUnit\Framework\TestCase
{
public function testRegisteringGivesNewUserAWelcomeDiscount(): void
{
// Stub: just needs to feed a fixed answer so we can check the RESULT (state testing)
$pricing = $this->createMock(PricingRules::class);
$pricing->method('welcomeDiscountPercent')->willReturn(10);
$emailSender = $this->createStub(EmailSender::class); // don't care how it's used here
$service = new RegistrationService($pricing, $emailSender);
$user = $service->register('[email protected]');
$this->assertSame(10, $user->discountPercent()); // asserting on the RESULT
}
public function testRegisteringSendsExactlyOneWelcomeEmail(): void
{
// Mock: we care about the INTERACTION — was send() actually called, once, with the right address?
$pricing = $this->createStub(PricingRules::class);
$emailSender = $this->createMock(EmailSender::class);
$emailSender->expects($this->once())
->method('send')
->with($this->equalTo('[email protected]'), $this->stringContains('Welcome'));
…declare(strict_types=1);
final class InMemoryUserRepository implements UserRepository
{
/** @var array<string, User> */
private array $users = [];
public function save(User $user): void
{
$this->users[$user->email()] = $user;
}
public function findByEmail(string $email): ?User
{
return $this->users[$email] ?? null;
}
public function delete(string $email): void
{
unset($this->users[$email]);
}
}
final class UserServiceTest extends \PHPUnit\Framework\TestCase
{
public function testUpdatingAUserPersistsTheChange(): void
{
$repository = new InMemoryUserRepository(); // real, working, in-memory implementation
$service = new UserService($repository);
$service->register('[email protected]', 'Bob');
$service->updateName('[email protected]', 'Robert');
$user = $repository->findByEmail('[email protected]');
$this->assertSame('Robert', $user->name()); // real state, actually round-tripped
}
}The four kinds of test double, by purpose
Pick the double that matches what you actually need to verify — not just whichever is fastest to type.
Configured with canned data Verifies HOW it was called
----------------------------- --------------------------
Stub YES (willReturn/willThrow) no
Mock optionally YES (expects()->method())
Spy records calls, no auto-check you assert on the recording, after the fact
Fake NO — it's a real, working impl behaves correctly across multiple real calls
State testing <-----------------------------------> Interaction testing
"what did the code under test RETURN?" "did the code CALL its collaborator correctly?"Important Rules
Use a stub when you need to control what a collaborator returns and don't care how it's called (state testing).
Use a mock when a call itself — not a return value — is the thing you need to verify (interaction testing for side effects).
Use a fake when the code under test performs multiple, related operations against the same collaborator within one test.
Never mock every single dependency of the class under test — leave room for integration tests to exercise the real thing somewhere in the suite.
Be suspicious of a test whose only assertions are mock call-count expectations, with no check on the actual observable output.
How this balance looks on a real team
A checkout service's unit tests mock the PaymentGateway (a genuinely external, slow, costly-to-call dependency) to test pricing and discount logic quickly and deterministically, while using a fake InMemoryCartRepository so multi-step cart operations (add item, apply coupon, checkout) behave statefully and realistically within the test. Separately, an integration test suite exercises the real OrderRepository against a real test database, and a small, deliberately isolated set of tests run against the payment gateway's official sandbox environment to verify the actual HTTP integration works — so the 'is the real thing correct' question that mocking necessarily skips gets answered somewhere in the overall suite, just not inside every fast unit test.
$repo = $this->createMock(OrderRepository::class); $repo->method('save')->willReturn(true); $validator = $this->createMock(OrderValidator::class); $validator->method('validate')->willReturn(true); $gateway = $this->createMock(PaymentGateway::class); $gateway->method('charge')->willReturn(true); // ... every collaborator mocked to say yes; test only checks the final return value is true
// Unit test: mock ONLY the payment gateway (the genuinely external dependency) to isolate pricing logic. // Separate integration test: use a REAL OrderValidator and a real (test) database-backed OrderRepository // so their actual behavior is exercised somewhere in the suite.
When every collaborator is mocked to unconditionally return success, the test proves only that the orchestration code calls things in some order — it provides zero evidence that the real validator actually rejects invalid orders, or that the real repository actually persists data correctly, because neither real implementation ever ran during the test.
What is the defining difference between a stub and a mock in PHPUnit? A. A stub can only return strings; a mock can return any type. B. A stub provides canned return values and the test asserts on the result (state testing); a mock has built-in expectations about how it's called, which PHPUnit verifies automatically (interaction testing). C. Mocks are always slower to run than stubs. D. There is no real difference — both terms describe the exact same object.
B. A stub provides canned return values and the test asserts on the result (state testing); a mock has built-in expectations about how it's called, which PHPUnit verifies automatically (interaction testing). — A stub exists purely to feed controlled input so you can check the code under test's output; a mock additionally declares expectations (call count, arguments) that PHPUnit itself checks, making it the right tool for verifying a side-effecting interaction with no observable return value.
What distinguishes a fake from a stub or mock? A. A fake is a real, working (simplified) implementation of the same interface, behaving correctly across multiple calls — unlike a stub's single canned answer or a mock's call expectations. B. A fake is just another name for a mock in PHPUnit's documentation. C. A fake always throws an exception when called. D. A fake can only be used in E2E tests, never in unit tests.
A. A fake is a real, working (simplified) implementation of the same interface, behaving correctly across multiple calls — unlike a stub's single canned answer or a mock's call expectations. — A fake genuinely implements the collaborator's behavior (an in-memory repository that actually stores and retrieves data), which makes it suited to multi-step, stateful interactions that a single-canned-answer stub cannot represent.
Why is over-mocking a genuine risk to a test suite's value? A. Mocks run slower than real implementations, making the suite take longer. B. If every collaborator of the class under test is replaced with a double configured to always succeed, the test can pass even when a real collaborator has an actual bug, because none of the real implementations ever ran. C. PHPUnit limits the number of mocks allowed per test file. D. Over-mocking causes tests to fail randomly due to timing issues.
B. If every collaborator of the class under test is replaced with a double configured to always succeed, the test can pass even when a real collaborator has an actual bug, because none of the real implementations ever ran. — Every mocked collaborator is real behavior the test does not exercise — mock too many, and the test only proves the orchestration code calls things in some order, not that the real underlying pieces actually work correctly.
A test verifies that EmailSender::send() was called exactly once with a specific email address, using PHPUnit's expects()/with(). What kind of testing is this? A. State testing, because it checks a return value. B. Interaction testing, because it verifies HOW a collaborator was called rather than checking a returned value. C. Smoke testing. D. Contract testing.
B. Interaction testing, because it verifies HOW a collaborator was called rather than checking a returned value. — Checking that a specific method was called, how many times, and with what arguments is interaction testing — it verifies the code under test correctly talks to its collaborator, which is exactly what's needed for side effects that produce no observable return value.
A NotificationService::notifyOrderShipped(Order $order) method looks up the order's customer via a CustomerRepository, then calls an SmsSender::send(string $phone, string $message) method. Write two PHPUnit tests: one using a stub CustomerRepository (state testing) to verify the SMS message text correctly includes the order's tracking number, and one using a mock SmsSender (interaction testing) to verify send() is called exactly once with the customer's phone number.
public function testMessageIncludesTrackingNumber(): void { $customers = $this->createMock(CustomerRepository::class); $customers->method('find')->willReturn(new Customer('555-0100')); $sms = $this->createMock(SmsSender::class); $captured = null; $sms->method('send')->willReturnCallback(function ($phone, $msg) use (&$captured) { $captured = $msg; }); (new NotificationService($customers, $sms))->notifyOrderShipped(new Order(id: 42, trackingNumber: 'TRK123')); $this->assertStringContainsString('TRK123', $captured); } public function testSendsExactlyOneSmsToCustomerPhone(): void { $customers = $this->createStub(CustomerRepository::class); $customers->method('find')->willReturn(new Customer('555-0100')); $sms = $this->createMock(SmsSender::class); $sms->expects($this->once())->method('send')->with('555-0100', $this->anything()); (new NotificationService($customers, $sms))->notifyOrderShipped(new Order(id: 42, trackingNumber: 'TRK123')); }
The first test needs to inspect what was actually sent — capture the message with a stub SmsSender that records its argument, or use willReturnCallback to capture it. The second test only needs ->expects($this->once())->method('send')->with(...).