Tests you can still read six months later
You open a test file you wrote six months ago. It takes ten minutes to figure out what the test actually verifies. The mock setup is dense, the expectations are chained, the matchers are generic. The intent is buried under the mechanics.
The problem
Mock libraries like Mockery or the PHPUnit built-in mocks are powerful. You can express almost any interaction with them. But power is not the same as clarity, and over time, tests built on heavy mock setups become write-only artifacts.
The pattern is familiar. A test starts simple: one mock, one expectation. Then a collaborator changes signature, so you add a matcher. Then a new branch has to be covered, so you add a second expectation. Then someone passes Mockery::any() to silence a failure no one wants to debug right now. After a few months the test passes, but no one remembers what it proves.
The issue is not the tools. It is how easy they make it to write tests that describe interactions rather than outcomes. The Detroit (or "Classical") school of TDD, going back to Kent Beck's original framing, leans the opposite way: use real collaborators when you can, fake them when you must, and assert on the resulting state rather than on the calls that produced it.
Pre-built fakes
Phexium ships hand-written Fakes for the dependencies that get mocked most often: FakeLogger, FakeDispatcher, FakeCommandBus, FakeQueryBus, FakeSession, FakeTransaction. Each Fake implements the same port as the production adapter, records what it received, and exposes assertion methods that read like sentences.
Here is a handler test that uses FakeDispatcher and FakeLogger:
use Tests\Phexium\Fake\Plugin\Dispatcher\FakeDispatcher;
use Tests\Phexium\Fake\Plugin\Logger\Logger as FakeLogger;
it('dispatches a BookCreated event after persistence', function (): void {
$dispatcher = new FakeDispatcher();
$logger = new FakeLogger();
$handler = new CreateBookHandler($repository, $dispatcher, $logger);
$handler(new CreateBookCommand(
title: 'Clean Code',
author: 'Robert C. Martin',
isbn: '9780134494166',
));
expect($dispatcher->wasDispatched(BookCreatedEvent::class))->toBeTrue();
expect($logger->hasLog('info', 'Book created'))->toBeTrue();
});
The assertion wasDispatched(BookCreatedEvent::class) reads as a question with a yes/no answer. There is no expectation setup, no matcher chaining, no closure-based argument inspection. If you need finer-grained checks, the Fakes provide them: getLastDispatchedEvent(), getDispatchCount(), hasLogContaining(), hasLogWithContext(). Each method does one thing and answers one question.
Compare to the equivalent Mockery setup:
$dispatcher = Mockery::mock(DispatcherInterface::class);
$dispatcher->shouldReceive('dispatch')
->once()
->with(Mockery::type(BookCreatedEvent::class))
->andReturnUsing(fn ($event) => $event);
$logger = Mockery::mock(LoggerInterface::class);
$logger->shouldReceive('info')
->once()
->with('Book created', Mockery::any());
Both versions verify roughly the same thing. The Fake version reads in one pass. The Mockery version forces you to mentally execute the expectation language before you understand the intent.
Object mothers for entities
Mocks are only half the story. The other source of illegible tests is fixture setup. ORM-based factories couple tests to the database layer, force a transactional setup, and produce verbose chains that hide what makes the test case interesting.
The Object Mother pattern, named by Martin Fowler, replaces those factories with static classes that return fully constructed domain entities. Phexium uses them for every aggregate in the demo modules:
final class BookMother
{
public static function available(int $id = 1): Book
{
return self::create(id: $id, status: BookStatus::Available);
}
public static function borrowed(int $id = 1): Book
{
return self::create(id: $id, status: BookStatus::Borrowed);
}
public static function create(
int $id = 1,
string $title = 'Clean Code',
string $author = 'Robert C. Martin',
string $isbn = '9780134494166',
BookStatus $status = BookStatus::Available,
): Book {
return new Book(
new TimestampId($id),
Title::fromString($title),
Author::fromString($author),
ISBN::fromString($isbn),
$status,
);
}
}
The call sites stay short and self-explanatory:
it('cannot be borrowed when already borrowed', function (): void {
$book = BookMother::borrowed();
expect(fn () => $book->borrow())
->toThrow(BookNotAvailableException::class);
});
The test reads as a property of the domain: a borrowed book cannot be borrowed. No database, no transaction, no migration, no ORM. The Mother runs in microseconds and stays in the unit suite, not the integration suite.
Trade-offs
This approach has real costs.
You write the Fakes yourself. They are not generated. Each new port that you want to fake means another small class to maintain. When a port contract changes, you update the Fake.
You commit to keeping the Mother and the production constructor compatible. If you add a required parameter to Book, the Mother breaks until you update its default.
Fakes do not catch interaction bugs that strict mocks catch by default. If your code calls dispatch() twice when it should call it once, wasDispatched() still returns true. You have to ask getDispatchCount() explicitly. The Fake makes you write the question; it does not assume what you wanted to verify.
These costs are paid once per Fake or per Mother, not once per test. In practice, the maintenance burden is far lower than the cumulative cost of writing and re-reading mock-heavy tests.
What this enables
When tests read as sentences, you stop avoiding them. You add a new edge case in the time it takes to write one it() block. You read a failing test in CI without opening the file. You refactor production code knowing that the test names and assertions describe what the system is supposed to do, not how the previous implementation happened to do it.
The deeper effect is cultural: the Classical school of TDD asks for state-based verification because state is what users observe. Phexium's Fakes and Mothers exist to make that style the default, without imposing extra discipline on the team.