PHP 单元测试:PHPUnit 上手与实战
AI 辅助生成本文由 AI 起草并经人工核查,示例均标明软件版本;发现错误请以官方文档为准。
PHP 项目常见的状态是:改一处代码,靠手动点几个页面确认没坏。单元测试把这件事变成一条命令。这一篇从 Composer 装 PHPUnit 开始,把断言、数据供给器、测试替身和数据库测试串成一套能长期维护的做法。
环境准备
PHPUnit 通过 Composer 装成开发依赖,不要全局安装——不同项目可能锁在不同大版本:
composer require --dev phpunit/phpunit ^11.5
确认版本:
vendor/bin/phpunit --version
PHPUnit 11 要求 PHP 8.2 以上,与 PHP 8.4 兼容。顺手在 composer.json 里配好自动加载与测试脚本:
{
"autoload": {
"psr-4": { "App\\": "src/" }
},
"autoload-dev": {
"psr-4": { "App\\Tests\\": "tests/" }
},
"scripts": {
"test": "phpunit"
}
}
autoload-dev 只在开发时加载,测试类不会进生产产物。改完跑一次 composer dump-autoload。
目录约定:
src/ # 生产代码,命名空间 App\
tests/ # 测试,命名空间 App\Tests\
phpunit.xml # 配置
phpunit.xml.dist
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>
几个关键点:
bootstrap指向 Composer 的自动加载,测试类才能use App\...。cacheDirectory存放测试结果缓存,加进.gitignore。<source>声明「被测代码在哪」,覆盖率只统计它,不含tests/与vendor/。failOnWarning/failOnRisky让「没有断言的测试」也失败,防止假通过。
跑测试:
vendor/bin/phpunit # 全部
vendor/bin/phpunit --testdox # 用方法名生成可读清单
vendor/bin/phpunit --filter testFormatsCentsAsCurrency
第一个测试
被测类是一个格式化金额的小工具:
<?php
declare(strict_types=1);
namespace App;
final class Price
{
public static function format(int $cents, string $currency = 'CNY'): string
{
if ($cents < 0) {
throw new \InvalidArgumentException('cents must be >= 0');
}
return number_format($cents / 100, 2, '.', ',') . ' ' . $currency;
}
}
测试继承 TestCase,每个测试方法是一条行为:
<?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 CNY', Price::format(1999));
}
public function testRejectsNegativeAmount(): void
{
$this->expectException(\InvalidArgumentException::class);
$this->expectExceptionMessage('cents must be >= 0');
Price::format(-1);
}
}
expectException 必须写在触发异常的调用之前,异常抛出后代码不会继续执行,写在后面永远不会生效。
断言怎么选
| 断言 | 比较方式 | 用在哪 |
|---|---|---|
assertSame |
===,类型与值都相同 |
字符串、整数、同一个对象 |
assertEquals |
宽松相等 | 只关心值的场景,慎用 |
assertTrue / assertNull |
布尔与空值 | 状态判断 |
assertCount |
数组 / Countable 数量 |
集合结果 |
assertArrayHasKey |
键存在 | 配置、响应结构 |
assertStringContainsString |
子串 | 日志、异常消息 |
assertMatchesRegularExpression |
正则 | 格式校验 |
assertInstanceOf |
类型 | 工厂、异常返回值 |
assertSame 应该是默认选择。assertEquals 会把 "1" 和 1 判为相等,类型错误就这样被放过。
数据供给器
同一逻辑多组输入,用数据供给器,而不是复制十个测试方法:
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 CNY'];
yield 'one unit' => [100, '1.00 CNY'];
yield 'thousands' => [100000, '1,000.00 CNY'];
}
供给器方法必须是 public static,返回数组或 iterable;用 yield 加字符串键,失败时会直接告诉你是哪一组。PHPUnit 11 用 attribute,旧的 @dataProvider 注释写法正在被淘汰,新代码别再用。
测试替身
依赖外部服务时,用替身断开真实调用。接口优先:
<?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');
}
}
验证调用用 createMock:
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');
}
只提供返回值用 createStub,它不对调用次数做断言,语义更清楚:
$mailer = $this->createStub(Mailer::class);
$mailer->method('send')->willThrowException(new \RuntimeException('smtp down'));
常用期望:
$mock->expects(self::never())->method('send');
$mock->expects(self::exactly(2))->method('send');
$mock->expects(self::atLeastOnce())->method('send');
createMock 会自动满足返回类型(返回 null、0、空数组等),不需要手写空实现。但别 mock 你正在测的类——那样测的是替身,不是代码。
测数据库代码
不要为单元测试搭一套 MySQL。PDO 支持 SQLite 内存库,建表在 setUp 里做,每个测试都是干净状态:
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 每个测试方法执行一次,tearDown 负责清理。不要写成 setUpBeforeClass——那个整个类只跑一次,数据会在测试之间残留。
SQLite 与 MySQL 有差异(类型、AUTO_INCREMENT、函数名),所以涉及方言的 SQL 仍需在真实数据库上跑一次集成测试;单元层用它挡住大部分逻辑错误就够。
覆盖率
覆盖率需要 Xdebug 或 pcov。Xdebug 更常见但更慢,CI 里可以只开覆盖模式:
# Linux / macOS
XDEBUG_MODE=coverage vendor/bin/phpunit --coverage-text
# Windows PowerShell
$env:XDEBUG_MODE="coverage"; vendor/bin/phpunit --coverage-text
HTML 报告更适合定位漏测分支:
vendor/bin/phpunit --coverage-html build/coverage
覆盖率只说明代码被执行过。一个只调用方法、不做任何断言的测试,同样能把行覆盖刷满。先补边界与失败分支,再看数字。
放进 CI
- run: composer install --no-interaction --prefer-dist
- run: vendor/bin/phpunit --testdox
几个实用参数:
--fail-on-warning、--fail-on-risky:CI 不放过「没有断言」的测试。--order-by=random:随机顺序暴露测试之间的隐式依赖;配--random-order-seed可复现。--stop-on-failure:第一个失败就停,日志更短。--coverage-text:覆盖率写进日志,配合--coverage-clover上传给外部服务。
本地 composer test,提交前跑一次完整套件,和静态检查一起进 CI。
常见错误与注意事项
1. 测试之间共享状态
静态属性、单例、未关闭的连接都会让结果依赖执行顺序。每个测试自己准备数据,setUp 里重建。
2. 用 assertEquals 比较一切
"0"、0、false 在宽松比较下容易混过去。能用 assertSame 就用它。
3. expectException 写在调用之后
异常已经抛出,测试直接失败,后面那行根本执行不到。永远先声明期望。
4. 数据供给器不是静态方法
PHPUnit 10 起要求 public static,否则报错或弃用警告。
5. mock 具体类而不是接口
构造函数不会执行,但方法签名、final 限制都会带来麻烦。依赖接口,测试才好替。
6. 在测试里 sleep
等待真实时间会让套件越来越慢。时间相关逻辑注入 clock,或用 Carbon::setTestNow()。
7. 断言实现细节
断言「内部调用了哪个私有方法」而不是输出结果,重构一次红一片。优先测公开行为。
8. 只测 getter 和构造函数
这类测试不提供任何保护。把精力放在分支、边界与失败路径上。
小结
- PHPUnit 装成
devDependency,autoload-dev加载测试命名空间,bootstrap指向 Composer。 assertSame是默认断言;多组输入用#[DataProvider],供给器必须public static。createMock验证交互,createStub只提供返回值;依赖接口而不是具体类。- 数据库代码用 SQLite 内存库 +
setUp建表,每个测试独立。 - 覆盖率看趋势,CI 里配
--fail-on-risky与随机顺序。