27. 7.3 代码覆盖率

在前两节中,我们已经掌握了如何通过 add_test 组织测试用例,以及如何运用 ctest 命令行工具高效地执行测试、筛选用例和分析结果。如果说“测试通过”证明了代码在功能上的正确性,那么“代码覆盖率”则揭示了这种正确性被验证的程度——你的测试究竟触及了多少行源代码、多少个分支、多少个函数?没有覆盖率数据的支撑,”所有测试都通过了”可能只是一句安慰剂。

CMake 生态与 GCC/Clang 的覆盖率工具链结合得非常紧密。本节将带你完成从编译器插桩、CTest 自动收集,到生成赏心悦目的 HTML 报告,最终对接 SonarQube 等代码质量平台的完整链路。请确保你的系统已安装 gcc/g++(或 Clang)、lcov 以及可选的 gcovr,我们将大量依赖这些工具进行实战演示。

GCov/LCov 工具链与编译器配置

代码覆盖率的基本原理是在编译期向程序中插入“桩代码”(Instrumentation),程序运行时这些桩会记录哪些代码路径被执行。GCC 和 Clang 都内置了这一能力,对应的底层工具是 gcov,而 lcov 则是封装在 gcov 之上的前端工具,负责聚合数据并生成 HTML。

编译器标志详解

要在 CMake 中开启覆盖率插桩,需要同时控制编译选项链接选项。核心标志如下:

  • --coverage:GCC/Clang 的便捷选项,等价于 -fprofile-arcs -ftest-coverage。它告诉编译器生成 .gcno(笔记文件,编译时产生)并在运行时输出 .gcda(数据文件,运行时产生)。
  • -g:保留调试符号。没有行号信息,gcov 无法将执行数据映射回源代码。
  • -O0:关闭优化。高优化级别(如 -O2)可能导致代码重排、内联或消除,使得源代码行号与实际执行流错位,覆盖率数据因此失真。

最忌讳的做法是在全局直接修改 CMAKE_CXX_FLAGS,这会污染所有目标,包括你的 Release 构建。遵循 Modern CMake 的理念,我们应当把这些标志打包进一个 INTERFACE 库(回忆 3.2 节的内容),按需链接到测试目标上。

封装覆盖率配置

在项目的测试目录或顶层 CMakeLists.txt 中,创建一个专用于覆盖率的接口目标:

# option 允许用户在配置时显式开启
option(BUILD_COVERAGE "Build with code coverage instrumentation" OFF)

if(BUILD_COVERAGE)
    if(CMAKE_CXX_COMPILER_ID MATCHES "GNU|Clang")
        add_library(coverage_config INTERFACE)
        
        # 生成期:插桩并保留调试信息
        target_compile_options(coverage_config INTERFACE
            -g
            -O0
            --coverage
        )
        
        # 链接期:链接 gcov 运行时库
        target_link_options(coverage_config INTERFACE --coverage)
        
        message(STATUS "Code coverage enabled for ${CMAKE_CXX_COMPILER_ID}")
    else()
        message(WARNING "Coverage is only supported with GCC or Clang.")
    endif()
endif()

当需要为某个测试目标启用覆盖率时,只需像链接普通库一样操作:

add_executable(test_math test_math.cpp)
target_link_libraries(test_math PRIVATE math_lib coverage_config)

这样,只有明确关联了 coverage_config 的目标才会被打上覆盖率标记,项目的主程序或其他第三方目标完全不受影响。如果你希望所有测试目标统一启用,可以在测试目录的 CMakeLists.txt 中统一链接。

CTest 内置覆盖率支持:ctest -T Coverage

配置好编译器标志并完成构建后,项目中会在编译产物旁生成大量的 .gcno 文件。运行测试时,可执行文件会在同目录下吐出对应的 .gcda 文件。如何收集这些数据?CTest 内置了一个专门的模式:-T Coverage

使用流程

确保你已经在开启 BUILD_COVERAGE 的情况下完成配置与构建:

cmake -B build -DBUILD_COVERAGE=ON -DCMAKE_BUILD_TYPE=Debug
cmake --build build -j$(nproc)

然后,在构建目录中执行:

ctest --test-dir build -T Coverage

CTest 会按以下顺序工作:

  1. 首先执行所有测试用例(等效于 ctest -T Test),确保 .gcda 数据文件被最新生成。
  2. 自动搜索构建树中的 .gcda.gcno 文件。
  3. 调用系统默认的 gcov 程序解析这些文件。
  4. 生成 Coverage.xml(位于 build/Testing/CoverageInfo/)并在终端打印简要的覆盖率统计。

终端输出通常长这样:

   Site: mymachine
   Build name: Linux-g++
