第9章:单元测试:gtest 与覆盖率

代码有没有毛病,不是你觉得,是测试说了算——这一章给代码立规矩、上枷锁,再用量化报告看看你的测试到底覆盖了多少

⚖️

本章导师:包青天

核心方法论:铁面无私

「升堂!本府断案,不看交情,只看证据。代码也一样——它到底有没有毛病,不是你觉得,是测试说了算。这一章,我教你给每一段代码立规矩:一条断言就是一道铁证,一份覆盖率报告就是案卷的完整度。代码在你心里没毛病?先过了本府这一关。」

9.1 零基础铺垫:单元测试是什么,为什么需要

先给概念定个调。单元测试(unit test),说人话,就是"对程序里最小的代码单元——一个函数、一个类的一个方法——写一段自动化验证程序,用断言检查它的行为是否符合预期"。这里的关键词是断言(assertion):一条"某个条件必须成立"的声明,比如"add(2,3) 必须等于 5"。断言成立,测试通过;断言不成立,测试失败并报出详细信息。单元测试的价值在于自动化:写一次,之后每次改动代码,跑一遍就知道有没有弄坏东西,不需要人肉去点、去看、去试。

为什么需要它?最核心的两个理由是回归保护重构安全网。回归(regression),说人话就是"以前好的功能,改了别的代码之后悄悄变坏了"——没有测试的话,这种坏往往要等上线后被用户发现;有了测试,改完代码跑一遍,红了就当场抓住。重构(refactor),说人话就是"不改变功能、只重写内部实现"——没有测试兜底,谁也不敢动自己写过的代码,因为不知道会碰坏什么;有测试,你就有底气把烂代码翻新。此外,测试还是一份活的文档:别人看你的测试,就知道你的类该怎么构造、方法该传什么参数、边界情况是什么——比注释可靠得多。

你可能说:验证功能,我直接在 main 里写 if 判断不就行了?早期的确有人这么干,但它的毛病很明显:验证代码和业务代码搅在一起、失败信息不统一、跑完没有任何统计(跑了几个、过了几个)、更没有人知道你测了什么、没测什么。正如我们在实践中的真实困惑:"做好了单元测试,但别人并不知道我们的单元测试做得如何,是否覆盖了所有需要被测试的类方法或者变量"——这正是本章要解决的两个问题:怎么把测试写规范(gtest),怎么证明覆盖得够不够(lcov 覆盖率)。

// 手工验证的原始形态:把断言写死在 main 里
#include <cassert>
#include <iostream>
#include "calculator.h"

int main() {
    Calculator calc;
    assert(calc.add(2, 3) == 5);      // 失败直接崩溃,只有一行信息
    assert(calc.subtract(5, 3) == 2);
    std::cout << "全部通过!" << std::endl;
    return 0;   // 跑了几条?失败了哪条?没人知道
}
// 同样的验证,用 gtest 来写:每条断言独立、失败信息完整、自动统计
#include <gtest/gtest.h>
#include "calculator.h"

TEST(CalculatorTest, Add) {
    Calculator calc;
    EXPECT_EQ(calc.add(2, 3), 5);   // 失败会打印期望值/实际值,但不中断
}
包青天提示

铁面无私第一课:断言必须可证伪。像 EXPECT_TRUE(1 == 1) 这种恒真的断言毫无价值——它永远不会失败,也就永远不会帮你抓住 bug。合格的断言是那种"如果代码有毛病就一定会失败"的条件,比如拿一个边界值、一个异常输入去撞被测函数。

9.2 gtest 是什么:Google 开源的测试框架

gtest(正式名 GoogleTest)是 Google 开源的 C++ 单元测试框架,官方文档在 google.github.io/googletest/,源码托管在 github.com/google/googletest。它提供了写测试所需的三样核心武器:断言宏(EXPECT_/ASSERT_ 系列,负责验证)、测试组织(TEST/TEST_F 宏,负责把测试分组登记)、运行与统计(RUN_ALL_TESTS,负责跑完所有测试并汇报通过/失败数量)。此外它还支持死亡测试(death test,验证程序应当崩溃的场景)、参数化测试(同一套断言喂多组数据)等高级特性——本章先掌握核心,剩下的等你需要时再看文档。

