
1. 先说清楚C单元测试到底解决什么问题为什么我最终选了gtest做C开发超过十年我踩过的最痛的一个坑不是内存泄漏也不是指针悬空而是代码明明能编译能跑上线之后却在边缘场景翻车。后来我复盘发现绝大多数这类问题根本不是设计上的缺陷而是函数在特定输入下返回了错误结果而我当时根本没测到那条分支。从那以后我在项目里强制推行单元测试。所谓单元测试就是把代码拆成最小可验证的单元通常是一个函数或一个类针对它写测试代码验证它在各种输入下行为是否符合预期。C这个语言比较特殊——它有手动内存管理、强类型、模板、多继承这些特性再加上编译链接流程本身的复杂性导致C项目的测试在工程落地上的难度远高于Python、Java这些语言。所以很多C团队即使想测也会被环境搭建和编译问题劝退。市面上的C测试框架有好几个CppUnit、Catch2、doctest、Boost.Test我基本都过了一遍最终团队统一用的还是gtest。原因有三点第一gtest由Google维护社区活跃度最高遇到问题几乎都能搜到答案第二它的断言体系极其丰富从基础比较到异常检查到死亡测试都能覆盖不需要额外造轮子第三它支持死亡测试Death Test和参数化测试Parameterized Test这两点对C这种底层语言来说太重要了——你不仅要测正常流程更要测崩溃分支。这篇内容我不会去抄官方文档而是把我从零开始引入gtest、搭环境、写用例、最后集成到CI持续集成里的完整经验梳理出来。不管你是刚学C的学生还是工作中被测试覆盖率折磨的工程师照着这篇文章走一遍应该都能把gtest用起来。2. 环境搭建把gtest编译出来并跑通第一个用例2.1 获取gtest的三种常见方式与选型建议第一步当然是把gtest拿到手。官方仓库地址是GitHub上的google/googletest当前release版本已经到1.14.0我写这篇时最新的稳定版是1.14.0版本号很稳定。获取源码的方式有三种直接git clone官方仓库下载release版本的tar.gz压缩包通过包管理器安装vcpkg、Conan、apt等我的建议非常简单如果你只是个人学习或者项目不复杂直接用git clone官方仓库把源码拖到自己工程里用CMake编译这样最灵活。如果你公司项目用的是vcpkg或Conan做依赖管理那直接通过包管理装更省事。至于apt直接装libgtest-dev我强烈不建议因为Ubuntu仓库里的gtest版本通常偏老而且编译出来之后头文件和库文件的路径很乱容易折腾半天。注意gtest在1.10版本之后官方把原先的gtest-all.cc编译方式改成了需要显式链接gtest和gtest_main两个库很多人卡在这一步——只链接了gtest而忘记链接gtest_main导致报一堆undefined reference to testing::UnitTest::Run()之类的错。原因在于gtest_main库中提供了main函数的默认实现如果你不链接它就必须自己写main函数调用RUN_ALL_TESTS。2.2 推荐方案CMake FetchContent构建gtest现在C项目的主流构建方式基本是CMake所以最顺手的方式就是通过FetchContent把gtest源码拉下来跟自己的项目一起编译。这种方式有一个天然优势gtest的源码会和你的测试代码一起编译编译器版本、C标准、架构完全一致不大会出现ABI应用二进制接口不兼容的问题。以CMake配置为例你的顶层CMakeLists.txt可以这样写cmake_minimum_required(VERSION 3.14) project(MyProject LANGUAGES CXX) set(CMAKE_CXX_STANDARD 11) set(CMAKE_CXX_STANDARD_REQUIRED ON) # 关闭gtest自带的main库冲突宏 option(INSTALL_GTEST OFF) option(gtest_force_shared_crt ON) include(FetchContent) FetchContent_Declare( googletest URL https://github.com/google/googletest/releases/download/v1.14.0/googletest-1.14.0.tar.gz ) # 如果GitHub下载不稳定可以换成国内镜像或本地路径 # FetchContent_Declare(googletest SOURCE_DIR ${CMAKE_SOURCE_DIR}/third_party/googletest) FetchContent_MakeAvailable(googletest)然后在你的测试目录下建一个CMakeLists.txt专门负责测试目标的编译# tests/CMakeLists.txt add_executable(unit_tests test_main.cpp test_string_util.cpp test_math_util.cpp ) target_link_libraries(unit_tests PRIVATE your_project_lib gtest_main gtest ) include(GoogleTest) gtest_discover_tests(unit_tests)这里有两个关键细节第一target_link_libraries里把gtest_main和gtest都链接上gtest_main里面封装了main函数的入口第二gtest_discover_tests这个CMake函数会在构建后自动扫描测试用例并且在使用ctest命令时自动注册每个测试用例方便后续集成CI。如果你不想用FetchContent也可以手动把googletest源码放到third_party目录下把FetchContent_Declare里的URL换成SOURCE_DIR路径做法一样好处是依赖完全本地化构建时不依赖外网。2.3 第一个能跑的测试用例从零到绿依赖编译好之后接下来干的事就是写第一个测试文件。这里我会用一个极简的字符串工具函数作为被测对象验证gtest的基本工作流程。被测试的模块我们假设在src目录下有一个math_util.h#ifndef MATH_UTIL_H #define MATH_UTIL_H int Add(int a, int b); bool IsEven(int n); #endif对应实现 math_util.cpp#include math_util.h int Add(int a, int b) { return a b; } bool IsEven(int n) { return n % 2 0; }测试文件 test_math_util.cpp#include gtest/gtest.h #include math_util.h TEST(MathUtilTest, AddWorksInPositiveRange) { EXPECT_EQ(Add(1, 2), 3); EXPECT_EQ(Add(10, 20), 30); } TEST(MathUtilTest, AddWorksWithZero) { EXPECT_EQ(Add(0, 0), 0); EXPECT_EQ(Add(0, 5), 5); } TEST(MathUtilTest, IsEvenChecksParity) { EXPECT_TRUE(IsEven(4)); EXPECT_FALSE(IsEven(7)); }测试文件里用到的TEST宏是gtest最基本的宏语法是TEST(TestSuiteName, TestName)。TestSuiteName是我们自定义的测试套件名可以简单理解为将一组相关的测试用例分组TestName是这个分组里具体的一个测试用例名。这两个名字合起来构成一个唯一的测试用例标识。然后写test_main.cpp内容如下#include gtest/gtest.h int main(int argc, char **argv) { ::testing::InitGoogleTest(argc, argv); return RUN_ALL_TESTS(); }不过既然我们已经链接了gtest_main理论上这个文件不是必须的。但我建议还是自己写上因为在真实项目中你大概率需要InitGoogleTest之后做一些自定义的初始化和全局配置比如过滤用例、设置输出格式、加载测试数据等。自己写main是常规操作熟练之后更灵活。编译运行之后你会看到终端输出类似于[] Running 3 tests from 1 test suite. [----------] Global test environment set-up. [----------] 3 tests from MathUtilTest [ RUN ] MathUtilTest.AddWorksInPositiveRange [ OK ] MathUtilTest.AddWorksInPositiveRange (0 ms) [ RUN ] MathUtilTest.AddWorksWithZero [ OK ] MathUtilTest.AddWorksWithZero (0 ms) [ RUN ] MathUtilTest.IsEvenChecksParity [ OK ] MathUtilTest.IsEvenChecksParity (0 ms) [----------] 3 tests from MathUtilTest (0 ms total) [----------] Global test environment tear-down [] 3 tests from 1 test suite ran. (1 ms total) [ PASSED ] 3 tests.看到这里你的gtest环境就算正式跑通了。这一步走通之后后面的所有东西都是在丰富这座房子的装修但地基已经稳了。3. 断言体系详解测试用例的成败全靠它3.1 EXPECT与ASSERT的区别一个继续跑一个直接停写过测试的人都知道断言是测试的灵魂。gtest的断言分成两大族EXPECT_*系列和ASSERT_*系列。很多新手搞不清楚这两个的区别导致了大量调试时间的浪费。EXPECT_XX断言失败后测试继续执行该测试用例在报告里标记为FAILEDASSERT_XX断言失败后当前测试用例立即中止执行后面的语句不会再跑简单来说EXPECT是记录问题但继续往下走ASSERT是这个前提已经坏了后面没必要再跑。我的经验是对于那些当前步骤失败则后续步骤没有任何意义的场景应该用ASSERT对于那些即使这项失败我还想看看其他项是否正常的场景用EXPECT。举个例子如果你在测试一个类第一步要创建对象如果创建失败那后面所有方法调用都没有意义这时候就必须用ASSERT_NO_FATAL_FAILURE或者ASSERT_NE(ptr, nullptr)。但如果只是验证返回值的多个属性就用EXPECT逐个验证。3.2 最常用的几组断言速查在实际工作中我用得最多的断言是下面这些。整理成一张表方便你快速查阅断言形式验证内容注意事项EXPECT_EQ(a, b) / ASSERT_EQ(a, b)a等于b对于浮点数不要直接EQ精度问题会导致偶发失败EXPECT_NE(a, b)a不等于b常用于指针是否为nullptr的验证EXPECT_TRUE(condition)condition为真适合布尔返回值EXPECT_FALSE(condition)condition为假适合错误标志位EXPECT_GT / EXPECT_LT大于 / 小于边界测试常用EXPECT_GE / EXPECT_LE大于等于 / 小于等于与GT/LT互补EXPECT_STREQ(str1, str2)C风格字符串相等不要用EXPECT_EQ比较char*因为比较的是指针地址EXPECT_NEAR(a, b, abs_error)浮点数在误差范围内相等第三个参数是绝对误差EXPECT_THROW(statement, exception_type)抛出指定异常针对异常流程EXPECT_NO_THROW(statement)不抛任何异常验证正常流程不异常EXPECT_DEATH(statement, regex)进程死亡且输出匹配regex专门测崩溃场景3.3 关于浮点数比较和字符串比较的两个深坑先聊浮点数。C里直接比较两个浮点数相等是危险的原因在于浮点数在计算机中是用二进制近似存储的0.1 0.2算出来很可能不是0.3而是0.30000000000000004。如果你写EXPECT_EQ(0.1 0.2, 0.3)大概率会得到一个悲惨的失败报告。gtest的EXPECT_NEAR就是专门解决这个问题的你需要为它指定一个容差TEST(MathUtilTest, FloatAddition) { double result 0.1 0.2; EXPECT_NEAR(result, 0.3, 1e-9); }容差参数的选择很有讲究。如果你的值是几十亿级别1e-9的绝对误差很可能完全不合适因为浮点数的表示精度随着数值增大而下降。在这种场景下应该使用相对误差比较的方式或者把数值scale到同一量级再比较。gtest在1.11版本后新增了EXPECT_DOUBLE_EQ它会在内部使用ULPUnits in the Last Place比较策略比固定容差更鲁棒这也是我比较推荐的做法。再看字符串。C里字符串可以分两种char* / const char* 的C风格字符串以及std::string。gtest对这两种字符串提供了不同的断言方式。如果你用EXPECT_EQ直接比较两个const char*实际上比较的是指针地址而不是字符串内容几乎必然失败。正确做法是用EXPECT_STREQ。如果被测试的是std::string那么EXPECT_EQ是支持直接比较的因为std::string重载了operator不过为了统一我通常都会把std::string转成const char*再用EXPECT_STREQ或者直接用EXPECT_EQ传std::string对象前者在做跨平台兼容时更稳。3.4 自定义失败信息让你的测试报告不再让人抓狂默认情况下gtest在断言失败时会打印文件和行号、断言表达式以及实际值和期望值。但很多时候这些信息不够直观尤其是当你在循环里测试一批数据时光看期望值3实际值5根本不知道是哪组数据出了问题。gtest提供了两种方式添加失败信息。第一种是断言宏的流式输出for (int i 0; i 100; i) { EXPECT_EQ(ComputeValue(i), ExpectedValue(i)) 计算第 i 组数据时出错; }这样在断言失败时输出里会附带计算第 42 组数据时出错排查效率翻倍。第二种是直接用SCOPED_TRACE宏在当前作用域内给所有断言附加一条上下文信息TEST(MathUtilTest, BatchCheck) { for (int i 0; i 100; i) { SCOPED_TRACE(迭代次数: std::to_string(i)); EXPECT_EQ(ComputeValue(i), ExpectedValue(i)); } }SCOPED_TRACE的好处是它作用域内任意一条断言失败都会在输出中带上这条上下文非常适合在多层循环或者复杂测试场景里定位问题。这两个特性属于知道了就回不去的那种技巧强烈建议掌握。4. 测试夹具与生命周期管理让测试用例学会共享4.1 为什么需要测试夹具假设你正在测试一个自定义的智能指针类或者一个负责读写配置文件的配置管理器。这些被测对象有一个共同点每个测试用例开始前都需要做复杂的初始化工作测完之后还需要做清理工作。如果每个TEST宏里都重复写初始化代码不仅代码量爆炸而且一旦初始化步骤变了所有测试用例都得跟着改。gtest为此提供了测试夹具Test Fixture机制用一个继承自::testing::Test的类来承载共享的初始化和清理逻辑。测试夹具的核心是SetUp和TearDown两个虚函数分别在每个测试用例执行前和执行后被调用。在测试代码里当你需要访问夹具的成员变量时要用TEST_F宏而不是TEST宏。TEST_F的第一个参数必须是夹具类名第二个参数是测试用例名。gtest会在内部为你创建一个夹具对象并在该对象上依次调用SetUp、运行测试体、TearDown。4.2 一个完整的测试夹具示例下面我用一个简单的Stack类来做示例这是一个基础版本的栈实现支持Push、Pop、Top、IsEmpty等操作。为了测试它我需要在不同用例之间共享一组预设好的数据。#include gtest/gtest.h #include vector class Stack { public: void Push(int v) { data_.push_back(v); } void Pop() { if (!data_.empty()) data_.pop_back(); } int Top() const { return data_.empty() ? -1 : data_.back(); } bool IsEmpty() const { return data_.empty(); } size_t Size() const { return data_.size(); } private: std::vectorint data_; }; // 定义测试夹具 class StackTest : public ::testing::Test { protected: void SetUp() override { // 每个用例运行前往栈里压入三个数据 stack_.Push(1); stack_.Push(2); stack_.Push(3); } void TearDown() override { // 每个用例运行后的清理动作 // 这里其实什么都不用做栈对象会自动析构 // 但真实项目中可能涉及释放外部资源、删除临时文件等操作 } Stack stack_; }; // 测试Pop后元素出现在栈顶 TEST_F(StackTest, PopRemovesTopElement) { stack_.Pop(); EXPECT_EQ(stack_.Top(), 2); EXPECT_EQ(stack_.Size(), 2); } // 测试Top不会改变栈的规模 TEST_F(StackTest, TopDoesNotChangeSize) { int top stack_.Top(); EXPECT_EQ(top, 3); EXPECT_EQ(stack_.Size(), 3); }在上面的代码里StackTest是夹具类它是一个空壳叠加了SetUp/TearDown逻辑。当TEST_F(StackTest, PopRemovesTopElement)执行时gtest会在内部创建一个StackTest对象调用它的SetUp函数把stack_初始化成[1,2,3]然后执行测试体最后调用TearDown并销毁对象。下一个测试用例同样如此——每个用例都是独立的夹具生命周期互不干扰。4.3 测试用例共享 vs 测试套件共享这里要把概念拆清楚。TEST_F本身做到了测试用例级别的隔离每个用例都有一份全新的夹具。但如果你希望在整个测试套件级别只初始化一次数据而不是每个用例初始化一次该怎么做比如被测对象是一个需要启动外部服务的Manager类每次SetUp都启动服务很浪费时间。gtest提供了另外两个钩子函数SetUpTestSuite和TearDownTestSuite。注意它们是在测试套件级别调用且必须是静态函数。假如你要构造一个数据库连接池希望整个StackTest套件只建立一次连接测试用例之间共用这个连接就可以用这两个函数。需要说明的是它们从gtest 1.10版本开始建议使用带TestSuite后缀的命名旧版本中带TestCase后缀的写法已经被标记为弃用。一个简单的例子class DatabaseTest : public ::testing::Test { protected: static void SetUpTestSuite() { // 整个测试套件执行前只调用一次 db_ Database::Connect(test_connection); } static void TearDownTestSuite() { // 整个测试套件执行完毕后只调用一次 db_-Close(); db_ nullptr; } static Database* db_; }; Database* DatabaseTest::db_ nullptr; TEST_F(DatabaseTest, QueryWorks) { ASSERT_NE(db_, nullptr); EXPECT_TRUE(db_-Query(SELECT 1)); }使用静态数据成员的夹具确实存在一个风险如果多个测试用例依赖同一个共享状态而某个测试用例修改了它后续用例可能会受影响。所以我的建议是尽量只在初始化成本极高、且不会在测试中被修改的场景下使用套件级别的共享其他情况坚持用例级别的隔离。4.4 实战中夹具最常见的三种误用第一SetUp里忘记调用父类SetUp。你的夹具类如果又继承了一层带SetUp的类那么子类中必须显式调用Base::SetUp()否则基类初始化逻辑不会自动执行。同样TearDown也要记得调用。第二在TEST_F里访问了未初始化的成员。如果你的夹具类包含指针类型成员而你在SetUp里忘了给它赋值那么在测试体里解引用这个指针就是未定义行为。建议在所有指针成员初始化后立即断言比如void SetUp() override { ptr_ new SomeObject(); ASSERT_NE(ptr_, nullptr); // 这里不要用EXPECT因为后续测试依赖ptr_ }第三TestBody中修改共享状态的副作用。很多人会把某些对象声明成static成员来提速却忘记测试用例执行顺序可能不同导致测试结果随机失败。gtest默认的执行顺序不保证稳定一旦用例间产生状态依赖轻则测试偶发失败重则带来大量调试时间的浪费。单元测试的黄金法则是每个测试应该独立运行共享状态是一个需要谨慎使用的特性。5. 参数化测试一份测试代码跑N组数据5.1 什么是参数化测试它解决什么问题很多时候单个测试用例的逻辑是完全相同的只是输入参数不同。比如你写了一个排序函数想验证它对空数组、单个元素、逆序数组、含重复元素数组、超长数组等不同输入的排序结果。如果每个场景都写一个TEST宏代码会大量重复。gtest的参数化测试Parameterized Test就是为了解决这个问题。你可以把一组测试数据传入测试用例gtest会为每组数据都执行一次测试体从而用一份代码覆盖多种输入。参数化测试的具体写法分为三个步骤定义参数化测试夹具类、使用TEST_P宏写测试、实例化测试数据。下面用排序函数作为例子演示。5.2 一个可运行的参数化测试示例假设被测函数是std::vectorint BubbleSort(std::vectorint input);参数化测试的代码如下#include gtest/gtest.h #include vector // 第一步定义参数化测试夹具继承testing::TestWithParamT // T是我们期望传入的参数类型 class BubbleSortTest : public ::testing::TestWithParamstd::vectorint { protected: void SetUp() override { // 可以在这里做一些与参数无关的通用初始化 } }; // 第二步使用TEST_P宏P代表Parameterized TEST_P(BubbleSortTest, SortsCorrectly) { std::vectorint input GetParam(); std::vectorint result BubbleSort(input); // 验证排序结果原序列的排序结果必须是一个非递减序列 ASSERT_EQ(result.size(), input.size()); for (size_t i 1; i result.size(); i) { EXPECT_LE(result[i - 1], result[i]) 排序后第 i 个元素不合规; } } // 第三步用INSTANTIATE_TEST_SUITE_P实例化数据 INSTANTIATE_TEST_SUITE_P( SortDataset, // 前缀用于生成完整测试名 BubbleSortTest, // 夹具类名 ::testing::Values( // 测试数据列表 std::vectorint{}, std::vectorint{1}, std::vectorint{2, 1}, std::vectorint{5, 4, 3, 2, 1}, std::vectorint{3, 1, 3, 2, 1}, std::vectorint{10, 100, 42, 0, -1, 99} ) );编译运行后gtest会为每一组数据生成一个独立的测试用例测试用例的命名规则是INSTANTIATE前缀 斜杠 测试套件名 斜杠 索引编号例如SortDataset/BubbleSortTest.SortsCorrectly/0。这样当某一组数据测试失败时你能直观地看到是哪组数据出了问题。注意INSTANTIATE_TEST_SUITE_P这个名字在gtest 1.10之后才出现更早的版本叫INSTANTIATE_TEST_CASE_P。后者已经被标记为弃用但网上还有很多旧文章引用它如果你使用新版本gtest而看到编译告警说INSTANTIATE_TEST_CASE_P已不再建议使用可以直接把宏名改成新版本。5.3 更多参数化方式Range、Combine与类型参数化除了Values直接列出所有测试数据gtest还提供了几种参数生成器对实战非常有价值。Range生成器等距序列INSTANTIATE_TEST_SUITE_P(NumericRange, NumberTest, ::testing::Range(1, 100, 10)); // 1, 11, 21, ..., 91ValuesIn从容器或数组中取数据std::vectorstd::string samples {abc, bca, cab}; INSTANTIATE_TEST_SUITE_P(StringSample, StringTest, ::testing::ValuesIn(samples));Combine实现多参数笛卡尔积INSTANTIATE_TEST_SUITE_P(MultiParam, ComboTest, ::testing::Combine( ::testing::Values(1, 2, 3), ::testing::Values(x, y) ));这套机制在协议解析、算法实现、配置模块的测试里用处很大。我通常会把边界值、异常值、正常值都放在参数列表里一份测试用例把这三类覆盖完毕。需要提醒的是参数化数据如果数量非常大测试输出会变得异常冗长上百个用例一次跑下来终端滚动半天。这种情况建议在运行测试时加上--gtest_brief1参数gtest 1.11版本之后支持只输出失败的用例和汇总信息非常清爽。6. 死亡测试程序崩溃也要纳入测试范围6.1 什么是死亡测试怎么测崩溃C程序里有很多函数在非法输入时不会抛异常而是直接断言失败或者主动退出进程。比如某些底层库在传入空指针时会直接abort这种死给你看的行为反而是一种受保护的设计——总比带着脏数据继续跑导致更隐蔽的问题要好。gtest的死亡测试机制专门用来验证这类程序应当崩溃的场景。它的实现原理是gtest会把测试体放到一个子进程中运行子进程崩溃后父进程检查子进程的退出状态和输出信息以此判断是否符合预期。这个机制保证了测试框架本身的进程不会因为被测代码的崩溃而终止。死亡测试常用断言有两种EXPECT_DEATH(statement, regex); ASSERT_DEATH(statement, regex);statement是被测的一段代码regex是对崩溃时进程输出信息的正则表达式。gtest默认在子进程崩溃且输出与regex匹配时判定测试通过。如果你只是想让测试验证代码会崩溃不太关心崩溃的原因可以传一个空字符串但空字符串不会匹配任何非空输出所以更常见的做法是写成EXPECT_DEATH(statement, )。6.2 一个现实场景验证空指针访问确实会让程序崩溃假设你有一段代码是这样的class Account { public: explicit Account(double balance) : balance_(balance) {} double GetBalance() const { return balance_; } private: double balance_; }; void PrintAccount(Account* account) { // 这里没有判空如果传入nullptr会崩溃 printf(%.2f\n, account-GetBalance()); }对应的死亡测试TEST(AccountTest, PrintNullAccountCrashes) { Account* bad_ptr nullptr; EXPECT_DEATH(PrintAccount(bad_ptr), .*); }注意这个测试在不同的编译选项下可能行为不同。如果你在Debug模式下编译访问空指针通常会在解引用时触发段错误但在某些Release 编译器优化场景下空指针解引用可能被优化掉或产生未定义行为导致死亡测试结果不稳定。所以我建议死亡测试尽量在Debug模式下运行并且配合ASanAddressSanitizer用效果更佳。6.3 死亡测试的坑与执行模式的切换需要注意一点gtest在死亡测试用例执行时默认会启用死亡测试风格Death Test Style有threadsafe和fast两种模式。fast模式下子进程通过fork来创建某些依赖多线程或全局状态的应用在fork时会出问题。如果遇到这类诡异的现象可以通过环境变量或参数切换./unit_tests --gtest_death_test_stylethreadsafe在线程安全的模式下gtest使用子进程的方式更安全但性能会下降。实际开发中我一般默认用threadsafe仅在测试非常耗时时才会专门切回fast模式。另外死亡测试不要滥用。它只适合测试程序确实会因为某个原因崩溃的断言场景。如果一个崩溃在真实环境中本可以被上层捕获或避免但它不是我们预期的行为那就应该判断成代码缺陷而不是测试用例的预期死亡。6.4 测试过滤与运行控制只需要跑一个用例的时候怎么办当你修改了一个函数想快速验证不想每次都跑全量的几千个测试gtest提供了几个非常有用的命令行参数。# 只跑测试套件中名字含MathUtil的用例 ./unit_tests --gtest_filterMathUtil* # 只跑某一个测试套件下的某个用例 ./unit_tests --gtest_filterMathUtilTest.AddWorksInPositiveRange # 跳过某个用例用负号匹配 ./unit_tests --gtest_filter-MathUtilTest.IsEvenChecksParity # 同时支持多个正负混合 ./unit_tests --gtest_filterMathUtil*:-MathUtilTest.IsEvenChecksParity配合正则表达式还能实现斜杠分隔的复杂过滤条件不过日常开发中上面这种通配符匹配已经足够用了。另外两个参数也很常用# 运行完输出XML格式的测试报告CI系统Jenkins、GitLab CI通常用这个 ./unit_tests --gtest_outputxml:test_report.xml # 列出所有测试用例但不执行 ./unit_tests --gtest_list_tests在实际工作中我习惯把--gtest_filter和CI流水线配合使用——提交代码到MR阶段只跑受影响的测试用例合并之后全量跑一遍既能节约大量排队时间又能保证最终质量。这个思路你可以直接抄作业。7. 测试覆盖率光有测试还不够还得知道测了多少7.1 覆盖率工具怎么配合gtest使用写了测试用例只是第一步。你还需要知道自己的测试到底覆盖了被测代码的多少分支。C常用的覆盖率工具有gcov/lcov基于GCC/Clang、OpenCppCoverageWindows等。以Linux CMake为例通常的做法是在编译时加上覆盖率相关参数重新编译测试目标set(CMAKE_CXX_FLAGS ${CMAKE_CXX_FLAGS} --coverage -fprofile-arcs -ftest-coverage) set(CMAKE_EXE_LINKER_FLAGS ${CMAKE_EXE_LINKER_FLAGS} --coverage)编译完后运行测试程序会生成gcda之类的覆盖率数据文件再用lcov把数据解析成html报告./unit_tests lcov --capture --directory . --output-file coverage.info lcov --remove coverage.info /usr/* */test/* --output-file coverage_filtered.info genhtml coverage_filtered.info --output-directory coverage_report打开生成的html页面你可以看到每个源文件的覆盖情况。我干过最蠢的一件事就是写完一堆测试觉得自己稳了结果拿覆盖率报告一看被测模块里的核心函数根本没有任何测试命中。没有覆盖率数据一切都是盲猜。7.2 覆盖率目标定多少合适在多数C团队里行覆盖率指标通常会定在80%到90%之间。但我不建议对每行代码都强行追求高覆盖因为有些代码是防御性检查如参数校验有些是异常分支编织它们会让测试成本非常高。更合理的做法是对核心业务模块要求高覆盖对边缘模块给一个宽松的阈值同时把分支覆盖率branch coverage看得比行覆盖率line coverage更重要因为行覆盖率只记录某一行有没有被执行而分支覆盖率能记录某一个if的两个分支是否都被走到——很多隐藏Bug恰恰来自没被测试到的分支。覆盖率的定位始终应该是发现盲区的工具而不是KPI。数值高不代表测试质量强真正有价值的测试是那些帮助你找到Bug、防止回归的用例。8. 常见问题与避坑实录我踩过的那些gtest的坑8.1 链接错误std::string相关符号找不到场景你在测试代码里使用了std::string但链接时报了一堆与std::__cxx11::basic_string相关的undefined reference。原因分析最常见的是gtest库与被测代码不是同一个编译器编译的或者ABI版本不匹配。在GCC 5之后标准库中存在两种std::string的ABI实现分别对应_GLIBCXX_USE_CXX11_ABI宏的0和1两种取值。如果gtest是用旧宏编译的而你的被测代码用新宏编译就会出现这种链接问题。解决办法永远不要让gtest以预编译二进制形式跨编译器使用。要么用FetchContent把源码拉下来一起编译要么用vcpkg安装与你的编译环境匹配的版本。如果确实需要使用预编译包请严格确认编译器的major version和库版本完全一致。8.2 中文路径和中文输出乱码Windows上如果你把测试工程放在带中文的路径下gtest在同时使用MSVC和GBK字符集时可能输出乱码严重时会导致测试报告解析失败。更糟糕的是某些断言输出中文信息时因为编码问题而造成断言结果判定的干扰。我的建议是项目路径和测试用例的SCOPED_TRACE信息一律只使用ASCII字符。如果测试数据本身需要中文使用代码中构造字符串的方式并确保源码文件保存为UTF-8 with BOM格式MSVC项目建议开启/utf-8编译选项。这一步在团队协作中特别重要一旦不同成员的本地字符集不同测试行为就可能出现差异。8.3 测试用例之间相互影响共享变量的风险有些老代码会使用全局变量或者static局部变量。这类测试对象天然困难如果测试用例A修改了某个全局变量的值测试用例B读取到的就是一个被修改过的状态两个用例独立运行都没问题一起跑就失败。对这种代码我会先做测试顺序无关验证方法很简单用--gtest_shuffle参数跑测试同时用--gtest_random_seed定一个种子让用例顺序打乱来观察是否出现随机失败。./unit_tests --gtest_shuffle --gtest_random_seed42如果打乱顺序后出现失败几乎可以断定代码中存在共享状态污染。这时候需要做两个层面的修复第一在测试层面对全局状态做save/restore例如在SetUp里记录旧值TearDown里恢复第二更重要的是推动重构被测代码逐步消除全局可变状态。测试的价值就在这里——它把那些最脏、最不合理的代码暴露出来促使你改进设计。8.4 测试执行时间太长CI跑不动如果单元测试数量从几十个涨到上千个执行时间就成了一个麻烦。我遇到过最痛的情况是某个测试用例每跑一次需要5秒——所有并行CI加起来要跑10分钟。后来排查发现这个测试用例在SetUp阶段做了一次不必要的磁盘文件读写而且文件系统是网络盘。把临时文件从网络盘切到本地tmp目录之后单次执行降到100毫秒以内。在工程层面遇到测试慢的问题我的排查优先级是一看是否访问了外部服务数据库、HTTP接口如果是优先mock掉二看是否做了大量文件IO尽量使用tmpfs或内存文件系统三看是否有不必要的大数据拷贝用std::move或引用传参加速。8.5 gtest与CI流水线的集成最后分享一个集成侧的经验。gtest自带的gtest_discover_tests已经能自动登记测试用例与CMake的ctest完美衔接。在CI配置里我通常把测试拆成两个step快速验证编译后直接运行测试程序不加任何过滤器全部跑一遍得到一个基础结果覆盖率检查运行测试生成覆盖率数据随后执行lcov和genhtml把报告上传到内部平台在GitLab CI或Jenkins里如果某个测试失败了我们最关心的是两件事哪个用例失败、失败时有哪些上下文信息。所以我会在CI脚本里强制指定--gtest_outputxml让CI系统能自动关联到具体的失败用例并把测试日志的编码统一设置为UTF-8避免中文乱码导致解析失败。9. 几个值得养成的测试习惯聊了这么多gtest的技术细节最后分享几个我这些年总结出来的、写在团队规范里的习惯。这些内容不在官方文档里但它们对测试工程的影响远比会用宏大得多。第一先写测试后写实现。如果可能尽量按TDD测试驱动开发的思路来。你不用做得很激进哪怕只是先写一个空函数体让测试编译通过然后再一步步把实现填上也能让代码设计的接口从一开始就是可测试的。反过来如果先实现后补测试大概率会发现函数的耦合度高得没法测只好回头重构。第二每个测试用例只验证一个行为。如果一个TEST里同时验证了排序结果正确和输入数组未被修改两个属性第二个属性失败时你需要花额外精力排查是哪个行为不达标。保持测试用例单一、精简并在失败信息里明确说明你期望验证的行为。第三测试代码和被测代码一样要维护。很多人写完测试代码就跑路后面被测需求变了测试代码不及时同步更新导致测试失败率飙升久而久之团队干脆手动跳过测试。实际上测试代码应该和生产代码一样经过代码评审它的质量直接影响整个工程的安全性。第四定期随机化跑测试。我每个月会抽出时间在本地跑一次--gtest_shuffle 不同种子的全量测试专门用来发现隐藏的顺序依赖。有一段时间我们团队几乎每周都能抓到一两个由全局变量状态污染导致的随机失败后来把这类问题集中清理干净之后CI整体稳定率提升了一大截。gtest上手并不难难的是把它真正变成一个能让你安心重构、高效发现问题的工程质量基石。希望这篇文章能帮你少走一些弯路。如果你在搭建过程中遇到什么奇怪的编译问题或者发现了某些独到的用法欢迎在评论区交流我也会持续更新自己的实践心得。