1. 先把 GoogleTest 这套东西看明白再动手1.1 C 单元测试为什么总被跳过写了几年 C 的人大概率都经历过这个循环代码改完编译通过手动跑一遍主流程觉得没问题就提交了。等到某个边界条件在生产环境炸出来回头一查发现是三个月前某次重构把手写的内存管理逻辑改歪了或者某个函数在空输入下行为变了。C 这门语言特别容易出这种问题——没有托管内存、没有运行时越界检查、模板报错能刷满三屏、链接错误信息晦涩任何一个疏忽都可能埋下定时炸弹。问题不在于程序员不重视测试而在于 C 的测试成本确实比动态语言高。Python 里写个测试几行就完事C 里光是搭一套测试工程、把被测代码编进测试二进制、处理各种依赖和链接问题就能劝退一大半人。更别说项目大起来之后测试用例怎么组织、公共初始化逻辑放哪、如何只跑子集、如何接入持续集成这一串工程问题没有框架兜底几乎没法规模化。GoogleTest业界通常简称 gtest就是冲这些问题来的。它是 Google 开源的一套 C 单元测试框架基于经典的 xUnit 架构同时配套一个叫 gMock 的模拟框架。它的价值不只是提供断言宏而是把测试用例的注册、组织、过滤、执行、报告这一整条链路都封装成了你可以直接复用的基础设施。你只需要关注被测行为是什么、期望结果是什么剩下的样板活它全包了。这篇文章面向的是刚开始接触 C 单元测试、或者手里有个老 C 项目想补上测试的开发者。我不会假设你懂任何测试框架的概念从怎么把 gtest 拉进项目开始讲一路讲到参数化测试、模拟对象、覆盖率统计这些工程实践。标题叫从入门到入门其实是想说gtest 真正需要你学的东西并不多入门一次剩下的就是熟练度问题。1.2 四个核心概念先建立起来在看代码之前先把 gtest 的概念模型理清楚否则后面看到一堆宏名会晕。gtest 的抽象其实非常克制核心就四层。第一层是断言Assertion。断言是测试的最小单位它表达我期望某件事为真。gtest 把断言分成两类ASSERT_*系列在失败时直接终止当前测试函数EXPECT_*系列在失败时记录一条失败信息然后继续往下跑。这个区别很关键后面会专门展开。第二层是测试用例Test。一个测试用例就是一段验证某个具体行为的代码用TEST()宏定义。它有一个全名格式是测试套件名.测试用例名。这个名字很重要因为运行时的过滤、报告、并行调度都靠它来标识。第三层是测试套件Test Suite。名字相近的测试用例归到同一个套件里通常对应被测的一个类或一个模块。套件级别可以有共享的初始化逻辑这就是夹具Fixture机制的用武之地。这里有个历史坑要提醒老版本 gtest 里套件叫 Test Case测试叫 Test Info后来术语调整了现在官方文档里 Test Suite 指一套用例Test 指单个用例。你如果看到网上老教程写INSTANTIATE_TEST_CASE_P那是已经被废弃的宏新版要写INSTANTIATE_TEST_SUITE_P。第四层是测试程序Test Program。所有测试用例链接进同一个可执行文件由 gtest 自己生成或你手写的main函数统一调度执行。这个可执行文件运行时会扫描注册表把所有用例跑一遍最后输出一份汇总报告。提示理解用例注册这个机制很重要。gtest 靠静态初始化在程序启动前把所有TEST宏展开出来的用例登记到一张全局表里所以哪怕你把测试分散在几十个.cpp文件里只要都参与链接运行时就能自动发现它们不需要你手动维护一份用例清单。1.3 跟其他方案比它强在哪C 的测试框架不止 gtest 一家Catch2、doctest、Boost.Test、CppUnit 都是常见选项。选型的时候我一般看几个维度。单头文件方案Catch2 早期、doctest的优势是接入成本极低扔一个头文件进项目就能用特别适合小项目或者只想快速验证的场景。代价是编译时间会随测试规模膨胀因为所有测试代码都要在编译单元里重新实例化模板。大型项目用久了容易受不了编译速度。gtest 的优势在于工程化程度高。它有成熟的 CMake 集成方案有独立的 gMock 配套有命令行级的过滤和重复执行能力还有与持续集成系统的良好衔接。代价是要编译成库链接进来接入稍微重一点但一次配好之后长期受益。它也是我建议中大型 C 项目默认选择的方案——不是因为技术最先进而是因为生态最完整、踩坑资料最多、团队协作时心智负担最低。doctest 是个有意思的折中它模仿了 gtest 的 API 风格但做成单头文件如果你喜欢 gtest 的写法又受不了编译时间可以了解下。不过考虑到本文主题我们专注 gtest。2. 从零把第一个测试跑起来2.1 获取源码与三种接入方式gtest 的源码在代码托管平台上有官方仓库拉下来之后用 CMake 构建即可。但在实际项目里我不建议你把它当成一个必须预先安装的系统库更好的做法是把它作为项目依赖管理起来保证不同机器、不同 CI 环境上编译出完全一致的版本。常见的接入方式有三种各有适用场景。第一种是FetchContent在 CMake 配置阶段自动下载并构建 gtest好处是完全自动化、版本锁定在 CMakeLists 里、不用预装任何东西缺点是需要网络构建时会多花一点时间。第二种是add_subdirectory把 gtest 源码作为子目录放进你的仓库或 vendor 目录好处是离线可用、版本可控缺点是仓库体积变大。第三种是find_package依赖系统或环境里已经装好的 gtest好处是构建快缺点是版本不可控容易出现我本地能过 CI 上挂了的经典问题。我自己项目里用FetchContent最多下面这段配置可以直接抄。cmake_minimum_required(VERSION 3.16) project(my_project LANGUAGES CXX) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) include(FetchContent) FetchContent_Declare( googletest GIT_REPOSITORY https://github.com/google/googletest.git GIT_TAG v1.14.0 ) # Windows 上需要这一行避免运行期库冲突 set(gtest_force_shared_crt ON CACHE BOOL FORCE) FetchContent_MakeAvailable(googletest) enable_testing() add_executable(unit_tests src/calculator.cpp tests/calculator_test.cpp ) target_link_libraries(unit_tests PRIVATE GTest::gtest_main GTest::gmock ) include(GoogleTest) gtest_discover_tests(unit_tests)几个细节值得解释。GTest::gtest_main这个 target 帮你带了一个标准的main函数里面就是调RUN_ALL_TESTS()所以你的测试文件里不用自己写main。如果你需要自定义初始化逻辑比如初始化日志系统那就换成链接GTest::gtest然后自己写main。gtest_force_shared_crt这行在 Windows 上是大坑之一。它控制 gtest 用哪种运行时库如果不设可能会和你自己的工程设置冲突导致链接时出现一堆_ITERATOR_DEBUG_LEVEL不匹配的报错。gtest_discover_tests是替代老式add_test的现代写法它在构建后扫描可执行文件把每个用例注册成独立的 CTest 测试项这样ctest就能按用例粒度并行调度和报告比整块跑一遍强很多。注意gtest 依赖线程库。Linux 上早期版本需要显式链接pthread新版本的 CMake target 已经处理好了但如果你手写编译命令别忘了加-lpthread否则会看到一堆undefined reference to pthread_...的错误。2.2 被测代码与测试代码的边界搭好构建之后先想清楚测什么。假设我们有个简单的计算器模块提供加法、除法和一个判断素数的方法。这里我故意在除法里留了个整除了零的问题方便后面演示异常和死亡测试。// src/calculator.h #pragma once #include stdexcept class Calculator { public: int add(int a, int b) { return a b; } double divide(double a, double b) { if (b 0.0) { throw std::invalid_argument(divisor must not be zero); } return a / b; } bool isPrime(int n) { if (n 2) return false; for (int i 2; i * i n; i) { if (n % i 0) return false; } return true; } };一个经常被忽略的工程决策是测试代码放哪、命名怎么定。我见过有人把测试文件塞在源码目录旁边也见过统一放tests/目录。两种都能用但推荐后者理由是构建系统里可以给测试代码单独的编译选项比如更严格的警告、启用 sanitizer而且不会被误打包进发布产物。命名我习惯用xxx_test.cpp或者xxx_unittest.cpp保持后缀统一gtest_discover_tests和覆盖率工具处理起来都省事。2.3 第一个测试与运行结果解读现在写第一个测试文件。// tests/calculator_test.cpp #include gtest/gtest.h #include calculator.h TEST(CalculatorTest, AddPositiveNumbers) { Calculator calc; EXPECT_EQ(calc.add(2, 3), 5); EXPECT_EQ(calc.add(0, 0), 0); EXPECT_EQ(calc.add(-1, 1), 0); } TEST(CalculatorTest, DivideNormalCase) { Calculator calc; EXPECT_DOUBLE_EQ(calc.divide(10.0, 4.0), 2.5); } TEST(CalculatorTest, DivideByZeroThrows) { Calculator calc; EXPECT_THROW(calc.divide(1.0, 0.0), std::invalid_argument); } TEST(CalculatorTest, IsPrimeSmallNumbers) { Calculator calc; EXPECT_FALSE(calc.isPrime(0)); EXPECT_FALSE(calc.isPrime(1)); EXPECT_TRUE(calc.isPrime(2)); EXPECT_TRUE(calc.isPrime(97)); EXPECT_FALSE(calc.isPrime(100)); }编译运行你会看到类似这样的输出。[] Running 4 tests from 1 test suite. [----------] Global test environment set-up. [----------] 4 tests from CalculatorTest [ RUN ] CalculatorTest.AddPositiveNumbers [ OK ] CalculatorTest.AddPositiveNumbers (0 ms) ... [] 4 tests from 1 test suite ran. (1 ms total) [ PASSED ] 4 tests.这份报告里几个字段要会读。[ RUN ]后面跟着的是用例全名格式是套件名.用例名。[ OK ]和[ FAILED ]是结果后面括号里的毫秒数是用例耗时性能敏感的测试可以拿它做初步观察。最后[ PASSED ]那一行是汇总。如果某个断言失败你会在[ FAILED ]下面看到文件路径、行号、实际值和期望值这就是自动生成的失败信息比你自己printf强太多。把失败的样子也看一眼。把上面的EXPECT_EQ(calc.add(2, 3), 5)改成EXPECT_EQ(calc.add(2, 3), 6)重跑输出会变成tests/calculator_test.cpp:7: Failure Expected equality of these values: calc.add(2, 3) Which is: 5 6注意它把表达式原文、实际计算值都打印出来了这个能力来自EXPECT_EQ这类比较断言的内部实现。这就引出了下一章要讲的重点断言怎么选直接决定了你排障时要花多少时间。3. 断言体系让失败信息自己说话3.1 ASSERT 和 EXPECT 到底怎么取舍gtest 的两套断言前缀初学者最容易搞混或者干脆全用EXPECT图省事。其实它们的区别很硬核ASSERT_*在失败时会立即从当前测试函数return后面的代码不再执行EXPECT_*失败时只记录结果然后继续往下跑直到函数结束。这个差别决定了使用原则当后续代码依赖前面断言成立时用 ASSERT当后续断言是独立的检查点时用 EXPECT。举个典型例子如果你要解引用一个指针前面必须先断言指针非空否则后面直接崩。TEST(SafeAccessTest, DereferencePointer) { std::unique_ptrint p std::make_uniqueint(42); ASSERT_NE(p, nullptr); EXPECT_EQ(*p, 42); // 只有前面成立这行才有意义 }反过来说验证多个独立属性时用 EXPECT好处是一次运行能看到所有问题而不是修一个跑一次。TEST(VectorTest, SizeAndContent) { std::vectorint v{1, 2, 3}; EXPECT_EQ(v.size(), 3u); EXPECT_EQ(v[0], 1); // 即使上一行失败这行照样执行 EXPECT_EQ(v.back(), 3); }注意ASSERT_*有个隐藏限制它只能在返回void的函数里用。因为它的实现里包含了return如果你把它写在返回非 void 的辅助函数或者 lambda 里编译器会报错。这个报错信息通常很隐晦新手经常会卡在这里。解决办法是把这类辅助函数改成返回 void或者用 EXPECT 替代。还有一个相关的坑在构造函数、析构函数、以及被noexcept标注的函数里使用 ASSERT 也可能出问题因为return语句在这些上下文中的语义会变。遇到过最典型的是在析构函数里写 ASSERT那基本是给自己找麻烦。3.2 数值、字符串、容器断言的正确姿势gtest 提供的断言宏数量不少常用的其实就是那么十来组。数值比较用EXPECT_EQ/NE/LT/LE/GT/GE覆盖等于、不等于、小于、小于等于、大于、大于等于命名很规律。布尔值用EXPECT_TRUE/FALSE。浮点数比较是个重灾区必须单独说。直接用EXPECT_EQ比较浮点数是错误做法因为浮点表示本身有精度损失0.1 0.2 0.3在很多平台上就是 false。gtest 提供了三个专门的宏EXPECT_FLOAT_EQ、EXPECT_DOUBLE_EQ会按 4 个 ULPUnits in the Last Place容差比较EXPECT_NEAR(a, b, abs_error)让你指定绝对误差范围。绝大多数场景用EXPECT_DOUBLE_EQ就够只有在你明确知道业务容差时才用EXPECT_NEAR。TEST(FloatTest, ComparisonIdioms) { double result 0.1 0.2; EXPECT_DOUBLE_EQ(result, 0.3); // 按 ULP 容差比较通过 EXPECT_NEAR(result, 0.3, 1e-9); // 指定绝对误差通过 // EXPECT_EQ(result, 0.3); // 千万别这么写 }字符串比较要用EXPECT_STREQ而不是EXPECT_EQ。原因是EXPECT_EQ比较char*时比的是指针地址不是内容而且失败信息里也不会打印字符串内容你会看到两个十六进制地址毫无诊断价值。EXPECT_STREQ支持const char*EXPECT_STRCASEEQ忽略大小写比较处理std::string时两者都能用但推荐 STREQ 系列因为失败信息更友好。TEST(StringTest, ComparisonIdioms) { std::string s hello; const char* c hello; EXPECT_STREQ(s.c_str(), c); EXPECT_STRNE(s.c_str(), world); EXPECT_STRCASEEQ(HELLO, hello); }容器比较 gtest 内置支持运算符的任意类型所以std::vectorint、std::map直接EXPECT_EQ就能逐元素比较失败信息也会完整打印两个容器。但如果你需要更复杂的匹配逻辑比如判断容器里是否包含某个元素判断元素是否满足某个谓词那就要上 gMock 提供的匹配器配合EXPECT_THAT使用。#include gmock/gmock.h TEST(ContainerTest, MatcherUsage) { std::vectorint v{2, 4, 6, 8}; EXPECT_THAT(v, ::testing::Contains(4)); EXPECT_THAT(v, ::testing::Each(::testing::Gt(0))); EXPECT_THAT(v, ::testing::SizeIs(4)); EXPECT_THAT(v, ::testing::ElementsAre(2, 4, 6, 8)); }这套匹配器写起来很顺手但要注意它来自 gMock 而不是核心 gtest链接时得带上GTest::gmock。3.3 自定义失败信息与 SCOPED_TRACE默认的失败信息已经很详细但有时候不够用。典型场景是在循环里断言失败了你想知道是哪一轮、哪个输入导致的。TEST(LoopTest, CheckAllInputs) { Calculator calc; std::vectorstd::pairint, int cases{{2, 3}, {5, 7}, {11, 13}}; for (const auto [a, b] : cases) { EXPECT_EQ(calc.add(a, b), a b) failed for a a , b b; } }断言宏支持流式追加信息后面可以拼任意可打印的东西。这个自定义信息会附在失败输出后面定位问题时能省不少事。习惯上我会在涉及外部数据驱动的测试里都加上反正没成本。再往上一个层级是SCOPED_TRACE它会在当前作用域内给所有失败信息附加一段上下文。最常见的用法是配合子函数调用——断言写在被调用的辅助函数里失败了你不知道是哪个调用点触发的。void CheckPrime(const Calculator calc, int n) { SCOPED_TRACE(::testing::Message() checking n n); EXPECT_TRUE(calc.isPrime(n)); } TEST(PrimeTest, MultipleValues) { Calculator calc; CheckPrime(calc, 2); CheckPrime(calc, 97); CheckPrime(calc, 100); // 这条会失败输出里会带上 n100 }有了SCOPED_TRACE失败信息里会多出一行Actual: false前面挂着的n100上下文。这个技巧在参数化测试和夹具里尤其有用因为那时候有大量测试共享同一段断言逻辑。4. 测试夹具与参数化把重复代码压下去4.1 夹具的构造与销毁契约当多个测试需要同样的初始化时你有两个选择抽成一个辅助函数在每个测试里调或者用夹具。夹具的价值在于 gtest 帮你管理了生命周期你不用在每个测试里手动清理。夹具的用法是继承::testing::Test然后在里面定义SetUp()和TearDown()。gtest 会在每个测试用例开始前调SetUp()结束后调TearDown()。class DatabaseTest : public ::testing::Test { protected: void SetUp() override { db_ std::make_uniqueDatabase(:memory:); db_-connect(); db_-createTable(users); } void TearDown() override { db_-disconnect(); db_.reset(); } std::unique_ptrDatabase db_; }; TEST_F(DatabaseTest, InsertAndQuery) { db_-insert(users, {alice, 30}); EXPECT_EQ(db_-count(users), 1); } TEST_F(DatabaseTest, DeleteExistingRow) { db_-insert(users, {bob, 25}); db_-remove(users, bob); EXPECT_EQ(db_-count(users), 0); }用TEST_F而不是TEST来定义夹具内的测试F 代表 Fixture。这里有几个细节必须说清楚。第一SetUp和TearDown在每个测试用例执行前后都会调用一次是每用例粒度。如果你需要整个套件只初始化一次比如启动一个重量级的服务用静态版本SetUpTestSuite()和TearDownTestSuite()。注意老版本叫SetUpTestCase已废弃。第二每个测试用例都会创建一个全新的夹具对象SetUp里的成员变量是干净的。这意味着测试之间不会通过夹具成员传递状态这是设计上刻意的隔离避免测试互相污染。第三TearDown理论上不该抛异常也不该用 ASSERT 断言。如果TearDown崩了gtest 的行为可能不符合你的预期。提示夹具的构造函数和SetUp都能做初始化区别在于构造函数里不能调虚函数也不能使用ASSERT断言前面说过 ASSERT 只能在 void 函数里用构造函数不算。所以推荐把初始化逻辑都放SetUp里构造函数只做必要的成员默认初始化。4.2 值参数化测试多个测试只差输入数据这时候逐个写TEST就是体力活。参数化测试解决的就是这个问题定义一次测试逻辑喂不同参数跑多次。class PrimeParamTest : public ::testing::TestWithParamint {}; TEST_P(PrimeParamTest, KnownPrimes) { Calculator calc; EXPECT_TRUE(calc.isPrime(GetParam())); } INSTANTIATE_TEST_SUITE_P( SmallPrimes, PrimeParamTest, ::testing::Values(2, 3, 5, 7, 11, 13, 17, 19, 23) );结构是三段式继承TestWithParamT定义夹具用TEST_P写测试P 代表 Parameterized用INSTANTIATE_TEST_SUITE_P绑定参数集合。GetParam()在测试里取出当前参数。还有一套复合参数的形式TestWithParamstd::tuple...适合输入输出成对的场景。class AddParamTest : public ::testing::TestWithParamstd::tupleint, int, int {}; TEST_P(AddParamTest, Addition) { auto [a, b, expected] GetParam(); Calculator calc; EXPECT_EQ(calc.add(a, b), expected); } INSTANTIATE_TEST_SUITE_P( AddCases, AddParamTest, ::testing::Values( std::make_tuple(1, 2, 3), std::make_tuple(-1, 1, 0), std::make_tuple(0, 0, 0), std::make_tuple(100, 200, 300) ) );注意这里用了 C17 的结构化绑定。如果你还在 C11/14改成std::get0(GetParam())也能用。参数集合也可以从函数动态生成::testing::ValuesIn(vec)支持传入一个容器或者传入一个返回容器的生成函数这样参数就能从文件读、从数据表拉把测试数据彻底外置。我很喜欢这个能力因为可以让非开发人员维护测试用例数据文件开发只负责断言逻辑。4.3 类型参数化测试值参数化解决的是同一逻辑不同数据类型参数化解决的是同一逻辑不同数据类型。典型场景是模板容器、泛型算法你要确保std::vectorint和std::vectorstd::string行为一致。template typename T class WrapperTest : public ::testing::Test { protected: T value_{}; }; using MyTypes ::testing::Typesint, long, double, std::string; TYPED_TEST_SUITE(WrapperTest, MyTypes); TYPED_TEST(WrapperTest, DefaultConstructible) { TypeParam v{}; (void)v; SUCCEED(); }TYPED_TEST_SUITE绑定类型列表TYPED_TEST里用TypeParam引用当前类型。同样要注意老教程里这个宏叫TYPED_TEST_CASE新版已经改名。如果你在维护历史代码看到旧名别慌功能一样。还有一个更进阶的TYPED_TEST_SUITE_P是类型参数化套在值参数化外面等于二维展开适合同时要测多种类型和多种数据的矩阵型场景。实际用到的频率不高但知道它存在可以避免你需要时重复造轮子。4.4 参数生成器组合::testing::Values是最基础的直接列举。::testing::Range(start, end, step)生成等差数列适合测边界范围。::testing::Bool()生成true/false。::testing::Combine(a, b)把两个生成器做笛卡尔积这个在组合场景下特别好用。class CombineTest : public ::testing::TestWithParam std::tupleint, bool {}; TEST_P(CombineTest, FlagMatrix) { auto [value, flag] GetParam(); // 验证 (value, flag) 组合下的行为 EXPECT_GE(value, 0); } INSTANTIATE_TEST_SUITE_P( Matrix, CombineTest, ::testing::Combine( ::testing::Range(1, 5), ::testing::Bool() ) );这个组合会生成(1,true) (1,false) (2,true) (2,false) ...共 8 组参数每个跑一次测试用例。这是个很高效的覆盖方式但也要小心组合爆炸——两个Range(1,100)组合起来就是上万次执行测试时间会失控。我的经验是组合维度别超过三个每维取值别超过十来个超了就拆成多个测试分别验证。5. 工程化落地组织、模拟与执行策略5.1 目录结构与命名约定小项目里测试文件怎么放都行项目一大组织方式就决定了好不好维护。我目前的习惯是按被测模块划分子目录。project/ ├── src/ │ ├── core/ │ │ ├── calculator.cpp │ │ └── parser.cpp │ └── io/ │ └── filereader.cpp ├── tests/ │ ├── CMakeLists.txt │ ├── core/ │ │ ├── calculator_test.cpp │ │ └── parser_test.cpp │ ├── io/ │ │ └── filereader_test.cpp │ └── test_utils/ │ └── test_data_loader.h测试文件名和被测文件一一对应好处是看到parser_test.cpp就知道测的是谁改动parser.cpp时能快速定位该更新哪些测试。套件名统一用被测类名 Test的后缀比如CalculatorTest这样运行时的过滤命令也统一好写。测试二进制不要只编一个巨大的。我的经验是至少拆两个一个跑纯粹的单元测试无外部依赖、秒级完成另一个跑集成测试依赖数据库、网络、文件系统。单元测试那个要做到想跑就跑停车等红灯都能跑一遍集成测试那个挂在 CI 上按需触发。gtest 本身不限制你在一个二进制里放什么但工程上要主动做这个分层。5.2 gMock 基本用法单元测试的核心诉求是隔离。被测代码可能依赖数据库、网络、文件系统这些在单元测试里都不该真的调用。这时候就需要模拟对象Mock替代真实依赖。gMock 是 gtest 的官方配套定义一个 Mock 类声明你要模拟的接口方法用MOCK_METHOD。// 接口定义在头文件里 class UserRepository { public: virtual ~UserRepository() default; virtual std::optionalUser findById(int id) const 0; virtual bool save(const User user) 0; }; // 测试里的 Mock class MockUserRepository : public UserRepository { public: MOCK_METHOD(std::optionalUser, findById, (int id), (const, override)); MOCK_METHOD(bool, save, (const User user), (override)); };MOCK_METHOD的参数顺序是返回类型、方法名、参数列表带括号、修饰符列表带括号。这个签名从老版本到现在变过老写法是用一堆MOCK_METHOD1、MOCK_METHOD2按参数个数区分新版统一成MOCK_METHOD别抄老教程了。设定行为用ON_CALL宽松设定不满足时返回默认值和EXPECT_CALL严格设定不满足会导致测试失败。TEST(UserServiceTest, GetUserReturnsUserWhenExists) { MockUserRepository repo; User expected{1, alice}; EXPECT_CALL(repo, findById(1)) .Times(1) .WillOnce(::testing::Return(expected)); UserService service(repo); auto result service.getUser(1); ASSERT_TRUE(result.has_value()); EXPECT_EQ(result-name, alice); }Times(1)明确了这个调用必须发生一次。WillOnce(Return(expected))设定了第一次调用的返回值。还有WillRepeatedly用于多次调用返回相同值WillOnce(...).WillOnce(...)可以设定不同次调用返回不同值来测状态变化。匹配器是 gMock 的另一把利器。findById(1)里那个1是精确匹配你也可以写::testing::_表示任意值::testing::Gt(0)表示大于零::testing::_配合::testing::Return就能让 mock 对任意入参都返回同样结果。EXPECT_CALL(repo, findById(::testing::Gt(0))) .WillRepeatedly(::testing::Return(expected));注意gMock 有个著名陷阱是在 mock 对象的析构时校验期望。如果EXPECT_CALL设定了但实际没被调用gtest 会在 mock 析构时报错报错信息通常在测试结束后才打印。写测试时如果看到预期的调用未发生之类错误但不知道来自哪行检查一下是不是某个 mock 对象没被销毁或者被提前复制了。5.3 死亡测试与异常测试前面写的EXPECT_THROW测的是正常的异常路径。但 C 里还有程序必须崩掉的场景比如断言失败、访问空指针、缓冲区溢出导致的保护性中止——这些没法用返回值或异常表达得用死亡测试。TEST(DeathTest, NullPointerDereference) { int* p nullptr; EXPECT_DEATH({ *p 42; }, ); }EXPECT_DEATH的第一个参数是会被 fork 到子进程执行的语句块第二个参数是正则表达式用来匹配 stderr 输出。程序崩了且输出匹配正则测试通过。死亡测试有几个硬性限制。第一它在类 Unix 系统上依赖 fork在 Windows 上实现方式不同行为可能有差异。第二语句块里不能正常返回比如写个空语句必须真的崩溃否则会报死亡测试没有死亡。第三也是最重要的不要在死亡测试里使用 mock因为 fork 出来的子进程和父进程的内存状态不一致mock 的计数和期望会错乱。我的实战建议是死亡测试能少用就少用只保留那些如果这条不成立就说明有严重 bug的关键检查别拿它当常规测试手段。断言失败、内存越界这类场景优先考虑用 AddressSanitizer 在普通测试里跑能覆盖得更全面报告也更好读。5.4 过滤、重复与随机执行gtest 内置的命令行参数是日常调试的加速器。最有用的是过滤器。# 只跑某个套件 ./unit_tests --gtest_filterCalculatorTest.* # 只跑某个用例 ./unit_tests --gtest_filterCalculatorTest.AddPositiveNumbers # 排除某类测试 ./unit_tests --gtest_filter-*Slow* # 多个条件用冒号分隔 ./unit_tests --gtest_filterCalculatorTest.*:StringTest.*正向过滤器匹配要跑的负向过滤器前面带减号匹配要跳过的可以混用。通配符支持*和?语法类似 shell 的 glob。注意--gtest_filter里的*在你的 shell 里可能会被提前展开所以一定要用引号把它包起来写成--gtest_filterCalculatorTest.*。这个坑我踩过不止一次表现为过滤器好像没生效实际上是 shell 把*替换成了当前目录的文件名列表。--gtest_shuffle会打乱用例执行顺序。这个能力被严重低估了它用来发现测试之间偷偷共享状态的问题。正常情况下测试应该是独立的如果你的测试通过顺序有依赖比如 A 跑完给全局变量设了值B 依赖这个值打乱之后大概率会暴露出来。./unit_tests --gtest_shuffle --gtest_random_seed12345--gtest_repeat10会重复执行整轮测试十次。这个用来抓偶发失败特别有效并发相关的 bug 经常是跑一百次挂一次。./unit_tests --gtest_repeat100 --gtest_break_on_failure--gtest_break_on_failure在第一次失败时就触发断点如果挂了调试器就是断下来配合调试器用能直接停在出错现场比看日志再回推快得多。5.5 覆盖率与持续集成测试写得再多也想知道到底覆盖了多少代码。Linux 上用 gcov/lcov 是标配方案编译时加覆盖率选项。g -stdc17 --coverage -O0 -g \ src/calculator.cpp tests/calculator_test.cpp \ -I src -lgtest -lgtest_main -lgmock -lpthread \ -o unit_tests ./unit_tests lcov --capture --directory . --output-file coverage.info lcov --remove coverage.info /usr/* */tests/* --output-file coverage_filtered.info genhtml coverage_filtered.info --output-directory coverage_html生成的 HTML 报告能逐行标出哪些代码被执行过、哪些没有。这里的关键经验是一定要过滤掉测试代码本身和系统头文件否则覆盖率数字会被稀释得很难看。覆盖率这个指标要正确理解。行覆盖率高不代表测试质量高因为可能存在执行了但这行没有断言验证的情况。我更关注分支覆盖率尤其是错误处理分支有没有被覆盖到。一般项目里我给自己设的底线是新增代码分支覆盖率不低于 80%但绝不把这个数字当成 KPI 去凑——为了凑覆盖率写一堆无意义的测试比不写测试更糟。CI 接入层次上gtest_discover_tests注册到 CTest 之后直接跑ctest --output-on-failure --parallel 4就行。--parallel会让 CTest 按用例并行调度在多核 CI 机器上能显著缩短反馈时间。# CI 配置的核心步骤伪代码示意 - run: cmake -B build -DCMAKE_BUILD_TYPEDebug - run: cmake --build build -j - run: ctest --test-dir build --output-on-failure --parallel 4在 Debug 构建里同时打开 AddressSanitizer 和 UndefinedBehaviorSanitizer能把很多隐藏的内存错误和未定义行为在测试阶段就抓出来。代价是运行慢两三倍但换来的是问题在合并前而不是上线后暴露这笔账怎么算都划算。6. 常见问题与排查技巧实录6.1 编译与链接类问题链接错误在 gtest 新手阶段出现频率最高因为 CMake target 的命名和链接顺序有讲究。最常见的报错是undefined reference to testing::...。这几乎总是意味着你没链接 gtest 库或者链接了gtest但没链gtest_main导致main缺失反过来如果你自己写了 main 又链了gtest_main会出现重复定义的main。推荐直接用 CMake 的 target 名GTest::gtest、GTest::gtest_main、GTest::gmock、GTest::gmock_main别手写库名能避开路径和顺序问题。第二个高频报错在 Windows 上_ITERATOR_DEBUG_LEVEL不匹配、RuntimeLibrary冲突。原因是 gtest 编译时的运行时库设置和你的工程不一致。解决办法是在FetchContent之前设gtest_force_shared_crt ON或者确保两边都用/MD或都用/MT。第三个是pthread相关符号找不到。Linux 上必须链接线程库虽然新版 gtest 的 CMake target 帮你带了但手写编译命令容易漏。加上-lpthread就行。6.2 运行期行为类问题测试跑起来了但行为不对通常有几类原因。一类是断言宏用错。比如把EXPECT_EQ用在字符串比较上测试通过了但其实是错的因为比的是指针。再比如在返回非 void 的函数里用ASSERT编译直接报错。这类问题特征明显检查断言宏家族和函数返回类型就能定位。一类是状态污染。表现是单个跑通过、一起跑失败或者反过来。根本原因通常是静态变量或者全局单例在测试之间没有重置。排查手段就是用--gtest_shuffle打乱顺序多跑几轮如果失败模式跟着顺序变基本可以确诊。解决方法是在TearDown里显式重置全局状态或者干脆重构掉全局变量。还有一类是夹具使用不当。典型症状是期望新构造的对象结果拿到了上一个测试留下的。这往往是错误地用了SetUpTestSuite而不是SetUp或者反过来。记住SetUp是每用例一次SetUpTestSuite是整个套件一次成员变量如果是共享的需要特别小心。6.3 问题速查表现象最可能原因快速验证方式解决办法undefined reference to testing::...没链接 gtest 库检查 CMake 的target_link_libraries链接GTest::gtest_main_ITERATOR_DEBUG_LEVEL不匹配Windows 运行时库设置冲突查编译选项里的/MD/MT设gtest_force_shared_crt ONpthread_create未定义漏链线程库检查编译命令是否含-lpthread加上-lpthreadASSERT 编译报错写在返回非 void 的函数里看报错行所在函数签名改用 EXPECT 或改成 void 函数单跑过、合跑挂测试间共享全局状态用--gtest_shuffle复现在 TearDown 里重置状态过滤器没生效shell 展开了通配符命令里给过滤器加引号用--gtest_filterX.*死亡测试报没死语句块正常返回了检查块内是否有必然崩溃语句确保块内输出错误并终止mock 析构时报预期未满足EXPECT_CALL设置了但没触发看报错位置与 mock 生命周期确认被测代码真的调用了6.4 几条踩出来的实操心得第一测试先写能跑起来的最小集合不要一上来追求覆盖所有分支。先搭好工程、跑通一两个用例把构建流程和 CI 都打通再逐步加用例。反过来做的人十有八九卡在构建环节就放弃了。第二失败信息是你花时间最多的地方值得在断言上多花心思。能用EXPECT_EQ就别用EXPECT_TRUE(a b)因为前者的失败信息会打印两个实际值后者只告诉你表达式是 false。这个差别在排查复杂对象比较时体现得淋漓尽致。第三SCOPED_TRACE是投入产出比最高的技巧之一。它在辅助函数、参数化测试、循环断言里都能用几行代码就能让失败信息从某个断言在某个文件某行失败了升级成某个输入组合在哪一轮失败了。第四别把测试写死。参数化测试、外部数据文件、生成器组合这些能力存在的意义就是让你用更少的代码覆盖更多的场景。我见过太多项目写了一百多个TEST宏每个只改一个输入值维护起来极其痛苦。这种时候换成参数化代码量能砍掉八成。第五测试代码也是代码同样需要 review 和重构。断言写得不清晰、辅助函数重复、夹具设计不合理这些问题会随着测试规模增长而累积。定期回看测试代码把重复的断言逻辑抽出来把不稳定的测试修掉而不是绕过长期来看能省下大量时间。6.5 关于覆盖率数字的一点个人看法最后说个容易让人焦虑的事。很多团队引入 gtest 之后会立刻上覆盖率门禁要求 PR 的覆盖率不低于某个百分比。这个做法本身没错但执行起来容易变形。见过最极端的例子是为了过门禁开发在测试里调用一遍函数就算覆盖了一个断言都不写。这种覆盖毫无价值反而给了团队虚假的安全感。我的做法是把覆盖率报告当成导航而不是考核。每季度看一次报告重点找那些核心业务逻辑、错误处理路径、边界条件分支没有被覆盖的地方针对性地补测试。新增代码要求写测试但不强制卡一个具体数字而是靠 review 时人工判断关键路径有没有被验证。这样既能保证测试质量又不会催生为了凑数而写的垃圾测试。这套思路用下来项目里 gtest 的测试从最初的十几个用例涨到上千个跑一轮大概十几秒绝大多数回归问题在提交前就拦住了。回头看投入在测试框架上的那点时间是整个项目里回报率最高的投入之一。