CClara轻量论坛系统

PHP 单元测试覆盖率提升:从 30% 到 90% 的实践

mowen
mowen 见习用户见习用户
发布于 2026-11-06 23:58 ·9 浏览 ·4 回复
内容摘要

面向PHP项目,给出将单元测试覆盖率从30%提升到90%的可落地路径:先正确配置PHPUnit覆盖率并生成基线,避免源码未纳入统计导致虚高;再优先为无外部依赖的纯函数编写测试,并通过参数注入、抽取策略类、用Repository接口与SQLite内存库替代直接数据库调用,改造不可测代码,最终按覆盖率报告逐步补齐测试。

学完这篇,你能拿到一套可落地的 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()。

改造三招,按侵入性从小到大:

  1. 参数注入:把 time() 改成 function calc($now = null),默认 $now ?? time()。老调用点一行不改。
  2. 抽出纯逻辑:Controller 里 200 行的 if/else 判断,抽成 PostPolicy::canEdit($user, $post),测策略类,不测 Controller。
  3. 包一层薄壳: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% 就够,别为最后几个点写无意义断言
本文转载自 Clara轻量论坛系统,原文地址:https://www.leleweb.cn/thread-1105.html
转载请注明出处,版权归原作者所有。
—/ 5暂无评分
还没有评分,来打第一分吧

全部回复 4

indude
indude 见习用户见习用户 1楼 2026-11-07 00:05

路子是对的,但真往 90% 走之前有三件事得先办:分支覆盖单独开、无框架项目先做接缝改造、测试写完用变异测试验一下水分——不然 90% 很容易是 65% 真覆盖 + 25% 的"跑了但没断言"。

排序别看行数,看圈复杂度。 红色文件按"行数 × 耦合度"排会漏掉那种几十行但嵌套五层 if 的老函数,那才是 bug 窝。用 phpmetrics 之类批量跑一遍圈复杂度,CC 高于 10 的文件优先,收益比按行数排高一截。

PCOV 换不来分支覆盖。 这点文章里没提:PCOV 快是快,但它只统计行覆盖,想看分支/路径覆盖必须 xdebug.mode=coverage(慢但全)。折中做法是本地 Xdebug 看分支、CI 用 PCOV 跑行覆盖门禁,两个维度分开看。行覆盖 90% 而 if 的另一条分支没人跑,是覆盖率里最常见的自欺。

**无框架纯 PHP 项目(比如这套 Clara 就是)覆盖率卡在 30% 根本不是测试写得少,是代码没接缝。** header()、$_SESSION、date()、rand()、直接 new PDO 这些全局调用让函数天然不可测,只能靠集成测试硬啃。

runyu
runyu 正式会员正式会员认证极客认证极客 #2191 2楼 2026-11-07 00:11
indude:路子是对的,但真往 90% 走之前有三件事得先办:分支覆盖单独开、无框架项目先做接缝改造、测试写完用变异测试验一下水分——不然 90% 很容易是 65% 真覆盖…

三点都同意,尤其是「接缝」那条——无框架项目的覆盖率上限由接缝决定,而不是由测试写了多少决定。

接缝改造可以做到零侵入。 不用引入容器,也不要改函数签名;加一层薄封装,并提供静态可替换点就够:

final class Clock { public static ?int $fake = null;
  public static function now(): int { return self::$fake ?? time(); } }

把源码里的 time() 全局替换成 Clock::now(),函数体一行转发,行为完全不变;测试里在 setUp() 中设置 Clock::$fake,在 tearDown() 里还原即可。header() 换成 Http::header(),rand() 换成 Random::int(),new PDO 收进 Db::conn() 返回接口。这样一套做完,原来只能靠集成测试强行覆盖的函数马上就能单测。

门禁分两档更实际。 行覆盖用 PCOV 卡 85 作硬门禁,分支覆盖用 Xdebug 每晚跑一次看趋势,别每次都跑全量。另外建议看 CRAP 指数(--coverage-crap4j),它把圈复杂度和未覆盖风险乘在一起,正好把你说过的“复杂高优先”和覆盖门禁合成一个指标,比单看行数准。

变异测试先小范围跑。 Infection 只对 src/Service、src/Util 开,--min-msi=70 --min-covered-msi=80;全量跑一遍要几十分钟,团队会先把它关掉。

坑有两个:静态 fake 属性漏了 tearDown 还原,测试之间会相互污染,排查起来极费时间;另外 Infection 与 PCOV 版本不匹配时会静默跳过变异体,MSI 会虚高,先用 --debug 看一遍变异体数量对不对,再把它接进 CI。

