Recently, on a Symfony project for the agency where I work, I spent a good half hour on a test that refused to be written. The method I wanted to cover was private, and I ended up pulling out PHP reflection to reach it. The test went green, and I moved on to something else.
Except I came back to it the next day. That discomfort while writing the test was actually telling me something about how my code was split up.
A test is a caller like any other
A test and a controller structurally do the same thing. Both instantiate the module, call it with some data, look at what comes out. They go through the same front door.
So what the test needs to know, the other callers need to know too. If the test asks for more than the interface gives, the interface is too narrow.
That is what makes the principle usable day to day. If I think « this test is painful to write », there is something to look for in the interface of the code.
Four ways to go around the interface
Here are the four workarounds I run into most often.
1. Reflection on a private method
$m = new \ReflectionMethod(PriceCalculator::class, 'applyTierRules');
$m->setAccessible(true);
$result = $m->invoke($calculator, $cart, $tier);
What matters here is the urge itself. You never feel the need to test a private method that is a real implementation detail, so if this one attracts a test, it carries standalone logic with a value of its own.
The right move: extract a TierRuleEngine with its own interface. PriceCalculator then becomes one more caller.
2. Mocking an internal collaborator
$mailer = $this->createMock(MailerInterface::class);
$mailer->expects($this->once())
->method('send')
->with($this->callback(fn(Email $e) =>
$e->getSubject() === 'Order #42 confirmed'
&& str_contains($e->getHtmlBody(), '149.90 €')
));
$notifier->notifyOrderConfirmed($order);
The point to pay attention to is what the assertion covers: the mail that goes out in the middle of the method. The test ends up coupled to the presence of a MailerInterface in the implementation. Replace the mailer with a message bus, the behaviour stays the same and the test breaks.
Here, building the mail deserves its own class.
final class OrderConfirmationMessage
{
public function build(Order $o): Email { /* … */ }
}
The content test now goes through that interface. There is still a test for the notifier, with a single mock and a single assertion: it sends.
3. Looking straight at the database
$service->archiveExpiredCarts();
$rows = $this->connection->fetchAllAssociative(
'SELECT id, archived_at FROM cart WHERE archived_at IS NOT NULL'
);
$this->assertCount(3, $rows);
If you look at the second statement, the test goes looking for the information from behind, in SQL, because the method returns void. The interface says nothing about what happened.
The fix is in the signature:
public function archiveExpiredCarts(): ArchiveReport;
And here something interesting happens. What the test needed, the controller needed too: for logging, for showing a flash message, for deciding on a retry.
4. The method added for the tests
/** @internal Used only by the tests */
public function getPendingOperations(): array { return $this->pending; }
This method widens the public interface without serving a single real caller.
Two possible readings. Either the exposed state really belongs to the domain, and we are back to case 3. Or the module both accumulates and executes, and those two responsibilities need to be separated.
The testability trap
Watch out for the false good idea: making methods public « for testability ». Every method turned public widens the interface of the module.
The module becomes testable by becoming shallow. So you lose exactly what made it worthwhile.
Testability comes from moving the boundary.
Internal boundaries are still fine
The principle should not be over-applied though. A deep module can perfectly well be made of small injectable pieces, tested separately, each one through its own interface.
The difference is subtle. An internal boundary is a test calling a sub-module through the interface of that sub-module, so it is simply a smaller module. The symptom shows up when the test calls the outer module, then goes looking elsewhere to find out what happened.
So breaking things down stays healthy. The warning sign to be aware of is when we observe through a path other than the interface.
The three repair moves
Faced with the urge to work around, only one of these three moves is the right one:
- Extract: what you want to test is a module in disguise, it needs its own interface (cases 1 and 2).
- Return instead of mutate: the missing observability belongs to the domain, its place is in the return value (case 3).
- Move the boundary: the module has the right material with the wrong scope.
Three reflexes remain to be avoided: reflection, the @internal getter, the SQL assertion.
And when you work with an agent
This part is worth the detour if you have Claude Code or another agent working on your codebase. Tests that only go through the interfaces stay valid even after the inside of the code has been rewritten.
You can ask the agent to rewrite the inside of a module while keeping the suite green, and green then means something. As soon as the tests grab the internals, the agent sees forty red tests without being able to tell broken behaviour from a moved collaborator. It ends up fixing the tests to make them pass, and green becomes noise again.
I do not know whether this grid covers every case, and I am not a testing pro yet. But from now on I ask myself the question when a test is painful, and I look at what it says about the system we are trying to test.
And you, have you ever reworked how a class was split up because of a test that was painful to write?