gtest 编译后分成两个链接目标,这个区分要记住:gtest 是框架本体(只有断言、宏、运行机制),gtest_main 是一个自带 main 入口的小库——链接它之后你连 main 函数都不用写,它会自动调用 RUN_ALL_TESTS() 并返回退出码。新手阶段直接链接 gtest_main 最省事,9.3 我们再揭开它的 main 到底写了什么。

安装沿用第 5 章的家伙——vcpkg:一条命令搞定下载、编译、配置。装完头文件会出现在安装目录的 include/gtest/ 下,CMake 集成我们在 9.5 讲。不想用 vcpkg 的话还有两条路:Debian/Ubuntu 上 sudo apt install libgtest-dev;或者用 CMake 官方推荐的 FetchContent 方式直接在构建时从 GitHub 拉源码编译(GoogleTest 官方 quickstart 就是这么演示的)。

# 用 vcpkg 安装 gtest(经典模式,延续第 5 章)
./vcpkg/vcpkg install gtest

# 验证头文件就位
ls vcpkg_installed/x64-linux/include/gtest/gtest.h
# 不用 vcpkg 的替代方案:CMake FetchContent 拉源码编译(GoogleTest 官方 quickstart 方式)
include(FetchContent)
FetchContent_Declare(
  googletest
  URL https://github.com/google/googletest/archive/03597a01ee50ed33e9dfd640b249b4be3799d395.zip
)
# Windows 下避免覆盖父项目的编译/链接设置(官方模板原样保留)
set(gtest_force_shared_crt ON CACHE BOOL "" FORCE)
FetchContent_MakeAvailable(googletest)
包青天提示

FetchContent 里的 URL 指向一个固定的 commit 哈希——官方建议经常更新哈希以跟随最新版本。vcpkg 和 FetchContent 二选一即可,别同时用,否则可能出现"两份 gtest、链接打架"的怪问题。断案要人证物证齐全,依赖也得来源唯一。

9.3 第一个测试:TEST 宏与 EXPECT_/ASSERT_ 断言

gtest 里登记一个测试用 TEST(TestSuiteName, TestName) 宏:第一个参数是测试套件名(逻辑分组,比如 CalculatorTest),第二个参数是测试名(比如 Add)。两者拼起来构成测试的唯一标识。同一套件的测试共享命名,但每个测试独立运行、互不干扰——一个失败不会连累其他测试。我们先写一个要被测的 Calculator 类,再为它写测试。

断言是测试的"铁证",gtest 把它们分成两大家族EXPECT_ 系列失败后记录错误但继续执行当前测试(非致命),方便一次跑出尽量多的失败点;ASSERT_ 系列失败后立即中止当前测试(致命)。选型原则一句话:如果后面语句的执行依赖这条断言的结果,就用 ASSERT_,否则用 EXPECT_。比如断言指针非空之后要解引用它,就必须用 ASSERT_NE(ptr, nullptr),否则解引用空指针会直接段错误,连错误报告都看不到。常用成员:EXPECT_EQ/EXPECT_NE(相等/不等)、EXPECT_TRUE/EXPECT_FALSE(布尔)、EXPECT_THROW(应抛异常)、EXPECT_STRNE(C 字符串不等)。注意参数顺序:EXPECT_EQ(实际值, 期望值)——第一个是被测代码的输出,第二个是你期望的值

谁调用这些测试?入口是 RUN_ALL_TESTS()。链接 gtest_main 时它替你写好了 main;手动写也很简单:先 testing::InitGoogleTest(&argc, argv) 解析命令行参数(gtest 支持 --gtest_filter 等调试参数),再 return RUN_ALL_TESTS()——全部通过返回 0,有失败返回非 0,这个退出码就是给 CI(持续集成,说人话:自动构建+自动跑测试的流水线)看的。

// calculator.h —— 被测单元:一个最简计算器类
#pragma once
#include <stdexcept>

class Calculator {
public:
    int add(int a, int b) { return a + b; }
    int subtract(int a, int b) { return a - b; }
    double divide(double a, double b);   // 除零时抛 std::invalid_argument
};
// test_calculator.cpp —— 第一个 gtest 测试文件
#include <gtest/gtest.h>
#include "calculator.h"