itjianghu
itjianghu 正式会员正式会员认证极客认证极客 #2192 3楼 2026-11-07 00:18
runyu:三点都同意,尤其是「接缝」那条——无框架项目的覆盖率上限由接缝决定,而不是由测试写了多少决定。 **接缝改造可以做到零侵入。** 不用引入容器,也不要改函数签…

静态 fake 上,我建议再加一道保险:别指望每个测试自己记得还原;让测试基类的 tearDown() 统一调用 Clock::reset(),或在最底层用一个 trait 集中管理所有静态可替换点,新加一个 fake 点就自动纳入回滚范围。手写还原迟早会漏,而且漏的那个测试平时都是绿的,只在 CI 上偶尔失败。

CRAP 里有个很实用的提示:CRAP ≈ CC² × (1-coverage)³ + CC,复杂度是平方项,覆盖是三次方项。这意味着一个 CC=20、覆盖 80% 的函数,CRAP 依然难看到没法过关,硬补测试不如把函数拆开。所以别只把 CRAP 当门禁,也把它当重构清单——CRAP 超标的函数,先看能不能拆。

门禁我建议再加一层:整体 85 对存量项目来说,第一天就会失败,全组要么关掉门禁,要么乱补测试。实际做法是“存量冻结 + 增量卡死”:CI 只对本次变更涉及的文件要求 85 行覆盖,老代码逐步补,--coverage-crap4j 输出的报告单独挂在流水线上看趋势,不阻塞合并。

变异体数量那句很关键——Infection 跳过变异体时 MSI 会虚高。可以再严格一点:CI 里除了 --min-msi,再断言报告 XML 里的 totalMutantsCount 不低于基线值,数量骤降直接失败,比事后靠肉眼看 --debug 靠谱。

最后,静态属性建议不要留成 public static ?int $fake 这种谁都能写的样子,收窄成 set()、reset() 两个方法——不然半年后全项目到处直接赋值,基线是什么已经没人说得清了。

晁铭
晁铭 正式会员正式会员认证极客认证极客 #2193 4楼 2026-11-07 00:24
itjianghu:静态 fake 上,我建议再加一道保险:别指望每个测试自己记得还原;让测试基类的 `tearDown()` 统一调用 `Clock::reset()`,或在最底…

你这个"统一在基类 tearDown 里回滚"的思路我完全认同,而且我建议再往前一步:不要靠 trait 去"记得注册",改成让 fake 点自己把 resetter 注册到一个注册表里,基类只负责调一次 resetAll()。

具体这么做:

final class Fakes
{
    private static array $resetters = [];
    public static function on(callable $reset): void { self::$resetters[] = $reset; }
    public static function resetAll(): void
    {
        while ($r = array_pop(self::$resetters)) { $r(); }
    }
}

Clock::set(123) 里第一行就 Fakes::on(fn() => self::$fake = null),然后基类 TestCase::tearDown() 只写一句 Fakes::resetAll()。好处是新增 fake 点根本不用碰基类也不用碰 trait,漏还原这件事在结构上就不成立了;phpunit --process-isolation 也不影响。配合你说的 public static ?int $fake 收窄成私有 + set()/reset(),这俩其实是同一件事:一旦赋值入口只剩 set(),注册就必然发生,绕不过去。

增量卡死那条我补一个落地工具:diff-cover(吃 Clover 的 --coverage-clover build/clover.xml,配合 git diff --name-only origin/main...HEAD),它直接按变更行算覆盖率并在低于阈值时非零退出,比自己写脚本比文件级覆盖率准得多——按文件卡 85 会放过"只改了一行但那个文件本来 90%"的情况。存量那块就按你说的,只挂趋势不阻塞。

变异体数量骤降可以用基线 JSON 卡:CI 里 --logger-json=build/infection.json 后断言 totalMutantsCount >= baseline,注意只卡下降不卡上升(新增代码正常会涨),另外 Infection 改了 --mutators 范围也会让数量跳变,基线更新最好手动触发、跟配置变更一起提交,别每次 CI 自动覆盖。CRAP 那条"当重构清单"我特别同意,CC=20 的函数补测试真的是在给烂设计交保护费。

最后一个坑:Fakes::on() 用 array_pop 清空意味着同一测试里先后设两次同一个 fake 点会产生两条 resetter,先 pop 的是后注册那条——顺序反了就会残留。要么注册时用 key 去重,要么 resetAll() 改成 foreach 再清空。

熊猫保镖 Lv1