Coverage produced path: /home/user/project/build/Testing/CoverageInfo
Coverage command: /usr/bin/gcov
         File: /home/user/project/src/math.cpp
         Lines executed: 85.71% of 14

内置模式的局限

CTest 的覆盖率收集虽然方便,但存在几个明显的局限:其一,它生成的 Coverage.xml 主要是面向 CDash 仪表板的格式,直接在本地阅读体验不佳;其二,它缺乏对系统路径、第三方代码和测试代码本身的过滤能力,导致统计结果中往往混杂了大量 /usr/include 下的标准库头文件数据;其三,它对分支覆盖率(Branch Coverage)的展示非常有限。

因此,在实际工程中,我们通常将 ctest -T Coverage 作为 CI 流水线的快速检查点,而本地开发和详细审阅时,则需要借助 LCov 生成更友好的 HTML 报告——这正是下一节要解决的问题。

生成 HTML 覆盖率报告

LCov 是处理 gcov 数据的行业标准工具,它能生成包含源码高亮、行覆盖率、函数覆盖率的完整 HTML 报告。genhtml 是 LCov 的配套程序,负责将 .info 数据文件渲染成网页。

在 CMake 中集成 LCov 工作流

最优雅的方式是定义一个自定义目标(add_custom_target),把“清空旧数据 → 运行测试 → 收集数据 → 过滤噪音 → 生成网页”这一整条链路封装成一个简单的 make coverage(或 ninja coverage)命令。

find_program(LCOV_EXECUTABLE lcov)
find_program(GENHTML_EXECUTABLE genhtml)

if(LCOV_EXECUTABLE AND GENHTML_EXECUTABLE)
    add_custom_target(coverage
        COMMENT "Generating HTML coverage report..."
        WORKING_DIRECTORY ${CMAKE_BINARY_DIR}
        
        # 1. 清空之前残留的 .gcda 数据,避免多次运行结果累积
        COMMAND ${LCOV_EXECUTABLE} --directory . --zerocounters
        
        # 2. 执行测试套件,生成新的 .gcda 文件
        COMMAND ${CMAKE_CTEST_COMMAND} -C $ --output-on-failure
        
        # 3. 从构建树中捕获覆盖率数据,输出为 coverage.info
        COMMAND ${LCOV_EXECUTABLE} --directory . --capture 
                --output-file coverage.info
        
        # 4. 过滤掉系统头文件、第三方库和测试代码本身
        COMMAND ${LCOV_EXECUTABLE} --remove coverage.info
                '/usr/include/*'
                '/usr/local/include/*'
                '*/third_party/*'
                '*/tests/*'
                '*/build/*'
                '*/_deps/*'
                --output-file coverage_filtered.info
        
        # 5. 生成带有图例和高亮的 HTML 报告目录
        COMMAND ${GENHTML_EXECUTABLE} --legend --highlight 
                --title "${PROJECT_NAME} Coverage Report"
                -o coverage_html coverage_filtered.info
                
        # 6. 输出报告位置提示(CMake 3.18+)
        COMMAND ${CMAKE_COMMAND} -E echo ""
        COMMAND ${CMAKE_COMMAND} -E echo "Coverage report: file://${CMAKE_BINARY_DIR}/coverage_html/index.html"
    )
else()
    message(STATUS "lcov/genhtml not found. Coverage target disabled.")
endif()

使用步骤非常简单:

cmake -B build -DBUILD_COVERAGE=ON
cmake --build build
cmake --build build --target coverage

命令执行完毕后,打开 build/coverage_html/index.html,你会看到一个仪表板,清晰地列出每个文件的行覆盖率(Lines)和函数覆盖率(Functions),点击文件名还能逐行查看哪些代码被执行(青色),哪些未被执行(红色)。

进阶:分支覆盖率

默认情况下,LCov 只统计行覆盖率。如果你希望追踪 if/elseswitch 等分支的覆盖情况,需要在捕获和生成阶段都开启分支覆盖选项:

lcov --rc lcov_branch_coverage=1 --directory . --capture -o coverage.info
genhtml --rc lcov_branch_coverage=1 -o coverage_html coverage_filtered.info

在 CMake 的 add_custom_target 中,只需在相关命令前添加 --rc lcov_branch_coverage=1 即可。

与 SonarQube 及质量平台集成

生成 HTML 报告本地查看固然方便,但在团队协作中,覆盖率数据最终需要汇入统一的代码质量平台。SonarQube 是目前企业中最常用的选择之一,然而 SonarQube 并不直接识别 LCov 的 .info 格式,它需要的是 SonarQube 通用覆盖率 XML 格式。这时候,gcovr 工具就派上了用场。

