代码覆盖率
概述
代码覆盖率(Code Coverage)是衡量测试套件对源代码执行程度的指标。它通过追踪测试运行时哪些代码行、分支、路径被执行了,哪些没有被执行,帮助开发者识别测试盲区,评估测试质量。PHPUnit 支持通过 Xdebug 或 PCOV 扩展来收集代码覆盖率数据,并生成多种格式的报告。
前置知识
阅读本节前,建议先了解:PHPUnit 单元测试 和 Mock 对象。
基础概念
覆盖率类型
| 类型 | 说明 | 重要性 |
|---|---|---|
| 行覆盖率(Line Coverage) | 被执行的代码行占比 | 基础指标 |
| 分支覆盖率(Branch Coverage) | if/else 等分支的执行比例 | 重要指标 |
| 路径覆盖率(Path Coverage) | 所有可能执行路径的覆盖比例 | 最严格 |
| 函数/方法覆盖率 | 被调用的函数占比 | 辅助指标 |
| 类覆盖率 | 被实例化的类占比 | 辅助指标 |
覆盖率驱动
PHPUnit 10 支持多种覆盖率驱动:
| 驱动 | 性能 | 安装难度 | 精确度 |
|---|---|---|---|
| Xdebug | 中 | 简单 | 高 |
| PCOV | 高 | 简单 | 高(仅行级别) |
| PHPDBG | 高 | 中等 | 中 |
安装与配置
Xdebug 驱动
bash
# 安装 Xdebug
pecl install xdebug
# 配置
cat > /etc/php/8.1/mods-available/xdebug.ini << 'EOF'
zend_extension=xdebug.so
xdebug.mode = coverage
xdebug.start_with_request = off
EOF
phpenmod xdebugPCOV 驱动(推荐用于 CI)
bash
# PCOV 性能更好,适合 CI 环境
pecl install pcov
# 配置
cat > /etc/php/8.1/mods-available/pcov.ini << 'EOF'
extension=pcov.so
pcov.enabled = 1
; 仅检测项目目录(排除 vendor)
pcov.directory = /path/to/project/src
EOF
phpenmod pcovPCOV vs Xdebug
PCOV 比 Xdebug 快 5-10 倍,但仅提供行级别的覆盖率。在 CI 环境中推荐使用 PCOV,开发环境中使用 Xdebug(同时提供调试能力)。
PHPUnit 覆盖率配置
xml
<!-- phpunit.xml -->
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/10.5/phpunit.xsd"
bootstrap="vendor/autoload.php"
cacheDirectory=".phpunit.cache">
<source>
<include>
<directory suffix=".php">src</directory>
</include>
<exclude>
<directory suffix=".php">src/Console</directory>
<file>src/Kernel.php</file>
</exclude>
<!-- 限制覆盖率报告的复杂度 -->
<include>
<directory suffix=".php">src/Services</directory>
</include>
</source>
</phpunit>详细说明
生成 HTML 报告
bash
# 生成 HTML 格式的覆盖率报告
./vendor/bin/phpunit --coverage-html coverage/html
# 报告输出到 coverage/html/ 目录
# 用浏览器打开 coverage/html/index.html 查看详细报告生成文本报告
bash
# 控制台输出覆盖率摘要
./vendor/bin/phpunit --coverage-text
# 输出到文件
./vendor/bin/phpunit --coverage-text=coverage/coverage.txt
# 只显示覆盖率百分比
./vendor/bin/phpunit --coverage-text --colors=never生成其他格式
bash
# Clover XML(CI 集成)
./vendor/bin/phpunit --coverage-clover coverage/clover.xml
# Cobertura XML
./vendor/bin/phpunit --coverage-cobertura coverage/cobertura.xml
# PHP 格式(可 include)
./vendor/bin/phpunit --coverage-php coverage/coverage.php
# Crap4J XML
./vendor/bin/phpunit --coverage-crap4j coverage/crap4j.xml代码覆盖率过滤器
xml
<!-- phpunit.xml -->
<?xml version="1.0" encoding="UTF-8"?>
<phpunit>
<source>
<!-- 包含特定文件 -->
<include>
<directory suffix=".php">src</directory>
</include>
<!-- 排除特定文件/目录 -->
<exclude>
<!-- 排除整个目录 -->
<directory>src/Console/Commands</directory>
<!-- 排除特定文件 -->
<file>src/Kernel.php</file>
<!-- 按后缀排除 -->
<file>src/Controllers/HealthController.php</file>
</exclude>
<!-- 限制覆盖率报告深度 -->
<include>
<directory suffix=".php">src/Domain</directory>
</include>
</source>
</phpunit>PHP 代码覆盖率阈值
xml
<!-- phpunit.xml -->
<?xml version="1.0" encoding="UTF-8"?>
<phpunit>
<!-- PHPUnit 10 的覆盖率阈值 -->
<source>
<include>
<directory suffix=".php">src</directory>
</include>
</source>
<!-- 使用 --coverage-* 命令行参数时生效 -->
</phpunit>php
<?php
declare(strict_types=1);
// 通过代码强制检查覆盖率阈值
function checkCoverageThreshold(string $cloverXml, float $threshold = 80.0): void
{
$xml = simplexml_load_file($cloverXml);
$metrics = $xml->project->metrics;
$lineElements = $metrics['elements'] ?? 0;
$coveredElements = $metrics['coveredelements'] ?? 0;
if ($lineElements === 0) {
echo "没有可分析的代码" . PHP_EOL;
return;
}
$coverage = ($coveredElements / $lineElements) * 100;
echo "覆盖率: " . round($coverage, 2) . "%" . PHP_EOL;
echo "阈值: {$threshold}%" . PHP_EOL;
if ($coverage < $threshold) {
echo "覆盖率低于阈值!" . PHP_EOL;
exit(1);
}
echo "覆盖率检查通过" . PHP_EOL;
}实战示例
覆盖率报告分析
php
<?php
declare(strict_types=1);
namespace Tests\Analysis;
/**
* 覆盖率分析工具
* 解析 Clover XML 报告
*/
class CoverageAnalyzer
{
/**
* 解析 Clover XML 并生成摘要
*/
public static function analyzeCloverXml(string $filePath): array
{
$xml = simplexml_load_file($filePath);
if ($xml === false) {
throw new \RuntimeException("无法解析 Clover XML: {$filePath}");
}
$metrics = $xml->project->metrics;
return [
'files' => (int) ($metrics['files'] ?? 0),
'loc' => (int) ($metrics['loc'] ?? 0),
'ncloc' => (int) ($metrics['ncloc'] ?? 0),
'classes' => (int) ($metrics['classes'] ?? 0),
'methods' => (int) ($metrics['methods'] ?? 0),
'covered_methods' => (int) ($metrics['coveredmethods'] ?? 0),
'statements' => (int) ($metrics['statements'] ?? 0),
'covered_statements' => (int) ($metrics['coveredstatements'] ?? 0),
'elements' => (int) ($metrics['elements'] ?? 0),
'covered_elements' => (int) ($metrics['coveredelements'] ?? 0),
'conditionals' => (int) ($metrics['conditionals'] ?? 0),
'covered_conditionals' => (int) ($metrics['coveredconditionals'] ?? 0),
];
}
/**
* 计算覆盖率百分比
*/
public static function calculatePercentages(array $data): array
{
return [
'method_coverage' => self::percent(
$data['covered_methods'],
$data['methods']
),
'statement_coverage' => self::percent(
$data['covered_statements'],
$data['statements']
),
'element_coverage' => self::percent(
$data['covered_elements'],
$data['elements']
),
'conditional_coverage' => self::percent(
$data['covered_conditionals'],
$data['conditionals']
),
];
}
private static function percent(int $covered, int $total): float
{
if ($total === 0) {
return 0.0;
}
return round(($covered / $total) * 100, 2);
}
/**
* 找出覆盖率最低的文件
*/
public static function findLowCoverageFiles(
string $cloverXml,
float $threshold = 50.0
): array {
$xml = simplexml_load_file($cloverXml);
$lowCoverage = [];
foreach ($xml->project->file as $file) {
$metrics = $file->metrics;
$elements = (int) $metrics['elements'];
$coveredElements = (int) $metrics['coveredelements'];
if ($elements === 0) {
continue;
}
$coverage = ($coveredElements / $elements) * 100;
if ($coverage < $threshold) {
$lowCoverage[] = [
'file' => (string) $file['name'],
'coverage' => round($coverage, 2),
'covered' => $coveredElements,
'total' => $elements,
];
}
}
usort($lowCoverage, fn($a, $b) => $a['coverage'] <=> $b['coverage']);
return $lowCoverage;
}
}分层覆盖率报告
php
<?php
declare(strict_types=1);
namespace Tests;
use PHPUnit\Framework\TestCase;
use PHPUnit\TextUI\Configuration\Builder;
/**
* 分层覆盖率报告
* 不同目录设置不同的覆盖率阈值
*/
class TieredCoverageTest extends TestCase
{
private const THRESHOLDS = [
'src/Domain/' => 95.0,
'src/Services/' => 90.0,
'src/Repositories/' => 80.0,
'src/Controllers/' => 60.0,
];
/**
* 检查各层覆盖率是否达标
*/
public function testTieredCoverageThresholds(): void
{
$cloverFile = __DIR__ . '/../coverage/clover.xml';
if (!file_exists($cloverFile)) {
$this->markTestSkipped('覆盖率文件不存在');
}
$xml = simplexml_load_file($cloverFile);
$failures = [];
foreach ($xml->project->file as $file) {
$path = (string) $file['name'];
foreach (self::THRESHOLDS as $dir => $threshold) {
if (str_contains($path, $dir)) {
$metrics = $file->metrics;
$elements = (int) $metrics['elements'];
$coveredElements = (int) $metrics['coveredelements'];
if ($elements > 0) {
$coverage = ($coveredElements / $elements) * 100;
if ($coverage < $threshold) {
$failures[] = sprintf(
'%s: %.1f%% < %.1f%%',
$path,
$coverage,
$threshold
);
}
}
break;
}
}
}
if (!empty($failures)) {
$this->fail("覆盖率阈值检查失败:\n" . implode("\n", $failures));
}
}
}CI 覆盖率门禁
yaml
# .github/workflows/tests.yml
name: Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: '8.1'
coverage: xdebug
tools: phpunit
- name: Install dependencies
run: composer install --prefer-dist --no-progress
- name: Run tests with coverage
run: vendor/bin/phpunit --coverage-clover coverage/clover.xml
- name: Check coverage threshold
run: |
COVERAGE=$(php -r '
$xml = simplexml_load_file("coverage/clover.xml");
$m = $xml->project->metrics;
$e = (int)$m["elements"];
$c = (int)$m["coveredelements"];
echo $e > 0 ? round(($c/$e)*100, 2) : 0;
')
echo "Coverage: ${COVERAGE}%"
# 覆盖率低于 80% 则失败
php -r "exit(${COVERAGE} < 80 ? 1 : 0);"
- name: Upload coverage report
uses: codecov/codecov-action@v3
with:
files: coverage/clover.xml注意事项
不应纳入覆盖率的代码
php
<?php
declare(strict_types=1);
// 以下代码不应追求 100% 覆盖率
// 1. 入口文件
// public/index.php
// 2. 配置文件
// config/app.php
// 3. 第三方代码
// vendor/
// 4. 简单的 getter/setter(值对象除外)
class UserDTO
{
public function __construct(
public readonly string $name,
public readonly string $email,
) {
}
// 不需要单独测试 getter
}
// 5. 框架引导代码
// bootstrap/app.php常见问题
覆盖率为 0%
bash
# 检查覆盖率驱动是否启用
php -m | grep -E "xdebug|pcov"
# Xdebug
php -r "xdebug_info();"
# 确认 Xdebug 模式包含 coverage
php -d "xdebug.mode=coverage" -r "echo 'OK';"
# PHPUnit 配置
./vendor/bin/phpunit --coverage-text 2>&1 | head -20覆盖率数据不准
bash
# 1. 确保使用了 --coverage-* 参数
./vendor/bin/phpunit --coverage-html coverage/
# 2. 确保没有在测试中调用 exit/die
# 3. 确保覆盖率过滤器包含目标文件
# 4. 清除缓存
rm -rf .phpunit.cache
./vendor/bin/phpunit --coverage-text最佳实践
1. 合理设置覆盖率目标
| 模块 | 推荐覆盖率 | 说明 |
|---|---|---|
| 领域模型 | 90-100% | 核心业务逻辑 |
| 服务层 | 80-95% | 业务服务 |
| 数据层 | 70-85% | 仓库/DAO |
| 控制器 | 50-70% | HTTP 层 |
| 工具类 | 85-100% | 通用工具 |
2. 关注分支覆盖率
分支覆盖率比行覆盖率更重要,因为它确保所有条件分支都被测试到。
3. 覆盖率变化监控
bash
# Git Hook:防止覆盖率下降
# .git/hooks/pre-push
#!/bin/bash
./vendor/bin/phpunit --coverage-clover /tmp/coverage.xml
php check-coverage.php /tmp/coverage.xml 80
if [ $? -ne 0 ]; then
echo "覆盖率低于阈值,禁止推送!"
exit 1
fi下一节
继续学习:集成测试