学完这篇,你能拿到一套可落地的 PHP 单元测试改造路径:从跑出第一份覆盖率报告,到把 30% 提到 90%,并且不靠"给 getter 写测试"凑数字。
第一步:先把覆盖率数字跑出来
没有基线就没有优化。PHPUnit 10+ 装法二选一:
composer require --dev phpunit/phpunit ^10
# 无 Composer 的纯 PHP 项目,直接下 phar
wget https://phar.phpunit.de/phpunit-10.phar
php phpunit-10.phar --version
覆盖率驱动要先开。二选一:
; php.ini —— 开发机用 Xdebug,信息最全
xdebug.mode=coverage
或者装 PCOV(只做覆盖率,快很多)。然后建 phpunit.xml:
<phpunit bootstrap="tests/bootstrap.php">
<testsuites>
<testsuite name="unit"><directory>tests</directory></testsuite>
</testsuites>
<source>
<include><directory suffix=".php">src</directory></include>
<exclude><directory>src/View</directory></exclude>
</source>
</phpunit>
跑一次:
vendor/bin/phpunit --coverage-text --coverage-html build/coverage
打开 build/coverage/index.html,你会看到红黄绿三色。先别急着写测试,把红色文件按"行数 × 耦合度"排个序,这就是你的作战地图。
注意:<source> 里一定要 include 你的源码目录,否则 PHPUnit 只统计"被加载过的文件",覆盖率会虚高到 95%,纯属自欺欺人。
第二步:先测纯函数,性价比最高
30% 的典型成因是:工具类、格式化、校验逻辑没人测,而 Controller 里塞满了业务判断。
先挑没有 $db、没有 $_SESSION、没有 header() 的函数下手。这类函数一个测试文件能覆盖几十行,投入产出比最高。比如金额计算、权限位运算、Markdown 片段处理、分页参数归一化。
final class PriceTest extends TestCase
{
public function test_discount_rounds_to_two_decimals(): void
{
$this->assertSame('9.90', Price::discount(11.00, 10));
}
}
顺手用 @covers 限定覆盖范围,防止"跑 A 测试顺带覆盖了 B 类"的假象:
#[CoversClass(Price::class)]
注意:PHPUnit 10 起 @covers 注解换成了 #[CoversClass] 属性,写错不会报错,只会静默失效。
第三步:把"不可测"的代码改造成可测
剩下 60% 的瓶颈几乎都是同一类问题:函数内部 new PDO()、直接读 $_POST、exit、die、time()。
改造三招,按侵入性从小到大:
- 参数注入:把
time() 改成 function calc($now = null),默认 $now ?? time()。老调用点一行不改。
- 抽出纯逻辑:Controller 里 200 行的
if/else 判断,抽成 PostPolicy::canEdit($user, $post),测策略类,不测 Controller。
- 包一层薄壳:DB 操作抽成
Repository 接口,测试时传内存实现或 SQLite :memory:。
第三招在无框架项目里收益最大。用 SQLite 内存库跑集成测试,比 mock 一堆 PDO 更接近真实:
$pdo = new PDO('sqlite::memory:');
$pdo->exec(file_get_contents('tests/schema.sqlite.sql'));
注意:MySQL 特有语法(AUTO_INCREMENT、ENGINE=InnoDB)在 SQLite 里跑不通,schema 要单独维护一份精简版,别直接 dump 生产库。
第四步:补边界与异常路径
覆盖率的最后 20% 全在 catch 分支和 if ($x === null) 里。这类分支常被忽略,但恰恰是线上事故高发区。
写测试时养成习惯:每个 try/catch 至少一个测试触发它;每个参数校验至少一个非法值用例。
public function test_rejects_expired_token(): void
{
$this->expectException(TokenExpiredException::class);
Token::verify('expired.token.value');
}
用 --coverage-text 的 Missed 列盯住剩余红行,比看 HTML 报告快。
第五步:设门禁,别让覆盖率掉回去
提上去不难,守住才难。CI 里加一条:
vendor/bin/phpunit --coverage-text --fail-on-warning
配合 phpunit.xml 的 <coverage> 阈值,或直接用 --coverage-clover 交给平台卡增量:新代码覆盖率低于 80% 直接挂 CI。
策略上推"增量覆盖率"而不是"全量阈值"——存量烂代码不动它,新提交必须带测试,半年后自然爬到 90%。
注意:Xdebug 开 coverage 会让整个测试套件慢 3~5 倍。CI 上换成 PCOV(pecl install pcov,pcov.enabled=1),本地开发再开 Xdebug 调试。
第六步:别为了数字写垃圾测试
90% 和 92% 没有业务差别,但 200 个 assertNotNull 会让维护成本翻倍。三个红线:
- 不给 getter/setter、纯 DTO 写断言(在
<source> 里 exclude 掉)
- 不写没有断言的"执行一遍不报错"测试
- 不为了覆盖
catch 去 mock 一个永不发生的异常
覆盖率是手段,回归防护才是目的。
小结
- 先跑基线:
<source> 必须 include 源码目录,否则数字虚高
- 优先测纯函数和策略类,一个文件顶十个 Controller 测试
- 不可测代码用「参数注入 → 抽纯逻辑 → 包 Repository」三招改造,不硬 mock
catch 与边界分支是最后 20% 的主战场,也是线上事故高发区
- CI 卡增量覆盖率而非全量阈值,新代码必须带测试
- 覆盖率到 90% 就够,别为最后几个点写无意义断言