Mocks test implementation details. Fakes test behavior.
In hexagonal architecture, ports are promises, adapters are implementations. Mocks break this promise. Fakes keep it.
When you use mocks in application services, you're not testing your business logic – you're testing whether your code still looks the same as yesterday.
Before diving into technical details, we need to understand: Why is mock culture so persistent?
1. Misunderstood Unit Test Principles
Many developers learn:
"Unit tests should be isolated. Each class is tested separately."
What they make of it:
"I must mock all dependencies, otherwise I'm not testing in isolation."
The truth:
- "Unit" doesn't mean "class", it means "behavior" (use case, feature)
- "Isolated" doesn't mean "no real objects", it means "without external systems" (DB, network, filesystem)
A test with an in-memory repository is isolated. It needs no DB, no fixtures, no setup.
2. Cargo Cult from Blog Posts and Tutorials
Google "how to test in PHP" → 90% of tutorials show mocks.
Why?
Because mocks:
- Are quick to explain (5 lines of code)
- Look fancy (
->expects($this->once())) - Are built into most testing frameworks
What's missing?
The explanation of when mocks make sense and when they don't.
The result: Junior developers blindly copy the pattern and mock everything.
3. False Prestige: "Sophisticated Engineering"
$mock->expects($this->exactly(2))
->method('save')
->withConsecutive(
[$this->callback(fn($o) => $o->status === 'pending')],
[$this->callback(fn($o) => $o->status === 'confirmed')]
)
->willReturnOnConsecutiveCalls($order1, $order2);This looks like sophisticated engineering.
This feels like control over every aspect of the system.
This is actually just syntactic sugar for a fragile test setup.
The illusion: "The more complex my mock, the better my test."
The reality: "The more complex my mock, the more often it breaks."
4. Lack of Experience with the Pain
When you're new to a project:
- Mocks feel fast (3 minutes setup)
- Mocks feel flexible (configurable)
- Mocks feel safe (isolated)
After 6 months:
- Mocks are slow (20 minutes per refactoring to fix all tests)
- Mocks are fragile (change one method, 15 tests break)
- Mocks are unsafe (tests green, feature broken)
But: The person who wrote the mock is already on the next team.
The pain hits the maintainer, not the author.
5. Fear of "Too Much Code"
A common argument:
"A fake is 50 lines of code. A mock is 5 lines. Mocks are more efficient."
The math doesn't add up:
// Fake: write once
final class InMemoryOrderRepository implements OrderRepositoryInterface
{
private array $orders = [];
public function save(Order $order): void {
$this->orders[$order->id] = $order;
}
public function findById(string $id): ?Order {
return $this->orders[$id] ?? null;
}
}
// 15 lines, written once
// In 10 tests:
$orders = new InMemoryOrderRepository();
// 1 line per test = 10 linesvs.
// Mock: new in every test
$orders = $this->createMock(OrderRepositoryInterface::class);
$orders->expects($this->once())
->method('save')
->with($this->isInstanceOf(Order::class));
$orders->expects($this->once())
->method('findById')
->willReturn($order);
// 8 lines per test
// In 10 tests = 80 linesFake: 15 + 10 = 25 lines
Mocks: 80 lines
And then comes maintenance:
- Fake: Interface changes → update 1 place
- Mock: Interface changes → update 10 tests
Mocks give you the feeling of control:
"I tell the system what to do. I control every call."
This is an illusion.
You don't control the system – you control your expectations of the system.
Example:
// With mock: "I expect save() to be called"
$mock->expects($this->once())->method('save');
// With fake: "I verify the order was saved"
$this->assertNotNull($repo->findById($order->id));The mock tests your assumption.
The fake tests reality.
Which is more trustworthy?
Yes, Martin Fowler wrote about mocks. But he also wrote:
"Mockist TDD leads to a different style of testing than classic TDD. Classicists prefer to test the state of the object, mockists prefer to test the behavior."
And later:
"The danger of mockist testing is that it can lead to tests that are too focused on implementation details rather than on the behavior of the system."
Fowler warns against mock overuse.
When you write a mock, you're not just testing your function's API – you're dictating exactly what happens inside that function:
public function testCreatesOrder(): void
{
$repo = $this->createMock(OrderRepositoryInterface::class);
// This mock says: "You MUST call save() exactly once"
$repo->expects($this->once())->method('save');
// This mock says: "You MUST call findById() with this exact parameter"
$repo->expects($this->once())
->method('findById')
->with('cust-123');
$service = new CreateOrderService($repo);
$service->execute('cust-123', $items);
}What does this test care about?
- That
save()is called exactly once (not zero, not twice) - That
findById()is called with the exact parameter - The order of these calls
- The number of these calls
What should this test care about?
- That an order is created
- That the order can be retrieved later
The mock is micromanaging your implementation.
Imagine you want to optimize your code:
// Version 1: Call repository twice
public function execute(string $customerId, array $items): Order
{
$customer = $this->customers->findById($customerId);
$order = Order::create($customer, $items);
$this->orders->save($order);
$payment = $this->gateway->charge($customer->paymentMethodId, $order->total);
$order->markAsPaid($payment->transactionId);
$this->orders->save($order); // Second save
return $order;
}
// Version 2: Cache in object, call repository once
public function execute(string $customerId, array $items): Order
{
$customer = $this->customers->findById($customerId);
$order = Order::create($customer, $items);
$payment = $this->gateway->charge($customer->paymentMethodId, $order->total);
$order->markAsPaid($payment->transactionId);
$this->orders->save($order); // Only one save
return $order;
}With mocks: Your test breaks. It expected exactly(2) calls to save().
With fakes: Your test stays green. The behavior is identical.
The problem: The mock is policing your implementation, not your API.
When tests break during refactoring, you face a choice:
- Revert the refactoring – "The tests are red, so my change must be wrong"
- Fix all the mocks – "I need to update 15 test files to change one line of code"
Both options are wrong. The real problem is: Your tests are testing the wrong thing.
// This is what you SHOULD be testing:
public function testCreatesOrderSuccessfully(): void
{
$orders = new InMemoryOrderRepository();
$service = new CreateOrderService($orders);
$order = $service->execute('cust-123', $items);
// Test the API contract, not the implementation
$this->assertNotNull($orders->findById($order->id));
$this->assertTrue($order->isPaid());
}This test doesn't care:
- How many times you call
save() - Whether you cache objects
- What internal optimizations you make
It only cares: Does the feature work?
"The first thing I delete when refactoring is the mock expectations."
When I see a test with mock expectations, I know:
- Someone is telling me HOW to write my code
- This test will break if I change ANYTHING internally
- I'm not free to improve the implementation
Mocks are code reviewers that never sleep.
They judge every line you write, forever.
And unlike a real code reviewer, they can't be convinced that your change is better.
These aren't accusations – they're reflections. If you find yourself nodding, it might be time to reconsider your approach.
public function testCreatesOrder(): void
{
$repo = $this->createMock(OrderRepositoryInterface::class);
$repo->expects($this->once())->method('save');
$service = new CreateOrderService($repo);
$service->execute($customerId, $items);
}What does this test verify?
- ✅ That
save()is called - ❌ That an order is created
- ❌ That the order is saved
- ❌ That the order is retrievable
It tests the code, not the feature.
// Mock test
$repo = $this->createMock(OrderRepositoryInterface::class);
$repo->method('findById')->willReturn($order);
// Replace with Doctrine:
$repo = new DoctrineOrderRepository($entityManager);
// Question: Is the test still meaningful?
Answer: No. The mock always returns $order, even if it doesn't exist.
public function testComplexBusinessLogic(): void
{
$orders = $this->createMock(OrderRepositoryInterface::class);
$customers = $this->createMock(CustomerRepositoryInterface::class);
$payments = $this->createMock(PaymentGatewayInterface::class);
$discounts = $this->createMock(DiscountRepositoryInterface::class);
// 30 lines of mock setup...
$service->execute('cust-123', $items);
// Test fails: Expected 89.99, got 99.99
}How do you figure out why the discount wasn't applied?
With mocks: Read code, guess, write more mocks
With fakes: XDebug → Step Into → See state → Bug found
// Before
public function execute(string $customerId, array $items): Order
{
$customer = $this->customers->findById($customerId);
$order = Order::create($customer, $items);
$this->orders->save($order);
return $order;
}
// After (performance optimization: batch save)
public function execute(string $customerId, array $items): Order
{
$customer = $this->customers->findById($customerId);
$order = Order::create($customer, $items);
// We don't save immediately, only on flush()
return $order;
}
public function flush(): void
{
$this->orders->saveAll(); // Batch insert
}Mock test: ❌ Breaks because save() is no longer called
Fake test: ✅ Works because the behavior is the same
The question: Why should improved code break the test?
If the goal is:
- "I want to ensure my methods are called" → Mocks
If the goal is:
- "I want to ensure my features work" → Fakes
What matters more for your business?
// Application Service
final class CreateOrderService
{
public function __construct(
private OrderRepositoryInterface $orders,
private CustomerRepositoryInterface $customers,
private PaymentGatewayInterface $paymentGateway,
) {}
public function execute(string $customerId, array $items): Order
{
$customer = $this->customers->findById($customerId);
if (!$customer) {
throw new CustomerNotFoundException();
}
$order = Order::create($customer, $items);
$this->orders->save($order);
$payment = $this->paymentGateway->charge(
$customer->paymentMethodId,
$order->total
);
$order->markAsPaid($payment->transactionId);
$this->orders->save($order);
return $order;
}
}final class CreateOrderServiceTest extends TestCase
{
public function testCreatesOrderAndChargesCustomer(): void
{
$customers = $this->createMock(CustomerRepositoryInterface::class);
$orders = $this->createMock(OrderRepositoryInterface::class);
$gateway = $this->createMock(PaymentGatewayInterface::class);
$customer = new Customer('cust-123', 'pm-456');
$customers->expects($this->once())
->method('findById')
->with('cust-123')
->willReturn($customer);
$orders->expects($this->exactly(2))
->method('save')
->with($this->isInstanceOf(Order::class));
$payment = new Payment('txn-789', 99.99);
$gateway->expects($this->once())
->method('charge')
->with('pm-456', 99.99)
->willReturn($payment);
$service = new CreateOrderService($orders, $customers, $gateway);
$order = $service->execute('cust-123', [['sku' => 'ABC', 'qty' => 2]]);
$this->assertInstanceOf(Order::class, $order);
}
}Scenario 1: You optimize performance
// You cache the customer in the order to avoid a second save()
public function execute(string $customerId, array $items): Order
{
$customer = $this->customers->findById($customerId);
if (!$customer) {
throw new CustomerNotFoundException();
}
$order = Order::create($customer, $items);
$payment = $this->paymentGateway->charge(
$customer->paymentMethodId,
$order->total
);
$order->markAsPaid($payment->transactionId);
$this->orders->save($order); // Only 1x save() now
return $order;
}Result: ❌ Test breaks. You made an improvement, but the mock expects exactly(2).
Scenario 2: You change the order
// You check payment availability BEFORE creating the order
public function execute(string $customerId, array $items): Order
{
$customer = $this->customers->findById($customerId);
if (!$customer) {
throw new CustomerNotFoundException();
}
// Check payment first, then create order
$total = $this->calculateTotal($items);
$canPay = $this->paymentGateway->canCharge($customer->paymentMethodId, $total);
if (!$canPay) {
throw new InsufficientFundsException();
}
$order = Order::create($customer, $items);
$this->orders->save($order);
$payment = $this->paymentGateway->charge($customer->paymentMethodId, $order->total);
$order->markAsPaid($payment->transactionId);
$this->orders->save($order);
return $order;
}Result: ❌ Test breaks. The order of calls is different. But the behavior is identical (or even better).
Scenario 3: A real bug sneaks in
public function execute(string $customerId, array $items): Order
{
$customer = $this->customers->findById($customerId);
if (!$customer) {
throw new CustomerNotFoundException();
}
$order = Order::create($customer, $items);
$this->orders->save($order);
// BUG: We're using the wrong paymentMethodId
$payment = $this->paymentGateway->charge(
'hardcoded-pm-999', // ← Bug here!
$order->total
);
$order->markAsPaid($payment->transactionId);
$this->orders->save($order);
return $order;
}Result: ✅ Test is green. The mock only expects one call to charge() with some parameters (or with 'pm-456', but that's only compared in the mock, not validated in real execution).
Mocks can miss real bugs because they only verify method calls, not correct behavior. Here are common scenarios:
1. Wrong data used
// Bug: Using wrong customer ID
$payment = $this->gateway->charge('wrong-id', $order->total);
// Mock stays green because it only checks that charge() was called
$gateway->expects($this->once())->method('charge');2. Missing validation
// Bug: No null check
$customer = $this->customers->findById($customerId);
$order = Order::create($customer, $items); // ← Crash if customer is null!
// Mock stays green because findById() was called
$customers->expects($this->once())->method('findById');3. Wrong calculation
// Bug: Wrong total calculation
$payment = $this->gateway->charge(
$customer->paymentMethodId,
$order->total * 0.5 // ← Only charging 50%!
);
// Mock stays green if it only checks method signature
$gateway->expects($this->once())
->method('charge')
->with($this->isType('string'), $this->isType('float'));4. Silent failures
// Bug: Exception swallowed
try {
$this->orders->save($order);
} catch (\Exception $e) {
// ← Bug: Exception ignored, order not saved!
}
// Mock stays green because save() was called
$orders->expects($this->once())->method('save');5. Side effects ignored
// Bug: Order state inconsistent
$order->markAsPaid($payment->transactionId);
// ← Forgot to save after marking as paid!
return $order; // Returns object with isPaid() = true, but not saved
// Mock stays green because it only checks method calls, not persistenceThe pattern: Mocks verify "Did you call the method?" but not "Did it work correctly?"
final class InMemoryCustomerRepository implements CustomerRepositoryInterface
{
/** @var array<string, Customer> */
private array $customers = [];
public function save(Customer $customer): void
{
$this->customers[$customer->id] = $customer;
}
public function findById(string $id): ?Customer
{
return $this->customers[$id] ?? null;
}
}
final class InMemoryOrderRepository implements OrderRepositoryInterface
{
/** @var array<string, Order> */
private array $orders = [];
public function save(Order $order): void
{
$this->orders[$order->id] = $order;
}
public function findById(string $id): ?Order
{
return $this->orders[$id] ?? null;
}
public function all(): array
{
return array_values($this->orders);
}
}
final class InMemoryPaymentGateway implements PaymentGatewayInterface
{
/** @var array<string, Payment> */
private array $payments = [];
/** @var array<string, float> */
private array $balances = [];
public function setBalance(string $paymentMethodId, float $amount): void
{
$this->balances[$paymentMethodId] = $amount;
}
public function charge(string $paymentMethodId, float $amount): Payment
{
$balance = $this->balances[$paymentMethodId] ?? 0;
if ($balance < $amount) {
throw new InsufficientFundsException();
}
$payment = new Payment(
'txn-' . uniqid(),
$amount,
$paymentMethodId
);
$this->payments[$payment->id] = $payment;
$this->balances[$paymentMethodId] -= $amount;
return $payment;
}
public function getPayment(string $transactionId): ?Payment
{
return $this->payments[$transactionId] ?? null;
}
}final class CreateOrderServiceTest extends TestCase
{
private InMemoryCustomerRepository $customers;
private InMemoryOrderRepository $orders;
private InMemoryPaymentGateway $gateway;
private CreateOrderService $service;
protected function setUp(): void
{
$this->customers = new InMemoryCustomerRepository();
$this->orders = new InMemoryOrderRepository();
$this->gateway = new InMemoryPaymentGateway();
$this->service = new CreateOrderService(
$this->orders,
$this->customers,
$this->gateway
);
}
public function testCreatesOrderAndChargesCustomer(): void
{
// Arrange
$customer = new Customer('cust-123', 'pm-456');
$this->customers->save($customer);
$this->gateway->setBalance('pm-456', 1000.00);
// Act
$order = $this->service->execute('cust-123', [
['sku' => 'ABC', 'price' => 49.99, 'qty' => 2]
]);
// Assert
$this->assertSame(99.98, $order->total);
$this->assertTrue($order->isPaid());
$savedOrder = $this->orders->findById($order->id);
$this->assertNotNull($savedOrder);
$this->assertTrue($savedOrder->isPaid());
$payment = $this->gateway->getPayment($order->transactionId);
$this->assertNotNull($payment);
$this->assertSame(99.98, $payment->amount);
$this->assertSame('pm-456', $payment->paymentMethodId);
}
public function testThrowsExceptionWhenCustomerNotFound(): void
{
$this->expectException(CustomerNotFoundException::class);
$this->service->execute('nonexistent', []);
}
public function testThrowsExceptionWhenInsufficientFunds(): void
{
$customer = new Customer('cust-123', 'pm-456');
$this->customers->save($customer);
$this->gateway->setBalance('pm-456', 10.00); // Not enough
$this->expectException(InsufficientFundsException::class);
$this->service->execute('cust-123', [
['sku' => 'ABC', 'price' => 49.99, 'qty' => 2]
]);
// Assert: Order was not created
$this->assertCount(0, $this->orders->all());
}
}Scenario 1 (Performance optimization): ✅ Test stays green. Behavior is the same.
Scenario 2 (Change order): ✅ Test stays green. Behavior is the same.
Scenario 3 (Bug with wrong payment method): ❌ Test turns red! The fake verifies that 'pm-456' is actually used, because that's real behavior.
When you write a mock today, you understand the use case:
public function testCreatesOrderWithDiscount(): void
{
$discounts = $this->createMock(DiscountRepositoryInterface::class);
// You know WHY this must be called exactly once
$discounts->expects($this->once())
->method('findForCustomer')
->willReturn(new Discount('GOLD10', 0.10));
// You know WHY this sequence matters
$orders->expects($this->exactly(2))
->method('save');
$service = new CreateOrderService($orders, $customers, $gateway, $discounts);
$service->execute('cust-123', $items);
}You wrote this. You know the business logic. The mock makes sense to you.
A new developer needs to refactor CreateOrderService:
// The refactoring looks safe...
public function execute(string $customerId, array $items): Order
{
$customer = $this->customers->findById($customerId);
// New optimization: batch discount lookups
$discount = $this->discountCache->get($customer->tier);
// ... rest of the code
}Test result: 💥 15 tests break.
Expected method 'findForCustomer' to be called 1 time(s), called 0 time(s)
Questions the new developer asks:
- Why does it expect exactly 1 call?
- What breaks if it's called 0 times?
- What breaks if it's called 2 times?
- Is this a real constraint or just test artifact?
- Should I fix the test or revert my change?
The answer: Nobody knows. The person who wrote the test left 18 months ago.
The mock has become archaeology:
- It documents implementation details that may no longer be relevant
- It enforces constraints that may not be actual requirements
- It blocks refactoring because nobody dares to change it
- It's a riddle wrapped in an enigma wrapped in
expects($this->once())
With a fake:
public function testCreatesOrderWithDiscount(): void
{
$discounts = new InMemoryDiscountRepository();
$discounts->save(new Discount('GOLD10', CustomerTier::GOLD, 0.10));
$customer = new Customer('cust-123', CustomerTier::GOLD, 'pm-456');
$this->customers->save($customer);
$order = $this->service->execute('cust-123', $items);
// Clear business rule: GOLD customers get 10% off
$this->assertSame(89.99, $order->total); // 99.99 - 10%
}Two years later, the new developer sees:
- What the feature does (GOLD discount)
- What the expected outcome is (10% off)
- No implementation details to decode
The test stays green after refactoring, or it turns red for the right reason (the discount broke).
Mock Test Fake Test
───────────────────────────────────────────────────────
Day 1 Clear Clear
Month 6 Still clear Clear
Year 2 Confusing Clear
Year 5 Archaeology Clear
After refactor Broken (false alarm) Broken (real bug) or Green
The pattern:
- Mocks decay. Their meaning erodes as code evolves.
- Fakes endure. They test behavior, which is timeless.
When a new developer joins a codebase with mock-heavy tests, they learn:
"This is how we test. This is what good tests look like."
They see:
public function testSomething(): void
{
$mock1 = $this->createMock(Interface1::class);
$mock2 = $this->createMock(Interface2::class);
$mock3 = $this->createMock(Interface3::class);
$mock1->expects($this->once())->method('doThing');
$mock2->expects($this->exactly(2))->method('doOther');
$mock3->expects($this->never())->method('doLast');
// ... 30 more lines of mock setup ...
}What they learn (wrong):
- "Testing means mocking all dependencies"
- "Good tests are isolated = everything is a mock"
- "Unit tests = one class, all else mocked"
- "Complex mocks = thorough testing"
What they DON'T learn:
- How components actually work together
- What the integration contracts are
- How to test behavior vs. implementation
- How to write maintainable tests
The mock culture reinforces a false dichotomy:
Unit Tests (fast, isolated, MOCK EVERYTHING)
↕
Integration Tests (slow, flaky, AVOID AT ALL COSTS)
This creates a religion: "Thou shalt mock thy dependencies."
The truth:
Domain Tests (pure logic, no doubles)
↓
Application Tests (use case tests, in-memory fakes)
↓
Infrastructure Tests (real adapters, TestContainers)
↓
System Tests (end-to-end, real systems)
In-memory fakes are NOT integration tests.
They're unit tests of behavior, not unit tests of classes.
When a new developer looks at mock-based tests, they can't answer:
"How do these components actually work together?"
// Mock test: What's the actual contract?
$repo->expects($this->once())
->method('save')
->with($this->isInstanceOf(Order::class));
// Questions:
// - Does save() validate the order?
// - Can save() throw exceptions?
// - What happens if I call save() twice with the same ID?
// - Is the order retrievable after save()?
The mock hides all this.
With a fake test:
// Fake test: The contract is clear
$repo = new InMemoryOrderRepository();
$repo->save($order);
$retrieved = $repo->findById($order->id);
$this->assertSame($order->id, $retrieved->id);
// The test shows:
// - save() makes the order retrievable
// - The order is stored by ID
// - The order is the same object (or equivalent)The fake documents the integration.
When you write fakes, new developers see:
final class InMemoryOrderRepository implements OrderRepositoryInterface
{
private array $orders = [];
public function save(Order $order): void
{
if (isset($this->orders[$order->id])) {
throw new DuplicateOrderException();
}
$this->orders[$order->id] = $order;
}
public function findById(string $id): ?Order
{
return $this->orders[$id] ?? null;
}
}What they learn:
- This is how a repository should behave
- Duplicate IDs throw exceptions
- Find returns null when not found
- The contract is simple and clear
This is documentation in code.
When the time comes to implement DoctrineOrderRepository, they already know what it should do.
Once a codebase is full of mocks, new developers:
- Copy the pattern (cargo cult)
- Add more mocks (technical debt compounds)
- Resist fakes ("that's not how we do it here")
- Never learn proper integration testing
The broken window (mocks) becomes the standard.
The fix: Start writing fakes. One service at a time. Show the alternative.
New developers will notice: "This test is easier to understand."
And slowly, the culture shifts.
Counter-argument:
You don't write a mock in one line either. Compare:
// Mock setup (for EVERY test)
$repo = $this->createMock(OrderRepositoryInterface::class);
$repo->expects($this->once())
->method('save')
->with($this->callback(fn($o) => $o->status === 'pending'));
$repo->expects($this->once())
->method('findById')
->willReturn($mockOrder);vs.
// Fake setup (written once, used everywhere)
$repo = new InMemoryOrderRepository();
$repo->save($order);
$result = $repo->findById($order->id);A fake is written once and reused in all tests.
A mock is reconfigured in every test.
Math:
- 1 Fake = 30 lines of code
- 10 Tests with mocks = 10 × 15 lines = 150 lines
- 10 Tests with fake = 30 + (10 × 3 lines) = 60 lines
Fakes are lower maintenance, not more effort.
Counter-argument:
No. Fakes are validated through contract tests of the interface.
abstract class CustomerRepositoryContractTest extends TestCase
{
abstract protected function createRepository(): CustomerRepositoryInterface;
public function testSaveAndFind(): void
{
$repo = $this->createRepository();
$customer = new Customer('cust-1', 'pm-1');
$repo->save($customer);
$found = $repo->findById('cust-1');
$this->assertNotNull($found);
$this->assertSame('cust-1', $found->id);
}
public function testFindReturnsNullWhenNotFound(): void
{
$repo = $this->createRepository();
$this->assertNull($repo->findById('nonexistent'));
}
}
// Doctrine implementation tests against the contract
final class DoctrineCustomerRepositoryTest extends CustomerRepositoryContractTest
{
protected function createRepository(): CustomerRepositoryInterface
{
return new DoctrineCustomerRepository($this->entityManager);
}
}
// InMemory fake tests against the same contract
final class InMemoryCustomerRepositoryTest extends CustomerRepositoryContractTest
{
protected function createRepository(): CustomerRepositoryInterface
{
return new InMemoryCustomerRepository();
}
}The contract guarantees that both implementations behave the same.
You're not testing "the fake", you're testing "the interface".
Counter-argument:
Mocks are even less realistic. A mock does nothing. It returns what you tell it.
$mock->method('findById')->willReturn($customer);This mock doesn't verify:
- Whether the ID exists
- Whether
save()was called before - Whether constraints are violated
- Whether race conditions can occur
A fake, however:
final class InMemoryOrderRepository implements OrderRepositoryInterface
{
public function save(Order $order): void
{
// Checks unique constraint
if (isset($this->orders[$order->id])) {
throw new DuplicateOrderException();
}
$this->orders[$order->id] = $order;
}
public function findById(string $id): ?Order
{
// Behaves like real DB: null if not found
return $this->orders[$id] ?? null;
}
}The fake simulates real behavior of the database. The mock simulates nothing.
Counter-argument:
Measurement doesn't lie.
// Mock test: ~0.015s per test
// Fake test: ~0.003s per testWhy? Because PHPUnit for each mock:
- Uses reflection
- Generates a proxy class
- Performs expectation matching
- Builds stack traces
A fake is simply normal PHP code. No magic. No overhead.
With 500 tests:
- Mocks: 7.5 seconds
- Fakes: 1.5 seconds
Fakes are faster.
Counter-argument:
This is the most dangerous misconception.
Decoupling means: "My code doesn't depend on implementation details."
But a mock tests exactly that:
$mock->expects($this->once())
->method('save')
->with($this->isInstanceOf(Order::class));This says: "I expect save() to be called exactly once."
This is not decoupling. This is maximum coupling to:
- The number of calls
- The order of calls
- The parameter types
A fake, however, only cares about the result:
$order = $service->execute('cust-123', $items);
$savedOrder = $repo->findById($order->id);
$this->assertTrue($savedOrder->isPaid());You test: "After execute(), the order is paid."
You don't test: "After execute(), save() was called twice."
Which test is more decoupled?
In hexagonal architecture, ports (interfaces) define behavior:
// Port = Promise
interface OrderRepositoryInterface
{
/** Saves an order. Throws exception on constraint violation. */
public function save(Order $order): void;
/** Finds order by ID. Null if not found. */
public function findById(string $id): ?Order;
}The interface is a contract: "If you save an order, you can find it later."
// Adapter 1: Doctrine
final class DoctrineOrderRepository implements OrderRepositoryInterface
{
public function save(Order $order): void
{
$this->entityManager->persist($order);
$this->entityManager->flush();
}
public function findById(string $id): ?Order
{
return $this->entityManager->find(Order::class, $id);
}
}
// Adapter 2: InMemory (Fake)
final class InMemoryOrderRepository implements OrderRepositoryInterface
{
private array $orders = [];
public function save(Order $order): void
{
$this->orders[$order->id] = $order;
}
public function findById(string $id): ?Order
{
return $this->orders[$id] ?? null;
}
}Both fulfill the same promise. The test should work for both.
$mock = $this->createMock(OrderRepositoryInterface::class);
$mock->method('findById')->willReturn(null);This mock says: "findById() returns null."
But: It doesn't say why. Did save() not work? Does the ID not exist? Is the database down?
A mock is not an adapter. It doesn't implement the port – it fakes it.
The ultimate question for any test:
If I run this test against the real database, does it stay green?
public function testCreatesOrder(): void
{
$repo = new InMemoryOrderRepository();
$service = new CreateOrderService($repo);
$order = $service->execute('cust-123', $items);
$saved = $repo->findById($order->id);
$this->assertNotNull($saved);
}Replace the fake with Doctrine:
$repo = new DoctrineOrderRepository($entityManager);Result: ✅ Test stays green.
public function testCreatesOrder(): void
{
$repo = $this->createMock(OrderRepositoryInterface::class);
$repo->expects($this->once())->method('save');
$service = new CreateOrderService($repo);
$order = $service->execute('cust-123', $items);
}Replace the mock with Doctrine:
$repo = new DoctrineOrderRepository($entityManager);Result: ❌ Test has no assertions anymore. It checks nothing.
There are exactly three legitimate use cases for mocks:
// We don't want to actually send emails
final class OrderConfirmationServiceTest extends TestCase
{
public function testSendsConfirmationEmail(): void
{
$mailer = $this->createMock(MailerInterface::class);
$mailer->expects($this->once())
->method('send')
->with($this->callback(fn($email) =>
$email->to === 'customer@example.com' &&
str_contains($email->subject, 'Order Confirmation')
));
$service = new OrderConfirmationService($mailer);
$service->sendConfirmation($order);
}
}Why OK? Because a fake here would be more complicated (would need to simulate SMTP) and because we're only testing the message, not the sending.
But better would be:
final class InMemoryMailer implements MailerInterface
{
private array $sent = [];
public function send(Email $email): void
{
$this->sent[] = $email;
}
public function getSent(): array
{
return $this->sent;
}
}And then:
$mailer = new InMemoryMailer();
$service->sendConfirmation($order);
$sent = $mailer->getSent();
$this->assertCount(1, $sent);
$this->assertSame('customer@example.com', $sent[0]->to);Even here: Fake > Mock.
When working with a codebase where refactoring is impossible:
// Legacy: direct DB calls, no interfaces
final class LegacyOrderService
{
public function createOrder($customerId): void
{
$db = Database::getInstance();
$db->query("INSERT INTO orders...");
}
}Here you must mock (or replace the global Database). But that's a code smell, not a test pattern.
When you want to test how often a method is called (e.g., caching):
public function testCachesCustomerLookup(): void
{
$repo = $this->createMock(CustomerRepositoryInterface::class);
$repo->expects($this->once()) // Only 1x, then cache
->method('findById')
->willReturn($customer);
$service = new CachedCustomerService($repo);
$service->getCustomer('cust-123'); // DB hit
$service->getCustomer('cust-123'); // Cache hit
}But even here:
final class InMemoryCustomerRepository implements CustomerRepositoryInterface
{
private int $findByIdCallCount = 0;
public function findById(string $id): ?Customer
{
$this->findByIdCallCount++;
return $this->customers[$id] ?? null;
}
public function getFindByIdCallCount(): int
{
return $this->findByIdCallCount;
}
}Fake with metrics is better than mock.
What am I testing? → Which test type? → Which tool?
Domain Layer (Pure Logic)
→ Unit Test
→ No test doubles needed
→ Instantiate directly
Application Layer (Use Cases)
→ Integration Test
→ Fakes for ports
→ In-memory implementations
Infrastructure Layer (Adapters)
→ Contract Test or Integration Test
→ Real systems (TestContainers, SQLite)
→ Only in CI: Mocks for 3rd-party APIs
Legacy Code / External Services with Side Effects
→ Workaround
→ Mocks
→ With comment "TODO: Build fake"
Rule of thumb:
- Domain: No doubles
- Application: Fakes
- Infrastructure: Real systems
- Mocks: Only when unavoidable
public function testCreatesOrderWithDiscount(): void
{
// ... Mock setup with 20 lines of expectations ...
$service = new CreateOrderService($orders, $customers, $discounts);
$order = $service->execute('cust-123', $items);
$this->assertSame(89.99, $order->total);
}
// Error:
// Failed asserting that 99.99 is identical to 89.99With Mocks:
You want to debug why the discount wasn't applied. What do you do?
- Set a breakpoint? ❌ Can't in the mock - it's generated proxy code
- Check mock return value? ❌ The mock returns what you told it - not what happened
- Look at expectations? ❌ They only tell you if methods were called - not why the result is wrong
var_dump()in the mock? ❌ You can't edit the generated code
You're in the dark. The only option: Read the code and guess.
public function testCreatesOrderWithDiscount(): void
{
$orders = new InMemoryOrderRepository();
$customers = new InMemoryCustomerRepository();
$discounts = new InMemoryDiscountRepository();
// Setup
$customer = new Customer('cust-123', CustomerTier::GOLD);
$customers->save($customer);
$discount = new Discount('GOLD10', 0.10);
$discounts->save($discount);
$service = new CreateOrderService($orders, $customers, $discounts);
$order = $service->execute('cust-123', $items);
$this->assertSame(89.99, $order->total);
}Debugging Workflow:
- Set breakpoint in fake:
final class InMemoryDiscountRepository implements DiscountRepositoryInterface
{
public function findForCustomer(Customer $customer): ?Discount
{
// Breakpoint here! ← XDebug stops here
foreach ($this->discounts as $discount) {
if ($discount->appliesToTier($customer->tier)) {
return $discount; // ← You see: not returned!
}
}
return null;
}
}-
Inspect variables:
$customer->tier=CustomerTier::GOLD✓$this->discounts=[Discount('GOLD10', 0.10)]✓$discount->appliesToTier()=false❌ ← AHA!
-
Root cause found:
// The bug was here:
final class Discount
{
public function appliesToTier(CustomerTier $tier): bool
{
return $this->tier === $tier; // ← String comparison instead of enum comparison!
}
}Time to debug:
- With mock: 30 minutes reading code, guessing, testing
- With fake: 2 minutes stepping through with XDebug
public function testConcurrentOrderCreation(): void
{
$orders = new InMemoryOrderRepository();
$inventory = new InMemoryInventoryRepository();
$inventory->setStock('SKU-123', 1); // Only 1 in stock
$service = new CreateOrderService($orders, $inventory);
// Two simultaneous orders for the same product
$order1 = $service->execute('cust-1', [['sku' => 'SKU-123', 'qty' => 1]]);
$this->expectException(InsufficientStockException::class);
$order2 = $service->execute('cust-2', [['sku' => 'SKU-123', 'qty' => 1]]);
}With mock: The test is green because the mock simply returns what you want. You're not testing behavior.
With fake:
final class InMemoryInventoryRepository implements InventoryRepositoryInterface
{
private array $stock = [];
public function reserveStock(string $sku, int $quantity): void
{
// Breakpoint here - you see the state!
$available = $this->stock[$sku] ?? 0;
if ($available < $quantity) {
throw new InsufficientStockException();
}
$this->stock[$sku] -= $quantity;
// ← After order1: stock = 0
// ← After order2: Exception!
}
}You can:
- Set breakpoints
- See
$this->stockduring execution - Check state after each step
- Understand why it works or doesn't
A mock is opaque. It's a black box that simulates answers.
$mock = $this->createMock(InventoryRepositoryInterface::class);
$mock->method('reserveStock')->willReturn(null);Questions you CANNOT answer:
- What happens in the mock when
reserveStock()is called? - How many times was it called?
- With which concrete values?
- What would have happened with a real DB?
You're testing blind. You hope your expectations are correct.
A fake is normal PHP code. No magic, no codegen.
final class InMemoryInventoryRepository implements InventoryRepositoryInterface
{
private array $stock = [];
private array $operations = []; // ← Audit log for tests
public function reserveStock(string $sku, int $quantity): void
{
$this->operations[] = ['reserve', $sku, $quantity]; // ← You see everything
$available = $this->stock[$sku] ?? 0;
if ($available < $quantity) {
throw new InsufficientStockException();
}
$this->stock[$sku] -= $quantity;
}
public function getOperations(): array
{
return $this->operations; // ← For debugging and assertions
}
}In the test:
$inventory = new InMemoryInventoryRepository();
// ... test runs ...
// Debugging helper:
dump($inventory->getOperations());
// [
// ['reserve', 'SKU-123', 1],
// ['reserve', 'SKU-456', 2],
// ]
dump($inventory->getStock('SKU-123')); // 4 (instead of 5)You see the complete state. Always. In real-time.
Typical debug workflow with fake:
- Test fails
- Set breakpoint in Application Service
- Step Into → lands in Fake Repository
- Step through fake code like real code
- Inspect state (
$this->orders,$this->stock, etc.) - Bug found
With mock:
- Test fails
- Set breakpoint in Application Service
- Step Into → lands in PHPUnit Mock Proxy
- ❌ Unreadable Generated Code
- Step Out, guess, repeat
- Give up frustrated, read code
A test should help find bugs – not hide them.
Mocks hide bugs behind opaque magic.
Fakes show you exactly what's happening in the system.
When your test fails with a mock, you're as smart as before.
When your test fails with a fake, you can debug like any other code.
Developer experience is part of code quality.
And mocks have the worst DX of all testing tools.
The Hidden Cost Factor: Maintenance Over Time
Initial development time is similar for mocks and fakes. The difference shows in maintenance:
Typical events in a project year:
Refactorings (improving code)
→ Mock tests: Often break
→ Fake tests: Stay green
Bug fixes (correcting logic)
→ Mock tests: Can stay green despite bug
→ Fake tests: Turn red on real bugs
Feature extensions (new methods on interface)
→ Mock tests: All must be adjusted
→ Fake tests: Extend fake once, tests run
Onboarding new developers
→ Mock tests: Hard to understand (expectations, matchers)
→ Fake tests: Normal code, easy to debug
The true costs of mocks are not in development, but in the next 2-3 years.
Fakes are the right choice in 80-90% of cases in the Application Layer. This isn't opinion – it follows from established engineering principles:
1. Liskov Substitution Principle (LSP)
"Objects of a supertype should be replaceable with objects of a subtype without altering the correctness of the program."
- A Fake is a real adapter that implements the port interface
- It can substitute the production adapter (e.g., Doctrine) without changing behavior
- A Mock is NOT a real adapter – it's a test stub that only simulates calls
- Violating LSP: Mocks pretend to implement the interface but don't honor the contract
2. Open/Closed Principle (OCP)
"Software entities should be open for extension, but closed for modification."
- With Fakes: Refactor the service → tests stay green (closed for modification)
- With Mocks: Refactor the service → tests break (must modify tests)
- Tests with mocks are NOT closed for modification – they break on implementation changes
3. Behavior Testing > Implementation Testing
// Mock tests implementation:
$mock->expects($this->once())->method('save');
// "Did you call save()?"
// Fake tests behavior:
$this->assertNotNull($repo->findById($order->id));
// "Is the order actually saved?"The goal of testing is to verify what the system does, not how it does it.
Not all situations are black and white. There are legitimate cases where other approaches make sense:
When refactoring is impossible or too risky:
// Global state, tight coupling, no interfaces
final class LegacyOrderService
{
public function create($customerId): void
{
$db = Database::getInstance();
$db->query("INSERT...");
}
}Verdict: Mock or wrap – but acknowledge it as technical debt, not best practice.
interface PaymentGatewayInterface
{
public function charge(string $token, float $amount): Payment;
}Options:
- Best: InMemory fake that simulates success/failure scenarios
- OK: Mock in unit tests, real API in integration tests
- Worst: Only mocks, no integration testing
Rule: If you can build a fake in 30 minutes, do it. If the protocol is AWS-level complex, mock temporarily.
// Simulating Kafka, RabbitMQ, Elasticsearch in-memory might be overkill
interface MessageBusInterface
{
public function publish(string $topic, Message $msg): void;
public function subscribe(string $topic, callable $handler): void;
public function acknowledge(string $messageId): void;
}Pragmatic approach:
- Application tests: Simple InMemory fake (stores messages in array)
- Integration tests: TestContainers with real Kafka
- NOT: Mock everywhere with no integration tests
"NEVER use mocks" → Wrong
- Some situations genuinely need mocks (legacy, external services)
- Being dogmatic makes you inflexible
"ALWAYS use mocks" → Wrong
- Most situations DON'T need mocks
- This creates fragile, unmaintainable test suites
The balanced position:
Use the simplest tool that tests behavior. Prefer fakes in Application Layer, use real systems in Infrastructure Layer, mock only when unavoidable.
┌─ Am I testing Domain Logic?
│ └─ No test doubles needed (pure functions/objects)
│
┌─ Am I testing an Application Service (Use Case)?
│ ├─ Does it depend on repositories/ports?
│ │ └─ Use IN-MEMORY FAKES
│ └─ Does it depend on external APIs?
│ ├─ Can I build a fake in <30min? → FAKE
│ └─ Too complex? → MOCK (with integration tests later)
│
┌─ Am I testing Infrastructure (Adapter)?
│ ├─ Database? → REAL DB (SQLite/TestContainers)
│ ├─ File system? → TEMP DIRECTORY
│ ├─ HTTP API? → MOCK SERVER (WireMock) or REAL API (CI only)
│ └─ Message Queue? → TESTCONTAINERS
│
└─ Am I testing Legacy Code?
└─ Mock carefully, plan refactoring to remove mocks later
Software engineering isn't about rigid rules – it's about understanding why certain approaches work better.
The "why" behind fakes:
- They honor the interface contract (LSP)
- They're resilient to refactoring (OCP)
- They test behavior, not implementation
- They're debuggable with standard tools
- They document expected behavior
The "why" behind mocks (when appropriate):
- They avoid side effects (sending real emails)
- They isolate from unavailable systems (3rd party APIs)
- They're a temporary workaround for bad architecture
Know the "why" and you'll make the right decision.
Mocks feel fast. They're not.
Mocks feel simple. They're not.
Mocks feel professional. They're not.
Mocks are technical debt in test form.
Every mock is a bet:
"I bet this implementation will never change."
You lose this bet. Always.
Fakes are boring code. But they work.
And in 2 years, when you refactor and the tests stay green,
you'll be glad you wrote boring code.
A green fake test is a promise.
A green mock test is a lie.
Version 2.0 – For discussions about test strategy in hexagonal architectures
© dazz