Unit Testing PHP with PHPUnit
AI-assistedDrafted with AI assistance and reviewed before publishing; examples are version-pinned. Verify against official docs when in doubt.
Most PHP projects reach a point where every change is verified by clicking through a few pages. A test suite replaces that with one command. This chapter starts from Composer, then builds up assertions, data providers, test doubles and database tests into something that survives a refactor.
Setup
Install PHPUnit as a dev dependency. Never globally: projects pin different major versions, and the binary must match the version in composer.lock.
composer require --dev phpunit/phpunit ^11.5
vendor/bin/phpunit --version
PHPUnit 11 requires PHP 8.2 or newer and runs fine on PHP 8.4. Wire up autoloading and a script:
{
"autoload": {
"psr-4": { "App\\": "src/" }
},
"autoload-dev": {
"psr-4": { "App\\Tests\\": "tests/" }
},
"scripts": {
"test": "phpunit"
}
}
autoload-dev keeps test classes out of the production autoloader. Run composer dump-autoload after editing it.
src/ # production code, namespace App\
tests/ # tests, namespace App\Tests\
phpunit.xml
phpunit.xml
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
bootstrap="vendor/autoload.php"
colors="true"
cacheDirectory=".phpunit.cache"
failOnWarning="true"
failOnRisky="true">
<testsuites>
<testsuite name="unit">
<directory>tests</directory>
</testsuite>
</testsuites>
<source>
<include>
<directory>src</directory>
</include>
</source>
</phpunit>
What matters:
bootstrappoints at Composer’s autoloader, otherwiseuse App\...fails.cacheDirectoryholds the result cache; add it to.gitignore.<source>tells the coverage engine what “your code” means, sotests/andvendor/stay out of the percentages.failOnWarningandfailOnRiskyturn “a test with no assertions” into a failure instead of a green tick.
vendor/bin/phpunit # everything
vendor/bin/phpunit --testdox # readable list built from method names
vendor/bin/phpunit --filter testFormatsCentsAsCurrency
The first test
<?php
declare(strict_types=1);
namespace App;
final class Price
{
public static function format(int $cents, string $currency = 'USD'): string
{
if ($cents < 0) {
throw new \InvalidArgumentException('cents must be >= 0');
}
return number_format($cents / 100, 2, '.', ',') . ' ' . $currency;
}
}
<?php
declare(strict_types=1);
namespace App\Tests;
use App\Price;
use PHPUnit\Framework\TestCase;
final class PriceTest extends TestCase
{
public function testFormatsCentsAsCurrency(): void
{
self::assertSame('19.99 USD', Price::format(1999));
}
public function testRejectsNegativeAmount(): void
{
$this->expectException(\InvalidArgumentException::class);
$this->expectExceptionMessage('cents must be >= 0');
Price::format(-1);
}
}
expectException must be declared before the call that throws. Once the exception is thrown, the rest of the method never runs.
Choosing an assertion
| Assertion | Comparison | Use for |
|---|---|---|
assertSame |
===, type and value |
strings, ints, the same object |
assertEquals |
loose equality | values only, use sparingly |
assertTrue / assertNull |
boolean and null | state checks |
assertCount |
array or Countable size |
collection results |
assertArrayHasKey |
key existence | config and response shapes |
assertStringContainsString |
substring | log lines, exception messages |
assertMatchesRegularExpression |
regex | format validation |
assertInstanceOf |
type | factories, returned exceptions |
assertSame should be the default. assertEquals treats "1" and 1 as equal, which is exactly how type bugs slip through a suite.
Data providers
use PHPUnit\Framework\Attributes\DataProvider;
#[DataProvider('amountProvider')]
public function testFormatsVariousAmounts(int $cents, string $expected): void
{
self::assertSame($expected, Price::format($cents));
}
public static function amountProvider(): iterable
{
yield 'zero' => [0, '0.00 USD'];
yield 'one unit' => [100, '1.00 USD'];
yield 'thousands' => [100000, '1,000.00 USD'];
}
The provider must be public static and return an array or iterable. String keys with yield name the failing row. Use the attribute form: the @dataProvider annotation still works in PHPUnit 11 but is on its way out, and new code should not add more of it.
Test doubles
Depend on interfaces, not concrete classes. Given:
<?php
declare(strict_types=1);
namespace App;
interface Mailer
{
public function send(string $to, string $subject): void;
}
final class SignupService
{
public function __construct(private Mailer $mailer) {}
public function register(string $email): void
{
if (filter_var($email, FILTER_VALIDATE_EMAIL) === false) {
throw new \InvalidArgumentException('invalid email');
}
$this->mailer->send($email, 'Welcome');
}
}
Use createMock when the interaction matters:
public function testSendsWelcomeMail(): void
{
$mailer = $this->createMock(Mailer::class);
$mailer->expects(self::once())
->method('send')
->with('ada@example.com', 'Welcome');
(new SignupService($mailer))->register('ada@example.com');
}
Use createStub when you only need a return value. It makes no call-count assertions, which keeps the intent obvious:
$mailer = $this->createStub(Mailer::class);
$mailer->method('send')->willThrowException(new \RuntimeException('smtp down'));
Other expectations worth knowing:
$mock->expects(self::never())->method('send');
$mock->expects(self::exactly(2))->method('send');
$mock->expects(self::atLeastOnce())->method('send');
createMock satisfies return types automatically (null, 0, empty arrays), so no hand-written null object is needed. Do not mock the class under test — that only verifies the double.
Testing database code
Do not stand up MySQL for a unit test. PDO supports an in-memory SQLite database, and setUp gives every test a clean schema:
final class UserRepositoryTest extends TestCase
{
private \PDO $pdo;
protected function setUp(): void
{
$this->pdo = new \PDO('sqlite::memory:');
$this->pdo->setAttribute(\PDO::ATTR_ERRMODE, \PDO::ERRMODE_EXCEPTION);
$this->pdo->exec(
'CREATE TABLE users (id INTEGER PRIMARY KEY, email TEXT NOT NULL UNIQUE)'
);
}
public function testInsertsUser(): void
{
$repo = new UserRepository($this->pdo);
$repo->create('ada@example.com');
$count = (int) $this->pdo->query('SELECT COUNT(*) FROM users')->fetchColumn();
self::assertSame(1, $count);
}
}
setUp runs before every test. setUpBeforeClass runs once per class, so rows created there leak into later tests.
SQLite is not MySQL: types, AUTO_INCREMENT and several functions differ. Keep the in-memory database for logic, and run one integration pass against the real engine for dialect-specific SQL.
Coverage
Coverage needs Xdebug or pcov. Xdebug is everywhere but slow, so enable coverage only for that command:
# Linux and macOS
XDEBUG_MODE=coverage vendor/bin/phpunit --coverage-text
# Windows PowerShell
$env:XDEBUG_MODE="coverage"; vendor/bin/phpunit --coverage-text
An HTML report makes uncovered branches easier to find:
vendor/bin/phpunit --coverage-html build/coverage
Coverage measures execution, not verification. A test that calls a method and asserts nothing still paints the line green. Write the boundary and failure cases first, then look at the number.
Running in CI
- run: composer install --no-interaction --prefer-dist
- run: vendor/bin/phpunit --testdox
Flags that earn their place:
--fail-on-warningand--fail-on-risky: no silent passes for assertion-free tests.--order-by=random: exposes hidden ordering dependencies;--random-order-seedmakes a failure reproducible.--stop-on-failure: shorter logs.--coverage-textplus--coverage-cloverwhen an external service consumes the report.
Locally, composer test; before pushing, the full suite alongside static analysis.
Common mistakes
1. Shared state between tests
Static properties, singletons and open connections make results depend on order. Build state per test in setUp.
2. Using assertEquals for everything
"0", 0 and false blur together under loose comparison. Prefer assertSame.
3. Declaring expectException after the call
The exception is already thrown and the test fails before the expectation is registered.
4. A non-static data provider
PHPUnit 10 and later require public static; otherwise you get an error or a deprecation notice.
5. Mocking a concrete class instead of an interface
Constructors are skipped, but signatures and final modifiers still bite. Depend on interfaces.
6. Sleeping in tests
Real waits make a suite slower every month. Inject a clock, or freeze time with Carbon::setTestNow().
7. Asserting on implementation details
Checking that a private helper ran makes every refactor red. Assert on observable behaviour.
8. Testing getters and constructors
Those tests protect nothing. Spend the effort on branches, boundaries and failure paths.
Summary
- PHPUnit belongs in
require-dev, withautoload-devfor the test namespace andbootstrappointing at Composer. assertSameis the default; data providers must bepublic staticand use the attribute form.createMockverifies interactions,createStubonly returns values; depend on interfaces.- Test PDO code against SQLite
:memory:with a fresh schema insetUp. - Coverage is a trend. In CI, add
--fail-on-riskyand random test order.
Next: put PHPUnit around the PDO repository from the practice chapters and test the API boundary.