TEST(CalculatorTest, Add) {
    Calculator calc;
    EXPECT_EQ(calc.add(2, 3), 5);        // 实际值在前,期望值在后
    EXPECT_EQ(calc.add(-1, 1), 0);       // 一个测试里可以写多条断言
}

TEST(CalculatorTest, Subtract) {
    Calculator calc;
    EXPECT_EQ(calc.subtract(5, 3), 2);
    EXPECT_TRUE(calc.subtract(3, 5) < 0);   // 结果为负也符合预期
}
// main.cpp —— 不链接 gtest_main 时的手动入口(gtest_main 内部干的就是这件事)
#include <gtest/gtest.h>

int main(int argc, char** argv) {
    testing::InitGoogleTest(&argc, argv);
    return RUN_ALL_TESTS();   // 跑全部测试;0 = 全部通过
}
// 异常测试:验证"除零必须抛异常"这个契约
TEST(CalculatorTest, DivideByZero) {
    Calculator calc;
    // 如果 divide 没有抛 invalid_argument,这条断言就失败——可证伪的断言
    EXPECT_THROW(calc.divide(1.0, 0.0), std::invalid_argument);
}
包青天提示

EXPECT_EQ 的两个参数顺序别写反:第一个是实际值(被测代码的输出),第二个是期望值。写反了测试照样能跑,但失败时打印的 "Expected / Actual" 是颠倒的,排查时容易看岔——铁证也要摆放整齐,方便对质。

9.4 测试夹具:TEST_F 与 SetUp/TearDown

9.3 里每个测试都要自己 Calculator calc; 构造一次。测试一多,你会发现大量测试共享相同的初始化逻辑——同样的对象、同样的临时文件、同样的数据库连接。重复粘贴初始化代码既啰嗦又危险(改一处忘一处)。gtest 的测试夹具(fixture)就是为这个场景设计的:写一个继承 ::testing::Test 的类,把公共初始化放进 SetUp(),把清理放进 TearDown(),测试函数里直接用夹具的成员。

夹具的运行机制是"每个测试独立开堂":每跑一个 TEST_F,gtest 都新建一个夹具实例 → 调用 SetUp() → 执行测试体 → 调用 TearDown() → 销毁实例。也就是说,测试之间通过夹具共享的是"写法",不是"状态"——上一条测试对成员变量的修改绝不会泄漏到下一条,这就是测试隔离。注意 TEST_F(FixtureClassName, TestName)第一个参数必须是夹具类名(不是测试套件名,写错会编译报错)。夹具成员通常放在 protected 区,测试体里可以直接访问。

// calculator_fixture.h —— 定义夹具:继承 ::testing::Test
#include <gtest/gtest.h>
#include "calculator.h"

class CalculatorTest : public ::testing::Test {
protected:
    void SetUp() override {
        calc = new Calculator();   // 每个测试开始前执行一次
    }
    void TearDown() override {
        delete calc;                 // 每个测试结束后执行一次
    }
    Calculator* calc;                // protected 成员,测试体可直接访问
};
// 使用夹具:TEST_F 的第一个参数必须是夹具类名
TEST_F(CalculatorTest, Add) {
    EXPECT_EQ(calc->add(2, 3), 5);
}

TEST_F(CalculatorTest, Subtract) {
    EXPECT_EQ(calc->subtract(10, 4), 6);
}

// 运行输出(示意):每个测试都走一遍 SetUp → 测试体 → TearDown
// [ RUN      ] CalculatorTest.Add
// [       OK ] CalculatorTest.Add (0 ms)
// [ RUN      ] CalculatorTest.Subtract
// [       OK ] CalculatorTest.Subtract (0 ms)
包青天提示

现代 C++ 里,如果夹具成员是 Calculator calc; 这样的栈对象,构造和析构自动发生在每个测试的起止,TearDown 甚至可以省略;只有当成员需要手动管理资源(new/delete、打开文件、连接网络)时,SetUp/TearDown 才有用武之地。能用 RAII 就不用写清理代码——这也是工程判断力的一部分。

9.5 CMake 集成:find_package(GTest) 与 ctest

