楼层: 首页/ 软件技术/ 现代 C++/ 单元测试:GoogleTest、Catch2 与覆盖率
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 + gMockCatch2
依赖体积需要编译 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
MockgMock 功能强,是最完整的 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 一个没少,还增加了维护成本。更好的做法:覆盖率作为趋势指标(关注是否下降、哪些新增代码没有测试)而不是硬门禁;重点检查关键模块(解析器、金融计算、并发逻辑)的分支覆盖率和错误路径覆盖;把评审精力放在"测试是否真的验证了行为"上。