PHPUnit — Green Tests Hide Gateway Timing Bugs
All PHPUnit tests pass but staging payments time out? Mock returned instantly — real gateway delays up to 30s.
- PHPUnit verifies individual units of PHP code in isolation, not your full stack.
- Mocks replace real dependencies (DB, API) to test logic without side effects.
- Data providers run one test method with many input sets — kills duplication.
- Code coverage tells you what's exercised, not what's correct.
- CI integration catches regressions before merge — pointless without a failing test gate.
Imagine you're building a car, but instead of waiting until the whole car is assembled to test whether it drives, you test each individual part on a workbench — the engine alone, the brakes alone, the steering alone. PHPUnit is that workbench for PHP code. It lets you prove, in isolation, that each small piece of your application works exactly as promised — before you bolt everything together and ship it to real users.
Every PHP application eventually reaches a point where a single 'harmless' change in one file breaks something completely unrelated three layers deep. That's not bad luck — that's the natural consequence of untested code. PHPUnit is the industry-standard testing framework for PHP, and at an advanced level it's not just about writing a few assert statements. It's about architecting a safety net so tight that you can refactor aggressively, onboard new developers fearlessly, and deploy on a Friday afternoon without your stomach dropping.
The real problem PHPUnit solves isn't catching bugs after the fact — it's making bugs impossible to hide in the first place. Without a proper test suite, every code review is guesswork, every deployment is a gamble, and 'it works on my machine' becomes a team-wide coping mechanism. PHPUnit forces your code to be testable, which in turn forces it to be modular, loosely coupled, and honest about its dependencies. The act of writing tests often reveals design flaws that code review misses entirely.
By the end of this article you'll know how to structure a professional PHPUnit test suite from scratch, mock external dependencies so your unit tests never hit a database or API, leverage data providers to eliminate test duplication, enforce meaningful code coverage thresholds, and wire everything into a CI/CD pipeline. You'll also walk away knowing the edge cases and anti-patterns that trip up even experienced PHP developers.
Setting Up PHPUnit and Structuring Your Test Suite
PHPUnit ships via Composer: composer require --dev phpunit/phpunit. Your tests live in a tests/ directory mirroring the source structure. A typical phpunit.xml configures autoloading, coverage filters, and test suites.
Start with a base test case that extends PHPUnit\Framework\TestCase. Name your test class with the same name as the class under test, plus a Test suffix. The convention in the PHP ecosystem is SomeClassTest. Use namespaces that match the source:
```php // src/Service/PaymentProcessor.php namespace io\thecodeforge\Service;
class PaymentProcessor { ... }
// tests/Service/PaymentProcessorTest.php namespace io\thecodeforge\Test\Service;
use PHPUnit\Framework\TestCase; use io\thecodeforge\Service\PaymentProcessor;
class PaymentProcessorTest extends TestCase { ... } ```
This structure makes autoloading trivial and keeps production and test code side by side.
composer install with --prefer-dist downloads stable packages faster. On CI, cache the vendor/ directory to skip downloads.cacheResult="true", stale cache can block test discovery after renaming a test class.phpunit --clear-cache or disable caching in CI pipelines.phpunit.xml as a checked-in configuration, not a local-only file.tests/ directory with flat test files.src/ structure in tests/ and split Unit, Integration, Functional suites.tests/ and phpunit.xml — root config aggregates them.Writing Expressive Assertions and Organising Tests
PHPUnit provides over 100 assertion methods. Beyond assertTrue() and assertEquals(), the expressive ones reduce test reading time:
assertCount(3, $items)— clearer thanassertEquals(3, count($items))assertContains('apple', $fruits)— explicit intentassertInstanceOf(SomeInterface::class, $obj)— type checksassertArrayHasKey('id', $data)— avoids undefined index warnings
For order-dependent tests, use @depends to pass results between test methods. But be careful — a failed test in a chain marks all dependent tests as skipped, not failed. That masks failures.
assertEquals on doubles will randomly fail due to precision.assertEqualsWithDelta with a tiny delta (0.0001).@depends chains — they mask failures.Mocks and Test Doubles — Controlling Dependencies
Unit tests must never hit external systems. That's where mocks, stubs, and spies come in. PHPUnit's built-in createMock() generates a proxy that returns default values for all methods. You configure expectations with and expects()willReturn().
- Stub: returns canned answers but doesn't assert behaviour.
- Mock: asserts that specific methods were called with specific arguments.
- Spy: wraps the real object and records interactions (PHPUnit doesn't have built-in spies, but you can use
willReturnCallbackto log calls).
Never mock what you don't own — mock interfaces, not concrete classes. This keeps tests decoupled from implementation details. If you mock a concrete class, a new __construct argument or a renamed method breaks your mock silently.
- It records every call made to the dependency.
- It compares actual calls against your expectations.
- If your subject doesn't call the dependency as expected, the camera 'alerts' — the test fails.
- Stubs, by contrast, are just stunt doubles that say the right thing but don't report back.
new Logger() writes to a file — your mock never touches disk.createStub() if you don't need call assertions.expects() only when behaviour must be verified.createStub(Interface::class) and method('foo')->willReturn('bar')expects() and with()Data Providers — Eliminate Test Duplication
Data providers are methods annotated with @dataProvider that return an array of arrays. Each inner array is unpacked as arguments to the test method. This lets you test the same logic across many input sets without writing separate test methods.
A common mistake: loading data providers from a file or database. Keep them static and deterministic — the test should never depend on external state. If you need a large set of test data, generate it with a helper function, but keep the provider method itself simple.
You can also chain data providers with @depends, but that's rarely useful. Prefer independent tests with clear provider names.
@group=smoke for a small representative sample and run the full suite nightly.@large annotation for tests with huge data providers — they'll run in a separate process.Code Coverage, CI Integration, and the Reality of 'Green' Tests
Code coverage reports show which lines you executed, not which paths you tested. A 90% coverage number doesn't mean your code is 90% bug-free — it means 90% of lines ran. The remaining 10% could hide the worst bugs.
Set coverage thresholds in phpunit.xml using <coverage><requireCoverage>true</requireCoverage></coverage>. But more important: enforce that new code must reach a minimum coverage. Use a tool like phpmd or infection (mutation testing) to go deeper.
CI integration is straightforward: run vendor/bin/phpunit in your pipeline. Upload coverage reports to services like Codecov or Coveralls. Critical: make the pipeline fail on coverage drop. Otherwise coverage drifts down sprint after sprint.
minimum-php-version and coverage-min in phpunit.xml.The Mock That Hid a Real Bug for Three Sprints
- Mocks that always succeed hide production failure modes.
- Always model realistic timing and retry behaviours in your test doubles.
- A green unit test suite is not proof your code works — it's proof your mocks are consistent with your assumptions.
andReturnCallback to log calls. Verify your test actually invokes the mocked method.--filter to run one test case. Profile with Xdebug if needed.use statements cover the production class. Run phpunit --coverage-html manually.--no-coverage) or split test suite into groups.Key takeaways
Common mistakes to avoid
4 patternsMocking concrete classes instead of interfaces
Using data providers that read from external files
Treating coverage percentage as a quality metric
Skipping integration tests because unit tests pass
Interview Questions on This Topic
What is the difference between a mock and a stub in PHPUnit?
createMock() creates a mock by default; createStub() creates a stub. Use mocks for behaviour verification, stubs for data supply.Frequently Asked Questions
That's Advanced PHP. Mark it forged?
3 min read · try the examples if you haven't