15
单元测试:GoogleTest、Catch2 与覆盖率
Unit Testing · GoogleTest / gMock / Catch2 / CMake + CTest
C++ 里"改一行崩三处"是常态,因为编译器不管你内存用得对不对、也没人拦你写出平台相关的行为。测试是 C++ 项目里唯一能让你敢于重构的东西。这一章不讲大道理,直接讲怎么落地:GoogleTest 怎么写、Fixture 和参数化怎么用、gmock 为什么必须先做依赖注入、Catch2 什么时候更合适、怎么接进 CMake 和 CI、覆盖率怎么看、以及那些"看起来有测试其实等于没有"的坑。
为什么 C++ 项目比别的语言更需要测试
不是"测试是好习惯"这种空话,而是 C++ 有几个客观特性决定了测试的性价比特别高。
论四个客观原因
① 未定义行为(UB)不会报错,只会"看起来正常":越界读一个字节、释放后继续用,代码可能跑出正确结果,也可能在换了个编译器版本、开了 -O2 之后突然崩。UB 的特点是"随机爆发",而测试是唯一能在可控条件下把它逼出来的手段(配合第 16 章的 Sanitizers 效果更好)。
② 编译能过 ≠ 行为正确:类型系统管得了类型,管不了逻辑。一个把 a - b 写成 b - a 的错误,编译器完全无法察觉。
③ 平台与 ABI 差异大:Windows 上 long 是 4 字节,Linux 64 位上常常是 8 字节;结构体对齐规则不同。同一份代码在不同平台的字节布局可能不一样,只有测试能发现"在 Mac 上跑得对在 Linux 上错"。
④ 重构的底气:C++ 项目最容易变成"谁都不敢动"的祖传代码。有了测试,你才能放心地把裸指针换成智能指针、把继承换成组合。测试不是额外成本,它是让你敢于做正确设计的许可证。
| 测试类型 | 测什么 | 典型工具 / 成本 |
| 单元测试 | 单个函数/类的行为,不依赖真实 IO,毫秒级 | GoogleTest、Catch2、doctest。跑几千个也就几秒,每次提交都跑。 |
| 集成测试 | 多个模块/真实文件/网络协作 | 同样用上述框架,但需要准备环境。适合夜间构建跑。 |
| 接口契约测试 | 对外二进制接口、序列化格式是否兼容 | ABI 兼容性测试、golden file 对比。 |
| 模糊测试 | 对解析器/协议实现喂随机输入 | libFuzzer、AFL。找边界崩溃的第一利器。 |
注意顺序:先把单元测试写起来,这是投入产出比最高的一层。不要一开始就搭复杂的测试体系,也不要追求"测试覆盖率 100%"——那是数字游戏。
GoogleTest 基础:TEST、TEST_F 与两类断言
GoogleTest(gtest)是目前 C++ 项目里最主流的测试框架:生态成熟、断言信息详细、和 CMake 集成极顺、还自带 Mock 组件。核心 API 只有四五个,但每个的语义要搞准。
第一个测试文件:TEST 与 EXPECT / ASSERT 的分工
#include <gtest/gtest.h>
#include "stack.h"
// TEST(测试套件名, 用例名) —— 这是最简单的一种测试
TEST(StackTest, 新栈应该是空的) {
Stack s;
EXPECT_TRUE(s.empty());
EXPECT_EQ(s.size(), 0);
}
TEST(StackTest, 压入再弹出应该还原) {
Stack s;
s.push(10);
s.push(20);
// ASSERT:失败就立刻结束本用例。用在这里是因为后面还要 pop
// 如果这个断言失败,继续 pop 会访问越界,产生一堆噪音错误
ASSERT_EQ(s.size(), 2);
// EXPECT:失败会记录但继续往下跑,能一次看到所有问题
EXPECT_EQ(s.pop(), 20);
EXPECT_EQ(s.pop(), 10);
EXPECT_TRUE(s.empty());
}
// 断言失败时可以附加说明,输出里会带上这行信息
TEST(StackTest, 容量应该反映实际元素个数) {
Stack s;
s.push(1);
EXPECT_EQ(s.size(), 1) << "刚压入一个元素,size 应该是 1";
}
// 浮点比较千万别用 EXPECT_EQ:0.1+0.2 != 0.3
TEST(FloatTest, 浮点要用近似比较) {
double r = 0.1 + 0.2;
EXPECT_DOUBLE_EQ(r, 0.30000000000000004); // 允许 4 个 ULP 误差
EXPECT_NEAR(r, 0.3, 1e-9); // 明确给出容差,更推荐
}
| 断言 | 语义 | 什么时候用 |
EXPECT_* | 失败时记录错误但继续执行后面的语句 | 默认用它。一个用例里能看到所有失败点,定位效率高。 |
ASSERT_* | 失败时立即从当前函数返回,后续语句不执行 | 后续代码依赖这个断言成立才有意义时(比如指针非空、容器大小正确),否则会越界崩掉。 |
EXPECT_EQ / NEAR / TRUE / FALSE | 等于 / 近似等于 / 真 / 假 | 最常用的四个,优先用语义明确的 EXPECT_EQ 而不是 EXPECT_TRUE(a == b),失败时能打印实际值。 |
EXPECT_THROW / NO_THROW / ANY_THROW | 表达式是否抛出(指定/不抛/任意)异常 | 验证错误处理路径。 |
EXPECT_FLOAT_EQ / DOUBLE_EQ | 按 ULP 允许极小误差 | 浮点计算的默认选择。 |
EXPECT_TRUE(a == b) 是最常见的写法浪费
写成 EXPECT_TRUE(s.size() == 2) 时,失败信息只会告诉你"期望 true、实际 false",不告诉你是 3 还是 0,你还得加日志重跑。改成 EXPECT_EQ(s.size(), 2),失败信息直接是 Expected: 2, Actual: 3,一眼看出问题。能明确语义的断言就用明确的,这是提高排查效率的最低成本手段。
Fixture:给一组测试准备共同的现场
如果多个用例都要"先建一个装了三个元素的栈",把这段准备代码抄十遍是灾难——改一次要改十处。Fixture 就是把这些共用准备/收尾逻辑抽出来。
TEST_F:继承 ::testing::Test,在 SetUp / TearDown 里准备与清理
#include <gtest/gtest.h>
#include <memory>
// 1) 定义 Fixture:类名随便起,但要继承 ::testing::Test
class LoggerFixture : public ::testing::Test {
protected:
// 每个 TEST_F 执行前都会调用一次(注意:是每个用例一次,不是整个套件一次)
void SetUp() override {
logger_ = std::make_unique<Logger>("test.log");
logger_->setLevel(Level::DEBUG);
}
// 每个用例结束后调用,用来清理资源
void TearDown() override {
logger_.reset(); // 关文件、释放资源
std::remove("test.log"); // 清掉临时文件,避免互相干扰
}
// Fixture 的成员:TEST_F 里可以直接用
std::unique_ptr<Logger> logger_;
};
// 2) TEST_F(夹具类名, 用例名):注意第一个参数必须是 Fixture 类名
TEST_F(LoggerFixture, 低于阈值的日志不应该被记录) {
logger_->log(Level::TRACE, "trace 细节");
EXPECT_EQ(logger_->lineCount(), 0);
}
TEST_F(LoggerFixture, 达到阈值的日志应该被记录) {
logger_->log(Level::INFO, "开始处理");
EXPECT_EQ(logger_->lineCount(), 1);
}
// 3) 全局级别的准备/清理:整个测试进程只跑一次,适合开销很大的初始化
class EnvFixture : public ::testing::Environment {
public:
void SetUp() override { /* 比如启动一次嵌入式数据库 */ }
void TearDown() override { /* 关掉它 */ }
};
int main(int argc, char** argv) {
::testing::InitGoogleTest(&argc, argv);
::testing::AddGlobalTestEnvironment(new EnvFixture);
return RUN_ALL_TESTS();
}
参数化测试与死亡测试:批量跑同逻辑 + 验证"应该崩"
// ---------- 参数化测试:同一套逻辑跑多组输入 ----------
// 1) 继承 TestWithParam<参数类型>
class IsPrimeTest : public ::testing::TestWithParam<std::pair<int, bool>> {};
// 2) 用 TEST_P 写用例,参数用 GetParam() 取
TEST_P(IsPrimeTest, 判断结果应该符合预期) {
auto [value, expected] = GetParam();
EXPECT_EQ(isPrime(value), expected)
<< "输入 = " << value;
}
// 3) 用 INSTANTIATE_TEST_SUITE_P 提供参数组合
INSTANTIATE_TEST_SUITE_P(
常见数值,
IsPrimeTest,
::testing::Values(
std::make_pair(1, false),
std::make_pair(2, true),
std::make_pair(17, true),
std::make_pair(100, false)
),
// 给每组参数生成可读的用例名,失败时一看就知道是哪组数据
[](const ::testing::TestParamInfo<std::pair<int, bool>>& info) {
return "v" + std::to_string(info.param.first);
}
);
// ---------- 死亡测试:验证"这段代码应该让程序终止" ----------
// 用于断言:非法输入必须 assert、越界访问必须 abort,而不是静默地跑错
TEST(SafetyTest, 越界访问应该触发断言) {
ASSERT_DEATH({
std::vector<int> v{1, 2, 3};
v.at(10); // at() 会抛异常,不是死亡
}, ""); // 空正则可匹配任意输出
}
TEST(SafetyTest, 文档约定的非法参数应该终止进程) {
EXPECT_DEATH(initialize(-1), "level must be positive");
}
// 注意:死亡测试会 fork 子进程,不能和线程、以及很多全局状态一起用
// 另外:NDEBUG(Release)下 assert 会被编译掉,死亡测试会失败——只在 Debug 构建里跑
Fixture 的两个容易误解的地方
误解一:以为 SetUp 在整个测试套件里只跑一次。不是。每个 TEST_F 用例都会重新构造 Fixture 对象、重新调用 SetUp。这样做是为了用例之间完全隔离,是好事。但如果你在 SetUp 里做了很重的初始化(比如启动数据库),几十个用例就会重复几十次,测试会变得很慢。那种情况应该用 SetUpTestSuite 或全局 Environment。
误解二:在 SetUp 里调用断言而不检查。如果 SetUp 里准备的数据错了,理论上有问题的是整个夹具,但用例还是会跑下去,报出一堆奇怪的业务失败,让你误以为是业务代码坏了。建议在 SetUp 里只做"准备",所有"验证"都写进用例里。
Mock:gmock 与依赖注入
单元测试的"单元"意味着不要依赖真实的数据库、网络、时间、随机数。gmock 是 GoogleTest 自带的 Mock 框架,它生成一个"替身"对象,让你精确控制"调用它时该发生什么",并验证"它有没有被以正确的方式调用过"。
论为什么说"依赖注入是 Mock 的前提"
① 反面案例:你的 OrderService 在构造函数里 new MySQLRepo(),或者直接调用全局单例。此时你根本无法把 Mock 塞进去——代码写死了具体类型,你只能连真数据库。
② 正确做法:让 OrderService 依赖一个抽象接口(纯虚类),具体实现通过构造函数传进来。这样生产代码传真实的 MySQLRepo,测试代码传 Mock。依赖注入不是为了"架构好看",它的直接收益就是可测试性。
③ 判断一个类好不好测:问自己"我能不能在不碰任何真实资源的前提下把它 new 出来"。如果答案是"不能",那就说明耦合太紧了,先改设计再写测试,别硬凑。
从接口到 Mock,完整可跑的例子
#include <gtest/gtest.h>
#include <gmock/gmock.h>
// ---------- 1) 先定义抽象接口(这是可测性的关键)----------
class IStockRepo {
public:
virtual ~IStockRepo() = default;
virtual int getStock(int productId) = 0;
virtual void deduct(int productId, int count) = 0;
};
// ---------- 2) 被测类只依赖接口,实现由外部注入 ----------
class OrderService {
public:
explicit OrderService(IStockRepo* repo) : repo_(repo) {}
bool buy(int productId, int count) {
if (repo_->getStock(productId) < count) return false;
repo_->deduct(productId, count);
return true;
}
private:
IStockRepo* repo_;
};
// ---------- 3) 用 MOCK_METHOD 生成 Mock 实现 ----------
class MockStockRepo : public IStockRepo {
public:
// 参数:(方法名, 返回类型, (参数类型列表), (限定符))
MOCK_METHOD(int, getStock, (int productId), (override));
MOCK_METHOD(void, deduct, (int productId, int count), (override));
};
// ---------- 4) 用例:控制返回值 + 验证调用行为 ----------
using ::testing::Return;
using ::testing::_;
using ::testing::Exactly;
TEST(OrderServiceTest, 库存不足时不应该扣减) {
MockStockRepo repo;
// 桩(stub):告诉 Mock"这个函数被调用时返回 3"
EXPECT_CALL(repo, getStock(1001)).WillOnce(Return(3));
// 断言(mock):deduct 绝对不应该被调用
EXPECT_CALL(repo, deduct(_, _)).Times(Exactly(0));
OrderService svc(&repo);
EXPECT_FALSE(svc.buy(1001, 5)); // 要 5 个但只有 3 个
}
TEST(OrderServiceTest, 库存充足时应该扣减对应数量) {
MockStockRepo repo;
EXPECT_CALL(repo, getStock(1001)).WillOnce(Return(10));
// 验证参数:productId 是 1001、count 恰好是 5
EXPECT_CALL(repo, deduct(1001, 5)).Times(Exactly(1));
OrderService svc(&repo);
EXPECT_TRUE(svc.buy(1001, 5));
}
| 用法 | 含义 |
EXPECT_CALL(m, f(args)).Times(n) | 断言 f 被调用恰好 n 次。不写 Times 默认是"至少一次"。 |
.WillOnce(Return(v)) | 这一次调用返回 v。多次 WillOnce 可以给出连续不同返回值。 |
.WillRepeatedly(Return(v)) | 之后所有调用都返回 v。 |
.WillOnce(Throw(std::runtime_error("x"))) | 模拟"下游抛异常",测你的错误处理分支。 |
_ / Eq(5) / Gt(3) / Field(&T::id, 1) | 参数匹配器:任意、等于、大于、成员字段匹配。 |
testing::StrictMock<T> | 严格模式:任何没被 EXPECT_CALL 声明的调用都会失败。适合验证"不该有别的交互"。 |
testing::NiceMock<T> | 宽容模式:没声明的调用只警告不失败。适合只关心部分交互的场景。 |
Mock 用过头,测试就变成了"抄写实现"
过度 Mock 是新手最容易犯的错:把被测类依赖的每一个函数都 Mock 掉,然后断言"它按顺序调用了 A、然后 B、然后 C"。这种测试测的不是行为,而是实现细节——你把内部实现抄了一遍,重构时测试必红,但功能其实没问题,于是团队开始随手改测试、直到测试失去意义。正确姿势:只 Mock 那些"真实调用代价高或不确定"的东西(IO、时间、随机、第三方服务),对纯计算逻辑用真实对象。断言关注"输出对不对",而不是"内部怎么调的"。
Catch2:更轻的另一条路
Catch2 是另一个主流选择,最大特点是单头文件(现在是少量源文件)、零依赖、BDD 风格自然。如果你的项目只想快速加几个测试、不想引入一整套构建依赖,Catch2 非常舒服。
Catch2 的 BDD 风格与区块断言
// Catch2 v3 的写法(v2 是单头文件,v3 拆成了库)
#include <catch2/catch_test_macros.hpp>
#include <catch2/matchers/catch_matchers_string.hpp>
#include "calculator.h"
// ---------- 风格一:传统 TEST_CASE ----------
TEST_CASE("计算器可以做四则运算", "[calculator]") {
Calculator c;
// SECTION 会为每个分支重新执行外层代码,天然保证用例隔离
SECTION("加法") {
REQUIRE(c.add(2, 3) == 5);
}
SECTION("除法") {
SECTION("正常整除") {
REQUIRE(c.divide(10, 2) == 5);
}
SECTION("除零应该抛异常") {
REQUIRE_THROWS_AS(c.divide(1, 0), std::domain_error);
}
}
}
// ---------- 风格二:BDD 写法,读起来像需求文档 ----------
SCENARIO("用户可以多次充值,余额累加", "[wallet]") {
GIVEN("一个新钱包") {
Wallet w;
WHEN("充值 100 元") {
w.recharge(100);
THEN("余额应该是 100") {
REQUIRE(w.balance() == 100);
}
AND_WHEN("再充值 50 元") {
w.recharge(50);
THEN("余额应该是 150") {
REQUIRE(w.balance() == 150);
}
}
}
}
}
// ---------- Matcher:断言信息更可读 ----------
using Catch::Matchers::ContainsSubstring;
TEST_CASE("错误信息应该包含关键字段") {
auto msg = buildError("timeout");
REQUIRE_THAT(msg, ContainsSubstring("timeout"));
}
// REQUIRE vs CHECK:REQUIRE 失败中止用例,CHECK 失败继续
// 语义上和 gtest 的 ASSERT_* / EXPECT_* 一一对应
| 对比项 | GoogleTest + gMock | Catch2 |
| 依赖体积 | 需要编译 gtest/gmock(通常用 FetchContent 拉) | v2 是单头文件,v3 拆成少量源文件,集成极简 |
| 断言写法 | EXPECT_EQ(a, b),宏风格 | REQUIRE(a == b),直接用表达式,更接近原生 C++ |
| 组织方式 | TEST / TEST_F / TEST_P,Fixture 靠继承 | TEST_CASE + SECTION 嵌套,SECTION 自动重新执行外层,隔离性更好 |
| BDD 支持 | 无原生支持,要靠命名约定 | 原生 SCENARIO / GIVEN / WHEN / THEN |
| Mock | gMock 功能强,是最完整的 Mock 方案 | 框架本身不提供 Mock,需自行手写替身或用第三方库 |
| 适合谁 | 大型项目、需要大量 Mock、团队已熟悉 gtest / 需要 Google 生态(如 benchmark、fuzz) | 中小项目、想快速加测试、喜欢简洁 API 和可读的测试命名 |
论两者能不能混用
技术上可以,但强烈不建议。两套框架各有自己的 main 入口、断言宏注册机制和输出格式,混在一个可执行文件里会出现"测试跑了一半就退出""报告格式错乱"这类问题。
务实建议:一个项目选一个。如果你的项目将会用到 Mock,直接上 GoogleTest + gMock,因为 gMock 的成熟度明显更高;如果只是"给核心算法补几十个测试",Catch2 的体验更轻快。真实项目里,多数基础库和工程库选择 gtest。
接进 CMake 与 CTest、看覆盖率
测试写完还要能被自动跑起来,否则等于没写。CMake 内建了 CTest,配合 FetchContent 拉取 gtest,可以做到"clone 下来就能跑测试"。
CMakeLists.txt:FetchContent 拉依赖、注册测试、开覆盖率
cmake_minimum_required(VERSION 3.20)
project(myapp CXX)
set(CMAKE_CXX_STANDARD 20)
# ---------- 1) 用 FetchContent 拉 GoogleTest,避免手工装依赖 ----------
include(FetchContent)
FetchContent_Declare(
googletest
GIT_REPOSITORY https://github.com/google/googletest.git
GIT_TAG v1.15.2 # 一定要锁定 tag,别用 main
)
# Windows 下需要这一行,否则会覆盖我们自己的 runtime 设置
set(gtest_force_shared_crt ON CACHE BOOL "" FORCE)
FetchContent_MakeAvailable(googletest)
# ---------- 2) 被测代码编译成库,测试链接它 ----------
add_library(mylib STATIC src/stack.cpp src/calculator.cpp)
target_include_directories(mylib PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/include)
# ---------- 3) 测试可执行文件 ----------
enable_testing()
file(GLOB TEST_SOURCES tests/*.cpp) # 生产项目建议显式列出文件,便于 code review
add_executable(myapp_tests ${TEST_SOURCES})
target_link_libraries(myapp_tests
PRIVATE
mylib
GTest::gtest_main # 自带 main 的入口,不用自己写 RUN_ALL_TESTS
GMock::gmock
)
# 可选:开启 ASan/UBSan,让测试顺便抓内存问题(见第 16 章)
option(ENABLE_SANITIZER "Enable ASan+UBSan" OFF)
if(ENABLE_SANITIZER)
target_compile_options(myapp_tests PRIVATE
-fsanitize=address,undefined -fno-sanitize-recover=all -g)
target_link_options(myapp_tests PRIVATE -fsanitize=address,undefined)
endif()
# ---------- 4) 把测试注册进 CTest ----------
include(GoogleTest) # 提供 gtest_discover_tests 命令
gtest_discover_tests(myapp_tests)
# ---------- 5) 覆盖率(GCC/Clang 用 gcov/llvm-cov)----------
option(ENABLE_COVERAGE "Enable coverage" OFF)
if(ENABLE_COVERAGE)
target_compile_options(mylib PUBLIC --coverage -O0 -g)
target_link_options(mylib PUBLIC --coverage)
endif()
跑起来:构建、测试、生成覆盖率报告
# 配置并构建(开 Sanitizer 与覆盖率)
cmake -B build -DCMAKE_BUILD_TYPE=Debug \
-DENABLE_SANITIZER=ON -DENABLE_COVERAGE=ON
cmake --build build -j
# 跑全部测试,失败时打印详细输出
ctest --test-dir build --output-on-failure
# 只跑名字匹配的测试(gtest 的参数也能透传)
ctest --test-dir build -R StackTest --output-on-failure
# ---------- 覆盖率:GCC 用 gcovr ----------
gcovr -r . --filter 'src/' --html-details coverage.html
# 终端里看汇总
gcovr -r . --filter 'src/' --print-summary
# 输出示例:lines: 87.3% (412 out of 472)
# ---------- 覆盖率:Clang 用 llvm-cov,需要先合并 profraw ----------
llvm-profdata merge -sparse default.profraw -o coverage.profdata
llvm-cov report ./build/myapp_tests -instr-profile=coverage.profdata
llvm-cov show ./build/myapp_tests -instr-profile=coverage.profdata \
--format=html --output-dir=coverage_html
论覆盖率怎么看才算没白看
① 行覆盖率不是目标,是线索。80% 的行覆盖率不代表质量好——剩下那 20% 很可能恰好是所有的错误分支(异常处理、边界条件)。真正要看的是"分支覆盖率"和"未覆盖的行在哪里"。
② 未覆盖的行比数字更有价值。打开 HTML 报告,看那些红色的行:如果它们是 if (error) { ... } 里的错误处理,说明你从来没用测试触发过失败路径——而这些恰恰是生产环境最容易出问题的地方。
③ 别把覆盖率设成 CI 门禁的一票否决。强制"覆盖率低于 90% 就构建失败",结果团队会写一堆只为了被执行、没有任何断言的"凑覆盖"测试。覆盖率的价值在于"帮你发现遗漏的分支",一旦它变成考核指标,就会被玩坏。
三个让测试"看起来有、其实没有"的坑
坑一:测试里 new 了不 delete,掩盖了真实的泄漏。测试代码里 auto* p = new Foo(); 然后忘了释放,测试照样通过(进程结束时系统会回收)。但如果生产代码有同样的泄漏,你永远发现不了——因为测试没有检查内存。解法:测试也用智能指针,并且开 ASan 跑测试,让泄漏直接变成测试失败。
坑二:测试之间靠全局状态互相影响。上一个用例给某个全局单例写了数据,下一个用例读到脏数据,于是"单跑通过、全跑失败"或者"换个顺序结果就变了"。这种测试是团队时间的黑洞——排查几小时发现和业务无关。解法:每个用例自己准备和清理状态,绝不依赖执行顺序;能不用全局单例就不用。
坑三:测试里用了固定 sleep 等异步结果。std::this_thread::sleep_for(100ms) 在本地飞快、在 CI 上超时,于是测试变得不可靠(flaky),大家开始习惯性重跑 CI。正确做法是轮询等待 + 超时上限(condition_variable 或"每 1ms 检查一次,最多 5 秒")。flaky 测试比没有测试更糟,因为它教会团队忽略红灯。
记
本章小结
① C++ 特别需要测试,因为 UB 随机爆发、编译不管逻辑、平台差异大,而测试是重构的底气。
② 默认用 EXPECT_*,只在后续语句依赖该断言时才用 ASSERT_*;能写 EXPECT_EQ 就别写 EXPECT_TRUE(a == b)。
③ Fixture 的 SetUp 是每个用例执行一次,不是整个套件一次;重初始化用 SetUpTestSuite 或全局 Environment。
④ Mock 的前提是依赖注入:先让被测类依赖抽象接口,才可能塞进替身。过度 Mock 会把测试变成"抄写实现"。
⑤ Catch2 更轻、BDD 更自然,但不自带 Mock;需要 Mock 时优先 GoogleTest + gMock。
⑥ 用 FetchContent 拉 gtest、用 gtest_discover_tests 接 CTest;测试务必开 ASan 一起跑。
⑦ 覆盖率是线索不是目标,重点看"哪些分支没被覆盖",别设成一票否决的门禁。
自测
小练习 · 单元测试
1.(概念题)EXPECT_EQ(a, b) 和 ASSERT_EQ(a, b) 该怎么选?
查看答案
答案:EXPECT_* 失败后继续执行,一个用例能报出所有失败点,是默认选择;ASSERT_* 失败后立即从当前函数返回,用于"后续语句依赖这个前提才有意义"的场合——比如 ASSERT_NE(ptr, nullptr) 之后再解引用它。如果这里用 EXPECT,指针为空时后面的解引用会直接段错误,你会看到一堆无意义的崩溃信息而不是清晰的断言失败。
2.(设计题)一个类在构造函数里直接 new MySQLRepo(),你没法给它写单元测试。问题出在哪?怎么改?
查看答案
答案:问题在于硬编码了具体实现,无法替换成替身(违反依赖倒置原则)。改法:① 抽出抽象接口 IRepo(纯虚类);② 让类持有一个 IRepo* 或 std::unique_ptr<IRepo>,由构造函数注入;③ 生产代码在 main / 工厂里传真实实现,测试代码传 Mock。核心结论:依赖注入的直接收益就是可测试性——不好测通常意味着设计耦合太紧。
3.(排查题)你的测试在本地全绿,在 CI 上偶发失败,报的是完全不相干的业务错误。最可能的原因?
查看答案
答案:典型的测试间耦合与 flaky。可能原因:① 用例之间共享了全局状态/单例/临时文件,依赖执行顺序;② 用了固定 sleep 等待异步结果,CI 机器慢就超时;③ 依赖了本机环境(时区、语言、文件系统大小写敏感、端口占用);④ 没做清理,上一个用例留下的数据污染了下一个。排查手段:多跑几次、随机化用例顺序、用 --gtest_shuffle 复现;找到后逐个改成"自准备、自清理、不用 sleep"。
4.(工程题)团队要求"覆盖率必须达到 95%,否则 CI 失败"。这个规则会带来什么问题?
查看答案
答案:会诱导团队写"凑覆盖率"的无效测试:只调用函数但不断言结果、把难以测试的代码用 // LCOV_EXCL_LINE 排除、给 getter/setter 写一堆无意义用例。数字上去了,bug 一个没少,还增加了维护成本。更好的做法:覆盖率作为趋势指标(关注是否下降、哪些新增代码没有测试)而不是硬门禁;重点检查关键模块(解析器、金融计算、并发逻辑)的分支覆盖率和错误路径覆盖;把评审精力放在"测试是否真的验证了行为"上。