Skip to content

代码覆盖率

概述

代码覆盖率(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 xdebug

PCOV 驱动(推荐用于 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 pcov

PCOV 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

下一节

继续学习:集成测试

参考链接