site logo

Marico's space

PCOV vs Xdebug 覆盖率对比:无人关注的 CI 速度差异

编程技术 2026-07-03 11:28:07 7

打开 CI 日志,想搞清楚为什么一个 PR 跑了 9 分钟才变绿。本地测试明明只需要 40 秒。翻到上面一看,罪魁祸首:覆盖率任务。同样的测试用例,只是套了个 Xdebug,每个断言都像爬一样。

大多数团队从来不把这两个数字拆开看。看到"测试耗时 9 分钟",就以为是测试本身慢。其实不是。慢的是覆盖率驱动。而且修复方法只是 CI 配置里改一行代码,但几乎没人做过,因为根本没想过要去测一下时间到底花在哪了。

PCOV 是一个专注覆盖率的驱动,只做一件事,不干 Xdebug 那些额外的事情。

为什么 Xdebug 的覆盖率这么慢

Xdebug 是个调试器。断点调试、堆栈跟踪、性能分析、变量检查,全靠它。为了实现这些功能,它会深度挂载到 Zend 引擎上。它安装了自己的 opcode 处理器,在函数进入、函数退出以及很多独立操作时都会被调用。

代码覆盖率只是它众多模式中的一种。当你用 XDEBUG_MODE=coverage 运行 PHPUnit 时,Xdebug 通过它那套通用的插桩机制来记录哪些行执行了。这套机制是通用目的的,代价是它能做的事情远比单纯计数行数多得多。

结果就是:你的测试套件里每个函数调用都要交 Xdebug 税,不管这个调用跟覆盖率有没有关系。小测试套件感觉不出来。等你有几万条断言、对象图很深的时候,税就累积成分钟级了。

在 CI 环境下还有个额外的坑。只要 Xdebug 被加载了、模式不是 off,它就会拖慢整个 PHP 进程,不只是覆盖率跑的那一会儿。很多 Docker 基础镜像默认就启用了 Xdebug。你的测试一直在为一个从来没主动要求过的调试器付代价。

PCOV:只做一件事的驱动

PCOV 是一个专注覆盖率的扩展,由 Joe Watkins 编写。它只记录行覆盖率,其他什么都不管。不断点调试、不做性能分析、不管堆栈。因为它根本不是调试器,所以不需要安装 Xdebug 那种重量级的逐操作钩子。

这种专注就是关键。PCOV 监控你指定的文件,记录哪些行执行了,其他的一概不管。PHPUnit 已经知道怎么跟它配合。phpunit/php-code-coverage 库会检测可用的驱动并使用它,所以你不用改一行测试代码。

安装方式:

pecl install pcov

然后加载它并指向你的源码目录:

; pcov.ini
extension=pcov.so
pcov.enabled=1
pcov.directory=src

pcov.directory 这个配置对速度很关键。它把收集范围限定在你的源码目录。没有这个配置的话,PCOV 还会检查 vendor 目录和测试文件,这些根本没必要测量。把它设置为 phpunit.xml<source> 元素指向的目录就行。

在自己的测试套件上测一下

这就是标题承诺的部分。没人测过这个,所以自己测。你不需要什么 benchmark 框架,用系统的 time 命令跑两次就够了。

先跑 Xdebug 版本。Xdebug 3 把覆盖率锁在特定模式下,所以要显式设置:

XDEBUG_MODE=coverage \ time vendor/bin/phpunit --coverage-text

再跑 PCOV 版本,关掉 Xdebug 模式防止它捣乱:

XDEBUG_MODE=off \ time vendor/bin/phpunit --coverage-text

各跑两三次,让文件系统缓存预热,取较短的那个时间。对比一下墙上时间。测试套件足够大的话,PCOV 版本能在 Xdebug 的一小部分时间内跑完。精确的比例取决于你代码的调用密度,所以用自己的套件测比看别人的数字靠谱多了。

顺手再跑第三次,完全不跑覆盖率:

XDEBUG_MODE=off \ time vendor/bin/phpunit --no-coverage

这是你的基准线:测试本身的开销。PCOV 跑完和这个的差距,才是覆盖率真正的成本。和 Xdebug 跑的差距,就是你一直在付出的代价。

接入 CI

如果用的是 shivammathur/setup-php,改一行配置就行。这个 action 会帮你装好并配置好驱动。

# .github/workflows/tests.yml
name: Tests on: pull_request: paths: - '**.php' - 'phpunit.xml' - 'composer.lock' jobs: tests: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: shivammathur/setup-php@v2 with: php-version: '8.4' coverage: pcov - name: Install run: composer install --no-progress --prefer-dist - name: Tests with coverage run: > vendor/bin/phpunit --coverage-clover=coverage.xml

真正起作用的就是 coverage: pcov 这一行。需要 Xdebug 的其他模式时换成 coverage: xdebug,不需要任何覆盖率报告的 job 用 coverage: none,跑起来就是全速。大多数矩阵里的 job 不需要覆盖率,给它们 none 就完了。

常见的做法是:主 PHP 版本的那个 job 跑 PCOV 并上传报告,矩阵里其他 job 只跑 coverage: none,只检查测试过不过。这样一份覆盖率数据,不需要在每个分支上都交驱动税。

什么时候还是得用 Xdebug

PCOV 只记录行覆盖率。这是大多数团队汇报、大多数门禁检查的数字。但覆盖率不止这一种,PCOV 到这里就停了。

Xdebug 还能收集分支覆盖率和路径覆盖率。分支覆盖率问的是每个 if 的两边有没有都跑过。路径覆盖率问的是每个方法里的每条执行路径有没有都跑过。PCOV 做不到这两样。如果你的 phpunit.xml 里有 pathCoverage="true",或者生成的 HTML 报告里显示了分支百分比,那这次跑只能用 Xdebug。

调试的时候当然还是用 Xdebug,这点不用多说。PCOV 没法设断点看变量。这两个扩展解决的问题不一样,健康的配置是两个都装上,但同一时间只激活一个。

实际分工是这样的:

  • CI 覆盖率门禁(行覆盖率): PCOV。最快的路径,也是最常见的需求。
  • 分支或路径覆盖率报告: Xdebug,专门开个 job,跑得不那么频繁(每天一次,或者合并到 main 时跑)。
  • 本地断点调试: Xdebug,模式设为 debug,正常跑测试时永远不要设成 coverage

像 Infection 这种变异测试工具,也更偏好 PCOV 来跑覆盖率那一遍,因为变异测试要做很多轮,每轮都省下的时间累积起来很可观。看看你用的工具的文档,大多数会自动检测 PCOV。

唯一的坑:不要同时加载两个

如果两个扩展都加载了、而且 Xdebug 的模式包含 coverage,覆盖率库就得分个先后,结果可能是你装了 PCOV 但还是在交 Xdebug 的税。规则很简单:要用 PCOV 的时候,确保 XDEBUG_MODE=off

跑之前可以检查一下当前激活的是哪个:

php -m | grep -i 'pcov\|xdebug'
php -i | grep 'xdebug.mode'

如果 PCOV 出现了、而且 Xdebug 的模式是 off,那正在干活的就是 PCOV。这个两秒钟的检查,不知道救过多少人免于得出"PCOV 没用"的错误结论——其实是 Xdebug 一直在悄悄跑。