Introduction
For experienced developers, writing tests is no longer a question of if, but how well. Yet, too often we encounter codebases where writing meaningful, isolated unit tests feels like a chore. Dependencies are hardcoded, side effects abound, and every test becomes a fragile house of cards.
This article focuses on testability as a design goal, and how leveraging Dependency Injection (DI) and Mocking strategies in PHPUnit can radically improve your code’s architecture and confidence levels.
The Real Problem: Code That Fights Back
Consider this scenario: you’ve got a service that sends invoices and logs the operation.
class InvoiceService {
public function sendInvoice(Invoice $invoice) {
$logger = new Logger(); // Uh-oh
$logger->info("Sending invoice...");
// send invoice logic
}
}
This is textbook procedural thinking hidden behind a class. What happens when you want to test whether logging occurred? Or simulate a failure during invoice sending?
You can't. The dependency is buried.
Enter Dependency Injection: Making Code Play Nice
Dependency Injection is the simple act of giving your class the things it needs rather than having it fetch them itself. It flips the control from the object to the caller—a foundational concept for clean, testable code.
Refactor it:
class InvoiceService {
public function __construct(private LoggerInterface $logger) {}
public function sendInvoice(Invoice $invoice) {
$this->logger->info("Sending invoice...");
// send invoice logic
}
}
You just unlocked testability, configurability, and reusability—in one stroke.
PHPUnit: Mocking with Intention
With DI in place, we can now mock the dependency:
public function testLogsInvoiceSend(): void
{
$logger = $this->createMock(LoggerInterface::class);
$logger->expects($this->once())
->method('info')
->with($this->stringContains('Sending invoice'));
$service = new InvoiceService($logger);
$service->sendInvoice(new Invoice());
}
This test does three critical things:
- Verifies interaction, not just outcome.
- Isolates the unit under test (InvoiceService).
- Makes failures local and clear.
Choosing What to Mock (and What Not To)
Mocking can be overused, and mocking everything is often a sign of missing boundaries. Use this guide:
What to Mock | Why |
---|---|
External APIs | Avoid flaky tests / simulate failures |
Infrastructure (e.g. FileSystem, Email) | Test logic, not delivery |
Time-dependent components | Avoid slow and non-deterministic behavior |
Pure value objects | ❌ Do not mock; use real instances |
Domain logic | ❌ Avoid mocking unless isolated behavior |
Strategy: Prefer Interfaces and Final Classes
Always depend on abstractions:
interface PaymentProcessor {
public function charge(float $amount): bool;
}
In production, use Stripe:
class StripeProcessor implements PaymentProcessor {
public function charge(float $amount): bool {
// real charge logic
}
}
In tests:
$processor = $this->createMock(PaymentProcessor::class);
$processor->method('charge')->willReturn(true);
This cleanly separates what you do (charge the card) from how it happens.
Side Note: Avoid Service Locators
class BadService {
public function __construct(private ContainerInterface $container) {}
public function doStuff() {
$logger = $this->container->get('logger'); // hidden dependency
}
}
This is DI in disguise. Don’t hide your collaborators—make them explicit. Testability thrives on transparency.
In Summary: PHPUnit Best Practices for Testable Design
- Embrace constructor injection as the default.
- Favor interfaces to keep mocks lightweight and flexible.
- Mock behavior, not internal state or return values.
- Avoid static state and global dependencies.
- Use fakes or stubs for value objects and simple services.
Next in the series: We’ll tackle higher-level tests with Behat, where the conversation shifts from unit behavior to user-facing outcomes, and how to architect your code for clean behavioral testing.