
写 C 的人大概都有过这种经历接手一个模块改了三行代码编译过了自己手点了一遍觉得没问题提交。第二天上游说数据错了回溯半天才发现是某个边界条件下走的分支没覆盖到。这种时候你就会想要是有个东西能替我把这些手点一遍的动作固化下来就好了。GoogleTest后面简称 gtest就是干这个的它是 Google 开源的 C 单元测试框架可以在 Linux、Windows、macOS 上跑跟 CMake、CTest、CI 流程配合得也顺。这篇标题里写了从入门到入门我觉得挺贴切——第一次入门是让测试跑起来第二次入门是知道它背后在干什么、什么时候会骗你。前者半天能学会后者得踩几个坑。下面我按自己当初摸索的顺序来写适合刚接触 C 单元测试的同学也适合已经在用 gtest 但只会写EXPECT_EQ的人。1. 把 gtest 接进项目三种引入方式与最小可跑工程很多人卡在第一步不是不会写测试而是不知道 gtest 该怎么装。它不是一个apt install就完事的库虽然很多发行版有包因为编译参数、C 标准、CRT 这些细节一旦不匹配报错信息会非常难读。所以我建议先想清楚自己要哪种引入方式。1.1 源码内嵌、包管理器、FetchContent 各适合什么场景三种主流方式各有取舍我拿实际项目对比过引入方式适合场景优点代价系统包管理器安装公司已有统一的编译环境镜像装一次全局可用构建快版本被发行版锁死跨平台不一致源码放进仓库子目录需要改 gtest 源码、内网无法访问外网完全自控离线可用仓库体积变大升级要手动同步CMake FetchContent绝大多数正常项目版本在配置里写清楚克隆即用首次 configure 需要能访问代码托管我自己现在默认用 FetchContent理由是版本号写在CMakeLists.txt里谁拉下来编译的都是同一份 gtest不会出现我这能过你那不能过。内网环境就把FetchContent_Declare的地址换成内网镜像其余不用动。1.2 一份最小可用的 CMakeLists 与两种 main 写法先明确一个前提近几年发布的 gtest 版本基本都要求 C 14 起步所以CMAKE_CXX_STANDARD至少给到 14我个人习惯直接给 17。下面这份是我常用的骨架可以直接抄cmake_minimum_required(VERSION 3.16) project(demo 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 ) # MSVC 下避免 CRT 混用导致的链接错误 set(gtest_force_shared_crt ON CACHE BOOL FORCE) # 别让 gtest 把自己 install 到系统目录 set(INSTALL_GTEST OFF CACHE BOOL FORCE) FetchContent_MakeAvailable(googletest) enable_testing() include(GoogleTest) add_library(mathcore STATIC src/mathcore.cpp) target_include_directories(mathcore PUBLIC src) add_executable(mathcore_test tests/test_mathcore.cpp) target_link_libraries(mathcore_test PRIVATE mathcore GTest::gtest_main) gtest_discover_tests(mathcore_test)关键点说三个。第一set(gtest_force_shared_crt ON)只有 Windows 上才需要考虑Linux 上留着也无害它解决的是 MSVC 运行库模式不一致时的LNK2038类报错。第二INSTALL_GTEST OFF建议加上否则某些 CMake 组合下你的install目标会顺带把 gtest 的头文件装进系统路径后面想换版本会打架。第三GTest::gtest_main是个带 main 的 gtest链接它就不需要自己写main如果链接GTest::gtest你就得自己写#include gtest/gtest.h int main(int argc, char** argv) { ::testing::InitGoogleTest(argc, argv); return RUN_ALL_TESTS(); }这四行是自定义 main 的标配InitGoogleTest那行千万别省后面的坑章节会专门讲为什么。测试文件本身简单到不可思议#include gtest/gtest.h #include mathcore.h TEST(MathCoreTest, AddsTwoPositiveNumbers) { EXPECT_EQ(Add(1, 2), 3); }cmake -B build cmake --build build cd build ctest --output-on-failure三步应该就能看到100% tests passed。1.3 链接期的报错怎么读别急着搜新手最容易在链接阶段就放弃其实常见的就那么几类。undefined reference to testing::Test::Test()基本是忘了链GTest::gtest_main或GTest::gtestduplicate symbol _main是你既链了gtest_main又自己写了main二选一undefined reference to testing::internal::...而链的是系统包多半是头文件和库文件版本对不上比如头文件来自 1.14 而.a是更老的版本。还有一种特别隐蔽的CMake 里用了target_include_directories手动指到了 gtest 的include目录同时又通过 FetchContent 链了目标结果两套头文件路径混着用。我的经验是能通过target_link_libraries传播的东西就别手写 include 路径。2. TEST 宏背后发生了什么写第一个用例前该知道的约定写测试不难难的是写出不会被自己坑的测试。想做到这一点得先知道TEST这两个字母展开后到底变成了什么。2.1 TEST 展开后的真实样子TEST(MathCoreTest, AddsTwoPositiveNumbers)大致会展开成下面这样细节省略class MathCoreTest_AddsTwoPositiveNumbers_Test : public ::testing::Test { public: MathCoreTest_AddsTwoPositiveNumbers_Test() default; private: void TestBody() override; static ::testing::TestInfo* const test_info_; }; void MathCoreTest_AddsTwoPositiveNumbers_Test::TestBody() { // 你写的大括号里的内容 }看到那个类名里的下划线了吗gtest 用下划线把测试套件名和测试名拼成一个类名靠一套静态注册机制在main执行前把所有用例登记到全局表里。这就解释了两件事。第一为什么测试名必须是合法的 C 标识符——它真的要参与生成类型名。第二为什么要避免自己再带下划线因为你名字里本来就有下划线拼接后到底哪段是套件名哪段是用例名就分不清了--gtest_filter写起来也容易出错。官方文档明确建议测试套件名和测试名都不要包含下划线我踩过一次用TEST(Parse_Test, Handles_Empty)这种写法后面想在命令行里按套件过滤纠结了半天。2.2 断言家族ASSERT 与 EXPECT 的选择标准gtest 的断言分两类名字只差前几个字母行为差得很远断言家族失败后的行为适用位置ASSERT_*终止当前测试函数直接返回前置条件检查比如指针非空、文件已打开EXPECT_*记录失败继续往下执行绝大多数业务断言选择逻辑其实就一句话失败之后后面的代码还能不能安全执行。比如你ASSERT_NE(ptr, nullptr)之后才ptr-DoSomething()那必须用 ASSERT否则 EXPECT 会一路跑到空指针解引用测试进程直接崩你看到的就不是断言失败而是段错误。反过来如果你在循环里比对一批数据希望一次跑出所有不一致项那就必须用 EXPECT改成 ASSERT 只能看到第一条。有个限制需要提前知道ASSERT_*只能用在返回void的函数里。你在一个返回int的辅助函数里写ASSERT_EQ编译期就会报错。这也是为什么夹具的初始化逻辑建议放SetUp()而不是构造函数。2.3 让失败信息自己会说话断言失败时gtest 默认会打印期望值、实际值、所在文件和行号但上下文往往不够。我一般会给断言挂个说明EXPECT_EQ(record.size(), expected_size) records: record.size() , expected: expected_size , raw input: raw.substr(0, 64);在循环里做断言的时候我更推荐SCOPED_TRACE它会把当前迭代信息附加到后续所有失败的输出里for (int i 0; i samples.size(); i) { SCOPED_TRACE(::testing::Message() sample index i); EXPECT_EQ(samples[i].parsed, samples[i].expected); }不用这个你只会看到第 37 行失败用了之后输出里会明确告诉你这是第几个样本失败。CI 上排查偶发问题这一条能省下大量时间。3. 夹具 TEST_F共享初始化、隔离状态与执行顺序只要测试需要先准备好点什么就绕不开夹具。TEST_F里的 F 是 Fixture用法是继承::testing::Test然后把夹具类名当第一个参数传进去。3.1 为什么初始化写 SetUp() 而不是构造函数class TempFileTest : public ::testing::Test { protected: void SetUp() override { path_ std::filesystem::temp_directory_path() / gtest_demo.txt; std::ofstream(path_) hello; } void TearDown() override { std::error_code ec; std::filesystem::remove(path_, ec); } std::filesystem::path path_; }; TEST_F(TempFileTest, FileIsReadable) { ASSERT_TRUE(std::filesystem::exists(path_)); }放SetUp()有两个实在的好处。第一在SetUp()里可以正常使用ASSERT_*而构造函数和析构函数不属于返回 void 的函数这个范畴往里塞致命断言会编译不过。第二SetUp()里失败了gtest 报告的是夹具初始化失败 具体断言行指向性明确同样的事情写在构造函数里报错点会飘到用例注册的地方读起来费劲。TearDown()则用来清理注意它是在断言失败之后也会执行的所以清理逻辑要写成对半初始化状态也安全的形式我上面给std::filesystem::remove传了error_code就是为了不抛异常。3.2 每个用例一个新对象这才是隔离的本质很多人以为夹具对象是复用的其实不是。gtest 对每一个使用该夹具的用例都会新建一个夹具对象执行顺序是构造夹具 →SetUp()→TestBody()→TearDown()→ 析构夹具。也就是说用例之间天然不共享成员变量。这解释了一个常见困惑你在用例 A 里改了成员变量在用例 B 里读到的是初始值不是你预期的修改后结果。想跨用例传递状态本身就是设计错误真要共享只读的昂贵资源应该用下一节讲的套件级初始化。顺带一提夹具类名同时也是测试套件名。TEST_F(TempFileTest, ...)里的第一个参数必须和夹具类名完全一致写错了编译会报找不到这个类或者一堆成员访问错误。3.3 套件级初始化与全局环境如果每个用例都去连一次数据库或加载一次大字典测试会慢到没人愿意跑。这时候用静态的套件级钩子class DictTest : public ::testing::Test { protected: static void SetUpTestSuite() { dict_ LoadBigDictionary(); } static void TearDownTestSuite() { dict_.reset(); } static std::shared_ptrconst Dictionary dict_; };SetUpTestSuite和TearDownTestSuite在该套件的第一个用例执行前和最后一个用例执行后各跑一次。但请记住它们是共享状态如果某个用例修改了里面的数据会污染同套件的其他用例这种 bug 排查起来非常痛苦。我的规则是套件级只放只读数据任何可能被修改的东西一律放SetUp()。再往上一层还有::testing::Environment可以在所有测试之前和之后各跑一次适合做全局日志初始化这类事但这个我在业务项目里基本不用因为它会让单个测试文件无法独立运行。4. 参数化、类型参数化与死亡测试把重复用例压扁一旦你发现自己在复制粘贴同一个用例改参数就该换工具了。4.1 TEST_P 与 INSTANTIATE_TEST_SUITE_P 的配合参数化测试解决的是同一段逻辑、同一批期望输入不同。写法分三步struct ParseCase { std::string input; int expected; }; class ParseIntTest : public ::testing::TestWithParamParseCase {}; TEST_P(ParseIntTest, ParsesDecimal) { const ParseCase c GetParam(); EXPECT_EQ(ParseInt(c.input), c.expected); } INSTANTIATE_TEST_SUITE_P( DecimalInputs, ParseIntTest, ::testing::Values( ParseCase{0, 0}, ParseCase{42, 42}, ParseCase{-7, -7}), [](const ::testing::TestParamInfoParseCase info) { return Case std::to_string(info.index); });有几个地方我踩过。第一夹具必须继承TestWithParamT不是Test。第二INSTANTIATE_TEST_SUITE_P忘了写编译能过但一个用例都不会生成ctest显示成功非常具有欺骗性。第三那个名字生成器返回的字符串只能由字母和数字组成带下划线或空格会直接报错因为它同样要参与生成标识符。第四参数组合多的时候优先用::testing::Combine(::testing::Values(...), ::testing::Bool())而不是手写嵌套循环报告里每个组合是独立用例失败定位更准。4.2 TYPED_TEST一份逻辑跑多种类型模板代码的测试很尴尬你不可能给每个实例化类型都写一遍。类型参数化就是为这个准备的template typename T class ContainerTest : public ::testing::Test { protected: T c_; }; using MyContainerTypes ::testing::Typesstd::vectorint, std::listint, std::dequeint; TYPED_TEST_SUITE(ContainerTest, MyContainerTypes); TYPED_TEST(ContainerTest, StartsEmptyAndGrows) { EXPECT_TRUE(this-c_.empty()); this-c_.push_back(1); EXPECT_EQ(this-c_.size(), 1u); }这里最容易忘的是this-前缀。夹具成员在模板基类里属于依赖名称编译器默认不会去基类里找不加this-会报没有这个成员。我头几回被这个坑得很惨明明ContainerTest里定义了c_就是找不到。4.3 死亡测试与匹配器处理崩溃和复杂结构有些函数的设计就是出错就崩比如底层的参数校验。这类行为用普通断言测不了因为进程直接没了得用死亡测试void CheckRange(int v) { if (v 0 || v 100) { std::fprintf(stderr, value out of range: %d\n, v); std::abort(); } } TEST(RangeTest, AbortsWhenOutOfRange) { EXPECT_DEATH(CheckRange(101), out of range); }第二个参数是正则用来匹配进程崩溃前输出到标准错误的内容。写正则有个讲究不要用.*这种什么都匹配的一是太松等于没验证二是太宽的正则在某些版本上会触发框架自身的校验。写一个确定会出现在输出里的片段就行。抛异常的场景用EXPECT_THROW(stmt, ExceptionType)只关心有没有抛就用EXPECT_ANY_THROW确定不该抛就用EXPECT_NO_THROW。复杂结构的比对建议上 gmock 的匹配器头文件是gmock/gmock.husing ::testing::ElementsAre; using ::testing::UnorderedElementsAre; using ::testing::SizeIs; using ::testing::AllOf; using ::testing::Gt; using ::testing::Lt; TEST(MatcherTest, ContainerLayout) { std::vectorint v{3, 1, 2}; EXPECT_THAT(v, ElementsAre(3, 1, 2)); EXPECT_THAT(v, UnorderedElementsAre(1, 2, 3)); EXPECT_THAT(v, SizeIs(3)); EXPECT_THAT(42, AllOf(Gt(0), Lt(100))); }ElementsAre关心顺序UnorderedElementsAre不关心这个区分在测集合类返回结果时特别有用能避免测试因为实现换了遍历顺序而假失败。另外浮点数一定别用EXPECT_EQ用EXPECT_FLOAT_EQ、EXPECT_DOUBLE_EQ或者明确容差的EXPECT_NEAR(a, b, 1e-9)精确相等比较在浮点世界里基本等于碰运气。5. 从能跑到敢用CTest、命令行开关与覆盖率接入测试写完只是开始能融进日常流程才算数。5.1 gtest_discover_tests 与 CTest 的分工前面 CMake 里那行gtest_discover_tests(mathcore_test)是关键。它的作用是在构建之后自动运行一次测试二进制并列出所有用例把每个用例注册成 CTest 的一个独立测试项。好处是ctest -j8能并行跑失败项在 CTest 的报告里是独立条目一眼能看出是哪个用例挂了。相比之下如果你只写add_test(NAME unit COMMAND mathcore_test)整个二进制就是一条测试失败了还得翻日志找具体用例。交叉编译场景要注意gtest_discover_tests默认在构建期执行目标程序来枚举用例如果目标可执行文件在宿主机上跑不了得加上DISCOVERY_MODE PRE_TEST让它推迟到测试执行阶段再枚举。日常命令我常用这几条ctest --output-on-failure # 只看失败的输出 ctest -j8 --output-on-failure # 并行 ctest -R ParseIntTest # 按名字正则过滤跑一部分5.2 --gtest_filter、--gtest_shuffle、--gtest_repeat 的实战用法即使不经过 CTest直接跑测试二进制时这些开关也很有用./mathcore_test --gtest_list_tests ./mathcore_test --gtest_filterParseIntTest/* ./mathcore_test --gtest_filter*Test.*-*Test.Slow* ./mathcore_test --gtest_shuffle --gtest_random_seed20240501 ./mathcore_test --gtest_repeat50 --gtest_break_on_failure ./mathcore_test --gtest_outputxml:report.xml--gtest_list_tests是我排查问题的第一招它不需要真正跑测试直接列出所有被注册的用例专门用来确认我以为注册了的用例到底有没有注册上。--gtest_filter里冒号分隔多个正向模式减号后面是排除模式*匹配任意字符、?匹配单个字符。--gtest_shuffle打乱执行顺序配合--gtest_random_seed固定种子这是暴露用例之间有隐藏依赖的最有效手段跑出来挂了就把种子记下来能稳定复现。--gtest_repeat搭配--gtest_break_on_failure用来抓偶发问题跑到失败就中断之后可以用 gdb 挂上去。5.3 覆盖率与 CI 产物的最小闭环覆盖率不是为了追求数字好看而是用来发现写了测试文件但某个分支从来没被执行过。GCC 下最简单的做法是编译时开--coveragecmake -B build -DCMAKE_BUILD_TYPEDebug \ -DCMAKE_CXX_FLAGS--coverage -O0 -g cmake --build build -j cd build ctest --output-on-failure lcov --capture --directory . --output-file cov.info \ --exclude /usr/* --exclude *googletest* genhtml cov.info --output-directory cov_html注意--exclude那个参数一定要把 gtest 自己的源码目录排掉否则你的覆盖率报告里会有一大堆框架代码看着就烦。CI 上我一般做三件事跑ctest、把--gtest_outputxml生成的报告作为测试结果组件上传、把覆盖率报告作为构建产物存档。指标只做参考不做卡点因为强行卡覆盖率最容易催生的就是一堆只调用不断言的凑数用例。6. 那些让你怀疑人生的坑0 个测试、假通过、顺序依赖这部分是我觉得比 API 文档更值得写的内容。6.1 0 tests from 0 test suites用例被链接器吃掉了第一次遇到这个提示的时候我以为自己文件没加进构建检查了半天CMakeLists.txt都对。真实原因在链接器身上gtest 靠静态对象在main之前注册用例如果你的测试用例被编译成了一个静态库而没有任何符号被主程序引用链接器会认为它没用直接把整个目标文件丢掉注册代码自然也不会执行。三种解法。最直接的是别把测试用例打成静态库直接add_executable编译进去如果因为架构原因必须分层就在链接时强制加载整个库target_link_libraries(my_test PRIVATE -Wl,--whole-archive test_support -Wl,--no-whole-archive GTest::gtest_main)MSVC 对应的是/WHOLEARCHIVE:test_support。第三种是加一个显式的引用符号但那种写法很脏不推荐。判断方法就是跑一次--gtest_list_tests输出为空基本就是这个原因。6.2 自定义 main 忘了 InitGoogleTest自己写main而没调用::testing::InitGoogleTest(argc, argv)症状是测试能跑但--gtest_filter、--gtest_output这些命令行参数全部失效--gtest_filter写了跟没写一样。因为解析参数的活儿就在这个函数里。更隐蔽的是--gtest_repeat不生效你以为跑了 100 遍其实只跑了 1 遍。这个错误一旦犯过就再也不会犯了。6.3 假通过断言写在了不会执行的分支里比测试失败更糟的是测试通过但什么都没测。我见过几种典型。一种是把断言写在if里面条件恒假断言永远不执行一种是循环体为空比如for (auto x : empty)还有一种是把被测函数包在try里然后catch(...) {}全部吞掉函数抛异常测试却绿了。想防这个可以在关键测试里加一个确认执行到的哨兵或者干脆看覆盖率里那些 0 命中的行。另外用例名的可信度也要警惕——名字叫HandlesEmptyInput不代表它真的测了空输入。6.4 顺序依赖与随机种子默认情况下 gtest 按注册顺序执行用例这个顺序在不同编译器、不同文件排列下可能不一样。如果你的某个用例依赖另一个用例先跑过比如依赖某个全局单例被初始化那它在本地能过到 CI 上可能就挂。发现手段就是--gtest_shuffle跑几轮就能暴露出来。修复思路是消除共享可变状态每个用例自己把依赖准备好。另外GTEST_SKIP()用来跳过不适用当前环境的用例是合理的比如 Windows 专属功能在 Linux 上跳过但别用DISABLED_前缀当临时关掉的挡箭牌那种前缀会连编译都跳过很容易被遗忘在代码里好几年。6.5 一个可复用的排查顺序遇到测试相关的奇怪问题我现在的固定排查顺序是这样的现象第一步看什么常见根因提示 0 个测试--gtest_list_tests用例被链接器丢弃或INSTANTIATE缺失编译过但用例不生成检查宏的第一个参数类型TEST_P夹具没继承TestWithParam命令行开关没反应main里有没有InitGoogleTest自定义 main 遗漏初始化断言不报错但行为不对覆盖率报告里对应行是否命中断言在不可达分支里本地过、CI 挂加--gtest_shuffle复现用例间共享状态、顺序依赖偶发失败--gtest_repeat--gtest_break_on_failure时间、随机数、并发、外部资源按这个表顺着走绝大多数问题十分钟内能定位。最后补一个实际项目里的做法如果被测类的关键成员是私有的可以在类里声明FRIEND_TEST(SuiteName, TestName)让指定用例访问私有成员比#define private public那种宏替换干净得多也不会污染整个头文件的语义。不过话说回来如果一段逻辑非得靠访问私有成员才能验证往往说明它的可测性设计得不够好值得回头看看接口拆分。