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:断言失败后,测试继续执行,该测试用例在报告里标记为FAILED
- ASSERT_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,它会在内部使用ULP(Units 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::vector<int> 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::vector<int> BubbleSort(std::vector<int> input);参数化测试的代码如下:
#include <gtest/gtest.h> #include <vector> // 第一步:定义参数化测试夹具,继承testing::TestWithParam<T> // T是我们期望传入的参数类型 class BubbleSortTest : public ::testing::TestWithParam<std::vector<int>> { protected: void SetUp() override { // 可以在这里做一些与参数无关的通用初始化 } }; // 第二步:使用TEST_P宏,P代表Parameterized TEST_P(BubbleSortTest, SortsCorrectly) { std::vector<int> input = GetParam(); std::vector<int> 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::vector<int>{}, std::vector<int>{1}, std::vector<int>{2, 1}, std::vector<int>{5, 4, 3, 2, 1}, std::vector<int>{3, 1, 3, 2, 1}, std::vector<int>{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::vector<std::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_brief=1参数,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模式下运行,并且配合ASan(AddressSanitizer)用,效果更佳。
6.3 死亡测试的坑与执行模式的切换
需要注意一点:gtest在死亡测试用例执行时,默认会启用死亡测试风格(Death Test Style),有threadsafe和fast两种模式。fast模式下子进程通过fork来创建,某些依赖多线程或全局状态的应用在fork时会出问题。如果遇到这类诡异的现象,可以通过环境变量或参数切换:
./unit_tests --gtest_death_test_style=threadsafe在线程安全的模式下,gtest使用子进程的方式更安全,但性能会下降。实际开发中,我一般默认用threadsafe,仅在测试非常耗时时才会专门切回fast模式。
另外,死亡测试不要滥用。它只适合测试"程序确实会因为某个原因崩溃"的断言场景。如果一个崩溃在真实环境中本可以被上层捕获或避免,但它不是我们预期的行为,那就应该判断成代码缺陷而不是测试用例的预期死亡。
6.4 测试过滤与运行控制:只需要跑一个用例的时候怎么办
当你修改了一个函数想快速验证,不想每次都跑全量的几千个测试,gtest提供了几个非常有用的命令行参数。
# 只跑测试套件中名字含MathUtil的用例 ./unit_tests --gtest_filter=MathUtil* # 只跑某一个测试套件下的某个用例 ./unit_tests --gtest_filter=MathUtilTest.AddWorksInPositiveRange # 跳过某个用例,用负号匹配 ./unit_tests --gtest_filter=-MathUtilTest.IsEvenChecksParity # 同时支持多个正负混合 ./unit_tests --gtest_filter=MathUtil*:-MathUtilTest.IsEvenChecksParity配合正则表达式还能实现斜杠分隔的复杂过滤条件,不过日常开发中上面这种通配符匹配已经足够用了。
另外两个参数也很常用:
# 运行完输出XML格式的测试报告,CI系统(Jenkins、GitLab CI)通常用这个 ./unit_tests --gtest_output=xml:test_report.xml # 列出所有测试用例但不执行 ./unit_tests --gtest_list_tests在实际工作中,我习惯把--gtest_filter和CI流水线配合使用——提交代码到MR阶段只跑受影响的测试用例,合并之后全量跑一遍,既能节约大量排队时间,又能保证最终质量。这个思路你可以直接抄作业。
7. 测试覆盖率:光有测试还不够,还得知道测了多少
7.1 覆盖率工具怎么配合gtest使用
写了测试用例只是第一步。你还需要知道自己的测试到底覆盖了被测代码的多少分支。C++常用的覆盖率工具有gcov/lcov(基于GCC/Clang)、OpenCppCoverage(Windows)等。以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_seed=42如果打乱顺序后出现失败,几乎可以断定代码中存在共享状态污染。这时候需要做两个层面的修复:第一,在测试层面对全局状态做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_output=xml,让CI系统能自动关联到具体的失败用例,并把测试日志的编码统一设置为UTF-8,避免中文乱码导致解析失败。
9. 几个值得养成的测试习惯
聊了这么多gtest的技术细节,最后分享几个我这些年总结出来的、写在团队规范里的习惯。这些内容不在官方文档里,但它们对测试工程的影响远比"会用宏"大得多。
第一,先写测试,后写实现。如果可能,尽量按TDD(测试驱动开发)的思路来。你不用做得很激进,哪怕只是先写一个空函数体让测试编译通过,然后再一步步把实现填上,也能让代码设计的接口从一开始就是可测试的。反过来,如果先实现后补测试,大概率会发现函数的耦合度高得没法测,只好回头重构。
第二,每个测试用例只验证一个行为。如果一个TEST里同时验证了"排序结果正确"和"输入数组未被修改"两个属性,第二个属性失败时你需要花额外精力排查是哪个行为不达标。保持测试用例单一、精简,并在失败信息里明确说明你期望验证的行为。
第三,测试代码和被测代码一样要维护。很多人写完测试代码就跑路,后面被测需求变了,测试代码不及时同步更新,导致测试失败率飙升,久而久之团队干脆手动跳过测试。实际上测试代码应该和生产代码一样经过代码评审,它的质量直接影响整个工程的安全性。
第四,定期随机化跑测试。我每个月会抽出时间在本地跑一次--gtest_shuffle + 不同种子的全量测试,专门用来发现隐藏的顺序依赖。有一段时间我们团队几乎每周都能抓到一两个由全局变量状态污染导致的随机失败,后来把这类问题集中清理干净之后,CI整体稳定率提升了一大截。
gtest上手并不难,难的是把它真正变成一个能让你安心重构、高效发现问题的工程质量基石。希望这篇文章能帮你少走一些弯路。如果你在搭建过程中遇到什么奇怪的编译问题,或者发现了某些独到的用法,欢迎在评论区交流,我也会持续更新自己的实践心得。