测试文件写好了,怎么把它编进工程?在 CMakeLists.txt 里做三件事:第一,find_package(GTest REQUIRED)——找到 gtest 的头文件路径和库。因为第 5 章配置的 vcpkg 工具链文件会把 installed/ 目录自动加进搜索路径,所以装过 gtest 之后这行直接就能命中,不用手写任何路径。第二,把测试可执行文件链接到 GTest::gtest_main(现代 CMake 的 imported target,自带 main 入口)。第三,enable_testing() 打开 CTest 支持,再用 gtest_discover_tests() 让 ctest 自动扫描测试二进制里所有 TEST/TEST_F 并逐个注册——这样每个测试都是 ctest 里的独立用例,失败时定位精确到测试名。

ctest 是 CMake 自带的测试运行器(说人话:一个统一的"跑所有测试"的命令)。配好后两条命令起飞:cmake --build build 编译,然后 ctest --test-dir build --output-on-failure 跑全部测试。ctest 的好处是工程里有多个测试二进制时统一调度、统一报告,CI 里也用它。想只跑某个测试?直接运行测试二进制并加 gtest 的过滤参数,比如 ./build/test_calculator --gtest_filter=CalculatorTest.Add

# CMakeLists.txt —— 被测库 + 测试可执行文件 + CTest 集成
cmake_minimum_required(VERSION 3.14)
project(calculator CXX)

set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)

# 找到 gtest(vcpkg 工具链自动提供搜索路径)
find_package(GTest REQUIRED)

# 被测代码编成静态库,测试文件链接它
add_library(calculator STATIC calculator.cpp calculator.h)
target_include_directories(calculator PUBLIC ${CMAKE_CURRENT_SOURCE_DIR})

# 打开 CTest,注册测试可执行文件
enable_testing()

add_executable(test_calculator test_calculator.cpp)
target_link_libraries(test_calculator PRIVATE calculator GTest::gtest_main)

# 自动发现 test_calculator 里所有 TEST/TEST_F 并注册为 ctest 用例
include(GoogleTest)
gtest_discover_tests(test_calculator)
# 构建并运行测试
cmake -B build
cmake --build build

# 方式一:ctest 统一跑(推荐,CI 里也是这么跑的)
ctest --test-dir build --output-on-failure

# 方式二:直接运行测试二进制(可用 --gtest_filter 只跑某一条)
./build/test_calculator --gtest_filter=CalculatorTest.Add
# 直接运行的输出(gtest 真实格式)
[==========] Running 3 tests from 1 test suite.
[----------] Global test environment set-up.
[ RUN      ] CalculatorTest.Add
[       OK ] CalculatorTest.Add (0 ms)
[ RUN      ] CalculatorTest.Subtract
[       OK ] CalculatorTest.Subtract (0 ms)
[ RUN      ] CalculatorTest.DivideByZero
[       OK ] CalculatorTest.DivideByZero (0 ms)
[----------] 3 tests from 1 test suite ran. (1 ms total)
[  PASSED  ] 3 tests.

注意:如果 find_package(GTest) 找不到包,先确认两件事:gtest 真的装了吗(./vcpkg/vcpkg list | grep gtest);配置 CMake 时是否带了 vcpkg 工具链参数(-DCMAKE_TOOLCHAIN_FILE=.../vcpkg.cmake)。工具链没带的话,CMake 根本不知道去哪找 vcpkg 装的库——这是第 5 章就埋下的伏笔,现在该还了。

9.6 覆盖率:gcov/lcov/genhtml 实战

测试全绿,就万事大吉了吗?回到本章开头那句真实困惑:"做好了单元测试,但别人并不知道我们做得如何,是否覆盖了所有需要被测试的类方法或者变量"。覆盖率(coverage)就是量化这个问题的指标。它分两个常见口径:行覆盖(line coverage),说人话就是"被测代码里有多少行真的被执行过",比如一个 100 行的文件跑了 80 行,行覆盖就是 80%;分支覆盖(branch coverage),说人话就是"if/switch 的每个分支是否都被走到"——代码里藏着 if (a) {...} else {...},只测了 a 为真的路,else 那条就是漏网之鱼。覆盖率报告告诉你的是:哪些代码被测试摸过,哪些从来没被摸到

