When Tests Only Mirror Implementation Details
A test that turns red on every implementation change without the observable behavior having changed is not a test, it is an obstacle. Overspecified mocks, testing your own classes through mocks, spy abuse, and expectation overload are the most common causes of brittle PHPUnit suites that create more maintenance effort than confidence.
Table of Contents
- 1. The Core Problem: Tests That Describe the Implementation
- 2. Overspecified Mocks: Too Many Expectations
- 3. Mock-What-You-Own: Never Mock Classes You Do Not Own
- 4. Spy Abuse: expects() Instead of Result Assertions
- 5. Mocking Concrete Classes: The Seam Problem
- 6. When Mocks Are Genuinely Useful
- 7. Anti-Pattern vs. Recommended Pattern Compared
- 8. Summary
- 9. FAQ
1. The Core Problem: Tests That Describe the Implementation
A good test checks the observable behavior of a piece of software: what is returned, what is thrown, what state does the system have afterward? A bad test checks how the implementation works internally: in what order internal methods are called, how often a particular dependency is consulted, which internal intermediate values are used. The difference between these two kinds of tests is the difference between a test that helps with refactoring and a test that prevents refactoring.
The problem almost always arises from mocks. Mocks are the most powerful tool in the PHPUnit toolbox, and the most frequently abused one. When a test has five expects($this->once()) calls, it is not a test of behavior but a script of the implementation. When a class is mocked that does not perform any IO access and has no network communication, the mock is a hint that the class structure should be reworked. The following anti-patterns show up in PHP projects of every size, from small Magento modules to large e-commerce platforms.
2. Overspecified Mocks: Too Many Expectations
The most common mocking anti-pattern is overspecification: a test that sets its own expectation for every internal interaction, even when that interaction is not part of the public contract. A classic example is a test for a method that internally calls two different repository methods. The test sets not only the willReturn values but also expects($this->once()) for both, even though only the result of the method is meant to be tested.
The result: when the implementation is refactored and one of the repository methods is replaced by another, without the behavior of the tested method changing, the test turns red. The test did not protect any real behavior, it fixed the internal implementation in place. The correct pattern: expects($this->any()), or forgo expectation counts entirely and only set willReturn. Counts only when the exact number of calls represents a business rule, for example that an expensive API call happens exactly once thanks to caching.
<?php
// ANTI-PATTERN: Overspecified Mock, tests internal call sequence, not behavior
class OrderServiceAntiPatternTest extends TestCase
{
public function testCreatesOrderAntiPattern(): void
{
$inventoryMock = $this->createMock(InventoryRepositoryInterface::class);
// BAD: These expectations tie the test to internal implementation details
$inventoryMock->expects($this->once()) // why exactly once? Is that a business rule?
->method('checkAvailability')
->willReturn(true);
$inventoryMock->expects($this->once()) // internal impl detail, not behavior
->method('reserveStock');
$orderMock = $this->createMock(OrderRepositoryInterface::class);
$orderMock->expects($this->once()) // we care that the order is saved, but not how many times
->method('save')
->willReturn($this->createMock(OrderInterface::class));
$service = new OrderService($inventoryMock, $orderMock);
$service->createOrder(['sku' => 'PROD-001', 'qty' => 2]);
}
}
// GOOD PATTERN: Test behavior, not internal call counts
class OrderServiceTest extends TestCase
{
public function testCreatesOrderAndReturnsOrderId(): void
{
$inventoryMock = $this->createMock(InventoryRepositoryInterface::class);
// Only configure what we need the mock to return, no call count assertions
$inventoryMock->method('checkAvailability')->willReturn(true);
$inventoryMock->method('reserveStock'); // returns void, no assertion needed
$savedOrder = $this->createMock(OrderInterface::class);
$savedOrder->method('getId')->willReturn(1001);
$orderMock = $this->createMock(OrderRepositoryInterface::class);
$orderMock->method('save')->willReturn($savedOrder);
$service = new OrderService($inventoryMock, $orderMock);
$result = $service->createOrder(['sku' => 'PROD-001', 'qty' => 2]);
// Test the OUTCOME: what does the caller of createOrder care about?
$this->assertSame(1001, $result->getId());
}
public function testThrowsWhenItemNotAvailable(): void
{
$inventoryMock = $this->createMock(InventoryRepositoryInterface::class);
$inventoryMock->method('checkAvailability')->willReturn(false);
$orderMock = $this->createMock(OrderRepositoryInterface::class);
$orderMock->expects($this->never())->method('save'); // THIS is a business rule expectation
$service = new OrderService($inventoryMock, $orderMock);
$this->expectException(OutOfStockException::class);
$service->createOrder(['sku' => 'PROD-001', 'qty' => 2]);
}
}
3. Mock-What-You-Own: Never Mock Foreign Classes Directly
A fundamental rule of test design states: mock only what you own. This means: foreign classes, such as libraries, framework classes, or vendor packages, should not be mocked directly. Instead, you write your own wrapper or adapter that encapsulates the foreign class, and you mock that adapter. That sounds like extra effort, but it has decisive advantages: when the foreign library changes its internal API, only the adapter test (or the adapter itself) breaks, not every test that directly mocked the foreign class.
In Magento projects this anti-pattern is often seen when mocking Guzzle classes (createMock(Client::class)), Doctrine classes, or Symfony components. The problem: a mock of Client::class has no knowledge of the real Guzzle behavior, the mock allows calls that never existed in the real client. If Guzzle renames a method and your own code calls that method, the real code fails, but the test with the mock stays green. That is the worst property a test can have: false green.
<?php
// ANTI-PATTERN: Directly mocking a third-party class
class ShipmentServiceAntiPatternTest extends TestCase
{
public function testCreatesShipmentLabelAntiPattern(): void
{
// BAD: Mocking a Guzzle class directly couples tests to Guzzle internals
$guzzleMock = $this->createMock(\GuzzleHttp\Client::class);
$guzzleMock->method('post')->willReturn(
new \GuzzleHttp\Psr7\Response(200, [], '{"label_url": "https://..."}')
);
// If Guzzle renames 'post' to 'request', this test stays green but production breaks
$service = new ShipmentService($guzzleMock);
$label = $service->createLabel('DE', 'AT', 1.5);
$this->assertNotEmpty($label->getUrl());
}
}
// GOOD PATTERN: Own adapter wraps the third-party class, only mock your own interface
interface ShippingHttpClientInterface
{
/** @param array<mixed> $payload */
public function post(string $endpoint, array $payload): array;
}
class ShipmentServiceTest extends TestCase
{
public function testCreatesShipmentLabel(): void
{
// GOOD: Mocking our own interface, we control its contract
$httpMock = $this->createMock(ShippingHttpClientInterface::class);
$httpMock->method('post')->willReturn(['label_url' => 'https://carrier.example.com/label/123']);
$service = new ShipmentService($httpMock);
$label = $service->createLabel('DE', 'AT', 1.5);
$this->assertStringContainsString('carrier.example.com', $label->getUrl());
}
}
4. Spy Abuse: expects() Instead of Result Assertions
Another widespread anti-pattern is using mocks as spies to test side effects instead of return values. The problem shows up when a test has expects($this->once())->method('log') on a logger mock instead of checking whether the system is in the correct state after the call. Logger calls, cache invalidations, and event dispatches are internal details, whether an error log is produced is rarely more important than what the caller receives as a result.
When testing side effects really does reflect a business requirement, for example that an audit log entry must be written on certain actions, a spy is legitimate. But the expectation should describe the outcome of the log, not merely the fact that it was called: expects($this->once())->method('log')->with($this->stringContains('order.created')) is better than just expects($this->once())->method('log'). Even better: implement your own AuditLog fake that stores entries in an array, and assert directly on the content of that array.
<?php
// ANTI-PATTERN: Testing side effects via spy instead of testing behavior
class PaymentServiceAntiPatternTest extends TestCase
{
public function testLogsPaymentAttemptAntiPattern(): void
{
$loggerMock = $this->createMock(\Psr\Log\LoggerInterface::class);
// BAD: We care that log() is called, not what the service actually does
$loggerMock->expects($this->once())->method('info');
$loggerMock->expects($this->never())->method('error');
$gateway = $this->createMock(PaymentGatewayInterface::class);
$gateway->method('charge')->willReturn(['status' => 'success', 'transaction_id' => 'TXN-99']);
$service = new PaymentService($gateway, $loggerMock);
$service->processPayment(99.00, 'card_token_123');
// Missing: assertion on WHAT the service returned or what state changed
}
}
// GOOD PATTERN: Test the outcome; use a simple fake for side-effect verification
final class InMemoryAuditLog implements AuditLogInterface
{
private array $entries = [];
public function record(string $event, array $context = []): void
{
$this->entries[] = ['event' => $event, 'context' => $context];
}
public function hasEntry(string $event): bool
{
return in_array($event, array_column($this->entries, 'event'), true);
}
}
class PaymentServiceTest extends TestCase
{
public function testProcessesPaymentSuccessfully(): void
{
$gateway = $this->createMock(PaymentGatewayInterface::class);
$gateway->method('charge')->willReturn(['status' => 'success', 'transaction_id' => 'TXN-99']);
$auditLog = new InMemoryAuditLog();
$service = new PaymentService($gateway, $auditLog);
$result = $service->processPayment(99.00, 'card_token_123');
// Test the RETURN VALUE, the primary behavior
$this->assertTrue($result->isSuccessful());
$this->assertSame('TXN-99', $result->getTransactionId());
// Only check side effect if it's a business requirement
$this->assertTrue($auditLog->hasEntry('payment.processed'));
}
}
5. Mocking Concrete Classes: The Seam Problem
PHPUnit makes it possible to mock concrete classes, createMock(ConcreteClass::class) creates a subclass that overrides all methods and whose return values can be configured. That sounds convenient, but it is problematic: mocking a concrete class is a signal that no clean abstraction exists. If code relies directly on new ConcreteClass() or on a concrete class through the constructor, with no interface in between, the mock is a patch over an architecture problem.
The deeper problem: when you mock a concrete class, you are implicitly mocking an interface that only exists in the imagination of the test. As soon as the concrete class gains new methods or renames existing ones, the test does not see it, the mock was built against the old state. The correct approach: extract an interface, have the concrete class implement the interface, and have the code accept the interface through the constructor. Tests mock the interface. That makes the dependency explicit and the test robust against implementation changes in the concrete class.
6. When Mocks Are Genuinely Useful
Mocks are the right tool in exactly three situations. First, when a dependency performs IO operations (network, filesystem, database) and those IO operations should not take place during the test. Second, when the behavior of the dependency under certain error conditions needs to be tested, conditions that are hard to provoke in the real implementation. Third, when an interaction with a dependency itself represents a business rule, for example that an audit log entry must be written, or that an expensive service is not called more than once thanks to caching.
In every other case, fakes (simple, self-written implementations of the interface) or real objects are the better choice. An InMemoryRepository that stores data in an array is more maintainable and more expressive than a mock with twenty willReturn configurations. It behaves like a real repository, has no false green, and can be reused across many tests. This is the often-overlooked third option between "real database" and "mock": the in-memory fake as a complete but lightweight implementation.
7. Anti-Pattern vs. Recommended Pattern Compared
The most common mocking anti-patterns and their recommended alternatives show the same underlying pattern: the anti-pattern tests how the code works. The recommended pattern tests what the code does.
| Anti-Pattern | Problem | Recommended Pattern | Benefit |
|---|---|---|---|
| expects(once) everywhere | Fixes implementation details in place | method() without a count | Refactoring without breaking tests |
| Mocking foreign classes | False green on API changes | Own adapter + mock of the adapter | Tests stay stable across library updates |
| Spy instead of result | Tests how, not what | Assert on the return value | Test checks actual behavior |
| Mocking a concrete class | No real interface, blind mock | Extract interface, mock the interface | Explicit dependency, more robust test |
| 20 willReturn configurations | Mock is more complex than the real impl. | Implement an in-memory fake | Simpler, reusable, no false green |
The most common root of all mocking anti-patterns is the absence of a clear separation between behavior and implementation. Anyone who asks, when writing a test, "What should this method do?" instead of "How does this method do it?" automatically writes better tests, with fewer mocks, less overspecification, and better resilience against refactoring.
8. Summary
Mocking anti-patterns always arise when tests describe the implementation instead of the behavior. Overspecified mocks with expects(once) on every method fix the internal call structure in place and break on every refactoring. Mocking foreign classes produces false-green tests because mock interfaces are not kept in sync with real library interfaces. Spy abuse tests side effects instead of return values and produces tests that only answer questions about the inside of the method. Mocking concrete classes is a symptom of missing interface abstractions.
The solution is a consistent focus on observable behavior: what does the method return, which exception does it throw, what state does the system have afterward? Configure mocks without call-count expectations when there is no business reason for the exact number. Write your own adapters in front of foreign libraries. Use in-memory fakes instead of overly complex mocks with twenty willReturn configurations. These principles turn PHPUnit test suites into a tool that enables refactoring, instead of a tool that prevents it.
Mocking Anti-Patterns — The Essentials at a Glance
Behavior, not implementation
Tests check return values, exceptions, and state changes, not the internal call order or method counts.
Mock only your own interfaces
Encapsulate foreign classes (Guzzle, Doctrine, Symfony) behind your own adapters. Mock only your own adapter, no false green on library updates.
In-memory fakes instead of mocks
Simple interface implementations with array storage are more maintainable than complex mocks with many willReturn configurations.
expects(once) only for business rules
Call-count expectations only when the exact number is a business rule (caching, audit log). Otherwise method() without a count.