使用 gcovr 生成 SonarQube XML

gcovr 是一个基于 Python 的覆盖率报告生成器,它能直接读取 .gcda 文件,输出包括 Cobertura、SonarQube、Coveralls 在内的多种格式。首先确保安装:

pip install gcovr

接着,在 CMake 中创建一个新的自定义目标,专门用于生成 SonarQube 所需的 XML:

find_program(GCOVR_EXECUTABLE gcovr)

if(GCOVR_EXECUTABLE)
    add_custom_target(sonar_coverage
        COMMENT "Generating SonarQube coverage XML..."
        WORKING_DIRECTORY ${CMAKE_SOURCE_DIR}
        COMMAND ${GCOVR_EXECUTABLE}
            --sonarqube ${CMAKE_BINARY_DIR}/sonarqube-coverage.xml
            -r ${CMAKE_SOURCE_DIR}
            --filter ${CMAKE_SOURCE_DIR}/src
            --filter ${CMAKE_SOURCE_DIR}/include
            --exclude ${CMAKE_SOURCE_DIR}/tests
            --exclude ${CMAKE_SOURCE_DIR}/third_party
            --exclude ${CMAKE_BINARY_DIR}
            ${CMAKE_BINARY_DIR}
    )
endif()

关键参数解释:

  • --sonarqube:指定输出文件路径,并激活 SonarQube XML 格式。
  • -r:设置项目根目录,确保文件路径在 XML 中是相对于仓库根目录的,这一点在 CI 环境中至关重要。
  • --filter:只保留你关心的源码目录(如 src/include/)。
  • --exclude:排除测试代码和第三方依赖,避免拉低真实业务代码的覆盖率。

生成的 sonarqube-coverage.xml 文件结构大致如下:

<coverage version="1">
  <file path="src/math.cpp">
    <lineToCover lineNumber="12" covered="true"/>
    <lineToCover lineNumber="15" covered="false"/>
  </file>
</coverage>

对接 SonarQube 与 CI 流水线

在 SonarQube 的项目配置文件 sonar-project.properties 中,只需增加一行指向生成的 XML 文件:

sonar.projectKey=my_cpp_project
sonar.sources=src,include
sonar.tests=tests
sonar.coverageReportPaths=build/sonarqube-coverage.xml

在 CI/CD 环境(如 GitHub Actions、GitLab CI 或 Jenkins)中,典型的执行顺序是:

  1. 安装依赖(lcovgcovr)。
  2. 运行 cmake -B build -DBUILD_COVERAGE=ON 并构建。
  3. 执行 cmake --build build --target sonar_coverage(或手动调用 gcovr)。
  4. 启动 SonarQube Scanner,它会自动读取 sonar.coverageReportPaths 并将数据上传到服务器。

除了 SonarQube,gcovr 还支持 --coveralls--cobertura 输出格式,分别对应 Coveralls 和 Jenkins 的覆盖率插件;而 LCov 生成的 .info 文件也能直接上传到 Codecov 平台。根据你团队使用的工具链灵活选择即可。

总结与最佳实践

代码覆盖率不是目的,而是衡量测试有效性的手段。在 CMake 项目中集成覆盖率工具链时,请遵循以下原则:

  • 隔离配置,绝不污染全局:始终通过 INTERFACE 库或 target_* 命令附加覆盖率标志,避免修改全局 CMAKE_CXX_FLAGS,确保 Release 构建干净且高性能。
  • 专用配置,避开优化陷阱:覆盖率构建应强制使用 -O0 -g。你可以创建一个自定义的 CMake 构建类型 Coverage(参考 2.4 节),在其中预设这些标志。
  • 先清零,再收集:多次运行测试而不清理 .gcda 文件会导致数据累积失真。lcov --zerocounters 是正式收集前不可或缺的一步。
  • 精准过滤,关注核心代码:系统头文件、生成的代码、第三方依赖和测试框架本身都不应纳入覆盖率统计,否则会得到虚假的高覆盖率或极低的覆盖率。
  • 本地 HTML,远端 XML:开发阶段使用 LCov + genhtml 本地审阅;CI/CD 阶段使用 gcovr 生成 SonarQube/CDash 等平台的 XML,实现质量门禁。

至此,你已经拥有了一套完整的 CMake 测试与质量保障体系:从 add_test 组织用例,到 ctest 并行执行,再到覆盖率插桩、报告生成与平台集成。下一节我们将继续提升代码质量,探讨如何将静态分析与动态分析(如 AddressSanitizer、Clang-Tidy)无缝融入 CMake 构建流程。

请登录后发表评论

    没有回复内容

正在唤醒异次元光景……