工具链是三件套:gcov 是 GCC 自带的覆盖率工具(编译时插桩记录,运行时统计);lcov 是 gcov 的前端封装,把原始数据整理成人类可读的报告;genhtml 是 lcov 包里负责把统计数据渲染成 HTML 报告的工具。lcov 的安装:官网 http://ltp.sourceforge.net/coverage/lcov.php 提供 rpm 包和源码包(源码包解压后 make install,然后 lcov -v 能输出版本号即成功);Debian/Ubuntu 上更省事的是 sudo apt install lcov

整个流程分三步,每一步的产物要对得上:① 编译插桩——给编译器加 --coverage 标志(等价于 -fprofile-arcs -ftest-coverage,即"记录每行执行次数 + 记录分支跳转"),编译后每个源文件旁生成 .gcno 文件(记录代码结构,编译期产生);② 运行测试——跑测试程序,结束后生成 .gcda 文件(记录实际执行情况,运行期产生,与 .gcno 一一对应);③ 汇总出报告——lcov 采集 .gcda/.gcno 生成 .info 统计文件,genhtml 渲染成 HTML 报告。CMake 里通常用开关控制是否插桩(素材验证过的真实配置如下,原样保留)。

# CMakeLists.txt —— 覆盖率开关(真实项目验证过的配置)
# coverage option
OPTION(ENABLE_COVERAGE "Use gcov" ON)
MESSAGE(STATUS "ENABLE_COVERAGE=${ENABLE_COVERAGE}")
IF(ENABLE_COVERAGE)
    SET(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -fprofile-arcs -ftest-coverage")
    SET(CMAKE_C_FLAGS "${CMAKE_C_FLAGS} -fprofile-arcs -ftest-coverage")
    SET(CMAKE_EXE_LINKER_FLAGS "${CMAKE_EXE_LINKER_FLAGS} -fprofile-arcs -ftest-coverage")
ENDIF()
# ① 开覆盖率开关编译 → 生成 .gcno;② 跑测试 → 生成 .gcda
cmake -B build -DENABLE_COVERAGE=ON
cmake --build build

# 必须让进程正常退出(return / exit),统计才会写入 .gcda
./build/test_calculator

# 查看插桩产物:.gcno(编译期)与 .gcda(运行期)成对出现
find build -name "*.gcno" -o -name "*.gcda"

这里有一个实践里踩过的大坑,务必记住:覆盖率统计只有在进程正常退出时才会落盘。如果进程不是通过调用 exit 或从 main 正常 return 结束(比如后台服务被 kill 强杀),.gcda 文件根本不会产生,自然也就没有覆盖率报告。所以测覆盖率时,测试程序必须跑完自然结束——这也是为什么"能自动退出"的单元测试程序比"常驻服务"更适合做覆盖率统计对象。

采集与出报告的命令以素材里验证过的为准:在 build 目录下用 lcov -c(--capture 的简写,采集)汇总出 .info 文件,再用 genhtml 生成 HTML 报告。报告默认输出一个 index.html 总览页,能看到每个文件、每个函数的覆盖率百分比和红绿标记,点进文件还能看到具体哪一行没被执行。系统头文件和第三方 include 目录会拉低数字,用 lcov --remove 把它们过滤掉再看真实覆盖率。

# ③ 采集覆盖率(素材验证过的命令:-d 数据目录,-c 采集,-o 输出,-b 基准目录)
cd build
lcov -d . -t test -o test.info -b . -c --no-external

# 等价写法:--capture/--directory 长参数版
lcov --capture --directory . --output-file test.info --no-external
# 过滤掉系统头文件与第三方 include,再看"自己的代码"的真实覆盖率
lcov --remove test.info '/usr/*' '*/inc/*' -o finalresult.info

# genhtml 生成 HTML 报告(-o 指定输出目录)
genhtml -o report finalresult.info

# 浏览器打开 report/index.html:总览 → 点文件 → 看红绿行标记
open report/index.html
包青天提示

覆盖率数字不是目标,是线索。100% 行覆盖也不代表没有 bug——只调用不验证的"空转测试"照样能刷满数字;但 30% 的覆盖率说明你的大部分代码从未被测试摸过,那才是真正危险的信号。断案的智慧在于:低覆盖率必须解释,高覆盖率也值得抽查,数字背后的人(谁写的测试、验证了什么)比数字本身重要。

章末练习

练习 1:概念四连 入门

用一句话分别解释:单元测试断言行覆盖分支覆盖

提示

四个概念对应"验证 / 条件声明 / 行 / 分支"四个视角,想想每个词回答的是"什么"问题。

参考答案

单元测试:对最小代码单元(函数/类方法)写自动化验证程序;断言:一条"某条件必须成立"的声明,成立则通过、不成立则失败;行覆盖:测试运行中实际被执行过的代码行占总行数的比例;分支覆盖:if/switch 的各个分支是否都被走到。

练习 2:EXPECT_ 还是 ASSERT_? 入门

EXPECT_EQASSERT_EQ 行为上有什么区别?在什么场景下必须用 ASSERT_ 系列?

提示

想想"失败后是继续跑还是停下来",再想想哪种失败会让后续语句没法安全执行。

参考答案

EXPECT_ 失败后记录错误并继续执行当前测试;ASSERT_ 失败后立即中止当前测试。当后续语句的执行依赖当前断言的结果时必须用 ASSERT_——比如断言指针非空后要解引用它(否则空指针解引用直接崩溃),或断言对象已初始化后要调用它的方法。

练习 3:补全异常测试 进阶

Calculator 的 divide(a, b) 约定:b 为 0 时抛出 std::invalid_argument,正常时返回 a/b。请用 gtest 补全测试文件:一个用例测正常除法(含期望值 EXPECT_EQ(divide(10, 4), 2.5)),一个用例用 EXPECT_THROW 测除零。

提示

两个 TEST,套件名用 CalculatorTest;注意 divide 参数是 double,10/4 不会整除以 0;除零用例传 0.0

参考答案
TEST(CalculatorTest, Divide) {
    Calculator calc;
    EXPECT_EQ(calc.divide(10.0, 4.0), 2.5);
    EXPECT_EQ(calc.divide(1.0, 3.0) * 3.0, 1.0);  // 浮点误差容忍度自己把握
}

TEST(CalculatorTest, DivideByZero) {
    Calculator calc;
    EXPECT_THROW(calc.divide(1.0, 0.0), std::invalid_argument);
}

练习 4:完整实战流水线 挑战

从零走完一整条流水线:① vcpkg 装 gtest;② 写一个你自己的类(比如一个字符串工具类)和覆盖主要方法的测试;③ CMake 集成(find_package + gtest_discover_tests)并用 ctest 跑通;④ 打开 ENABLE_COVERAGE 开关重新编译运行;⑤ 用 lcov + genhtml 生成覆盖率报告;⑥ 打开 report/index.html,指出哪个文件覆盖率最低,并补一个测试把它的行覆盖提上去。把每一步命令和遇到的坑记录下来。

提示

安装见 9.2;测试写法见 9.3/9.4;CMake 集成见 9.5;覆盖率开关和命令见 9.6。坑位预告:忘记加 vcpkg 工具链参数会导致 find_package 找不到;测试程序没跑完就 Ctrl+C 会导致 .gcda 缺失;没过滤 /usr/* 时覆盖率会被系统头文件拉低。

参考答案

核心命令链:① ./vcpkg/vcpkg install gtest;② 写好 myutil.h + test_myutil.cpp;③ cmake -B build -DCMAKE_TOOLCHAIN_FILE=/path/to/vcpkg/scripts/buildsystems/vcpkg.cmake,CMakeLists 里 find_package(GTest REQUIRED) + target_link_libraries(test_myutil PRIVATE GTest::gtest_main) + gtest_discover_tests(test_myutil),然后 ctest --test-dir build --output-on-failure;④ cmake -B build -DENABLE_COVERAGE=ON && cmake --build build && ./build/test_myutil;⑤ cd build && lcov -c -o test.info -b . -d . && lcov --remove test.info '/usr/*' -o final.info && genhtml -o report final.info;⑥ 看 report/index.html 里红色/低百分比文件,为没测到的方法补 TEST 后重跑 ④⑤。整个过程就是:测试保功能、覆盖率保完整。