Mocking vs. Fakes in PHP Tests: Why We Mock Repositories But Avoid Mocking Everything
A mock verifies that a specific interaction happened — it asserts on behavior. A fake is a real, working, simplified implementation of a dependency — it asserts on state. Both are test doubles, but confusing the two, or defaulting to mocks everywhere, is one of the most common ways a test suite becomes brittle without actually catching more bugs. This article uses the repository pattern example from our earlier article to show the real difference in practice.
The Core Distinction
A mock is a test double you set expectations on: "this method must be called exactly once, with these exact arguments." If that expectation isn't met, the test fails — regardless of whether the outcome would otherwise have been correct. A fake is a test double that actually behaves like the real thing, just simpler: an in-memory array instead of a database, but still genuinely storing, retrieving, and returning data.
The Same Cancellation Example, Two Ways
Recall the OrderCancellationService from the repository pattern article, which depends on OrderRepositoryInterface. Here's the same test written first as a mock, then as a fake.
Version 1: Mocking
```php
public function testCancelOrder_UsingMock()
{
$order = new Order(id: 1, status: 'pending');
$repository = $this->createMock(OrderRepositoryInterface::class);
$repository->expects($this->once())
->method('findCancellable')
->with(1)
->willReturn($order);
$repository->expects($this->once())
->method('markCancelled')
->with($order);
$inventory = $this->createMock(InventoryRepositoryInterface::class);
$inventory->expects($this->once())
->method('releaseReservation')
->with(1);
$service = new OrderCancellationService($repository, $inventory);
$result = $service->cancel(1);
$this->assertTrue($result);
}
```
Version 2: Using a Fake
```php
public function testCancelOrder_UsingFake()
{
$repository = new FakeOrderRepository();
$repository->seed(new Order(id: 1, status: 'pending'));
$inventory = new FakeInventoryRepository();
$service = new OrderCancellationService($repository, $inventory);
$result = $service->cancel(1);
$this->assertTrue($result);
$this->assertEquals('cancelled', $repository->find(1)->status);
$this->assertFalse($inventory->isReserved(1));
}
```
Why the Fake Version Is Usually Better Here
The mock version is asserting on implementation details: that findCancellable gets called, that markCancelled gets called with a specific object reference, that it all happens exactly once. If you later refactor OrderCancellationService to, say, call a different repository method that achieves the same result, the mock test breaks — even though the actual behavior (the order gets cancelled) is completely unchanged. This is the classic failure mode of over-mocking: tests that break on refactors, not on regressions.
The fake version asserts on the actual outcome: after calling cancel(), is the order's status really "cancelled," and is the inventory really released? It doesn't care how the service got there internally. That means it survives refactors that don't change behavior, and only fails when the actual outcome is wrong — which is what a test is supposed to catch.
So When Should You Actually Use a Mock?
Mocks earn their place specifically when the interaction itself is the thing you need to verify, not just the end state. A few genuine cases:
- Side effects with no observable state. If cancelling an order should also send an email, there's often no state to assert on directly — the best you can do is verify that NotificationService::send() was called with the right arguments.
- Verifying something did NOT happen. For example, asserting a payment gateway's charge() method was never called when an order fails validation — a fake can't easily express "nothing happened," but a mock's expects($this->never()) does this cleanly.
- Expensive or dangerous real dependencies where even a fake would be overkill for a single, narrow interaction check — a third-party API client where you only need to confirm one specific call shape.
Why We Don't Mock Everything
The temptation is to mock every dependency, every time, since it's often less code to set up for a single test. The cost shows up later: a test suite full of mocks tends to test that the code calls what it calls, not that the code actually works — and it actively resists refactoring, punishing internal changes that don't affect behavior. This directly undermines the point of test-driven development as a safety net for change: a safety net that tears the moment you touch it isn't one you can rely on.
A practical rule of thumb: for a repository or any dependency where "did the data end up in the right state" is a meaningful, checkable question, write a fake and assert on state. Reach for a mock only when the interaction itself — a call, a non-call, an argument shape — is genuinely what needs verifying, and no observable state change exists to check instead.
A Note on Maintenance Cost
Fakes do cost more to write upfront — a FakeOrderRepository needs its own small, correct implementation of find, seed, markCancelled, and so on. But that cost is paid once and reused across every test that needs an OrderRepositoryInterface, whereas mock expectations tend to get rewritten per test, since they're often tied to the specific interaction being verified in that one case.
Now that AI automate code writing, that initial upfront cost becomes negligible.
Frequently Asked Questions
Are fakes harder to keep in sync with the real implementation?
Yes, this is a genuine risk — if OrderRepository's real behavior changes, FakeOrderRepository needs to change too, and nothing enforces that automatically. Keeping the fake's tests focused on the interface's documented contract, and periodically running a shared contract test against both the real and fake implementation, helps catch drift.
Can you use both a mock and a fake in the same test?
Yes, and it's common: fake the repository whose state you care about, mock a side-effect dependency like a notification service where only the call itself matters.
Isn't writing a fake just reimplementing the real class?
Not usually — a fake only needs to satisfy the interface's contract well enough for tests, typically with an in-memory array instead of a database. It's a small fraction of the real implementation's complexity (no SQL, no connections, no migrations), while still behaving correctly enough to make state-based assertions meaningful.
Building a PHP codebase where the test suite has become brittle or unreliable? Get in touch.