☰
GoogleTest C++单元测试实战:断言、夹具、Mock与CMake集成
2026/10/2 4:54:20 网站建设 项目流程

1. GoogleTest 到底是干什么的,值不值得花时间学

我带的几个新同事,第一个月基本都栽在同一件事上——写完一个函数,随手在main里printf两行,看到输出对了就提交合并。三个月后需求一改,谁都不敢动那段代码,因为没人知道动完之后哪个分支会崩。后来我给团队立了个规矩:新模块必须带单元测试。框架试用了一圈,从 Boost.Test、Catch2 到 GoogleTest,最后还是统一到了 GoogleTest,也就是大家常说的 gtest。原因很朴素:它跨平台、文档够用、生态成熟、和 CMake 结合得顺,新人上手成本也低。

先把话说清楚。GoogleTest 是 Google 开源的一套 C++ 单元测试框架,跑在 xUnit 架构上,核心能力是三件事:写断言、组织测试用例、自动收集并运行它们。它本身不负责构建,也不负责持续集成,你把它理解成"一个帮你把测试写规范、跑起来、还能报出漂亮结果的库"就够了。配套的 gMock 是它的兄弟项目,专门用来造假对象,把你的被测代码从数据库、网络、硬件这些外部依赖里隔离出来。

这套东西适合谁?如果你是写 C++ 的,不管你做的是后端服务、嵌入式、桌面软件还是算法库,只要你的代码能编译成库或者可执行文件,GoogleTest 都能用。纯小白也别慌,你只要会写函数、会写if,再把 CMake 的基本用法摸清楚,这篇文章足够你从零开始跑通第一个测试。反过来说,如果你只写一次性脚本、代码寿命不超过一周,那确实没必要上框架,写两行assert就够了。

我特别想强调一点:单元测试框架的价值不在"测出 bug",而在"防止你已经修好的 bug 再回来"。回归才是它真正省钱的地方。我统计过自己负责的一个模块,引入 gtest 之后,线上因为"改 A 处崩 B 处"引发的问题下降了大概七成,这个收益远比测试本身写起来的那点时间成本高。

下面我按实际落地的顺序来讲:先搭环境,再写第一个测试,然后依次把夹具、参数化、Mock、CMake 集成、问题排查讲透。每一块我都会告诉你"为什么这么选",而不只是"这么写能跑"。


2. 环境搭建:三条路线,按你的场景挑

2.1 源码编译:最通用也最不容易出幺蛾子

很多人卡在第一步不是因为不会写测试,而是因为 gtest 没装明白。它和普通的apt install库不太一样,GoogleTest 的推荐用法是把源码拉下来,用 CMake 编译成静态库,然后链接进你的测试工程。这样做的好处是版本可控,团队里每个人用的都是同一份代码,不会出现"你机器上能过、CI 上挂了"这种事。

拉代码很简单:

git clone https://github.com/google/googletest.git cd googletest cmake -S . -B build -DBUILD_GMOCK=ON -DCMAKE_BUILD_TYPE=Release cmake --build build -j 8

编译完之后,build/lib目录下会出来几个文件:libgtest.a、libgtest_main.a、libgmock.a、libgmock_main.a。这里有个新手特别容易搞混的点——gtest_main和gtest是两个不同的库。libgtest.a只提供宏和断言实现,不提供main函数,你得自己写;libgtest_main.a里自带一个main,它会自动帮你调用初始化并运行所有测试。什么时候用哪个?我的习惯是:只要没有特殊初始化需求,一律链接gtest_main,省掉一个手写的main,代码干净。如果测试前需要读配置文件、连内存数据库之类,那就用gtest自己写main。

关于版本,近几个大版本把最低 C++ 标准往上抬了:1.12 之后基本要求 C++14,更新的版本已经要求 C++17。如果你的项目还锁在 C++11,建议就用 1.10 或 1.11,别硬上新版,否则编译期会给你一堆error: 'auto' 说明符需要 C++14之类的报错,排查起来很耽误事。

2.2 包管理器与 FetchContent:省事但要注意版本漂移

不想编译源码的话,可以用系统包管理器。Ubuntu 上是sudo apt install libgtest-dev libgmock-dev,注意有些老发行版里libgtest-dev装完之后还要去/usr/src/googletest手动编译一次才会生成静态库,这个坑很多人都踩过——装完了却找不到libgtest.a,以为装失败了,其实只是没编译。

我更推荐现代 CMake 的FetchContent方式,它把"下载 + 配置 + 编译 + 链接"四步合成了一件事:

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)

用FetchContent最大的好处是版本钉死在GIT_TAG上,任何人 clone 你的仓库都能拉到同一份代码。这里有个必须强调的实操心得:一定要写具体的 tag,别写main。我见过有同学图省事写GIT_TAG main,结果某天上游改了 API,第二天 CI 全红,人还一脸懵。用FetchContent的另一个隐藏福利是,测试代码可以和生产代码共享一套编译选项和第三方依赖,不用你手工维护两套配置。

2.3 验证环境是否真的通了

装完之后别急着写业务测试,先跑一个最小例子确认链路通了:

#include <gtest/gtest.h> TEST(SmokeTest, EnvironmentWorks) { EXPECT_EQ(1 + 1, 2); }

链接gtest_main编译运行,如果输出类似下面的内容,说明环境没问题:

[==========] Running 1 test from 1 test suite. [----------] Global test environment set-up. [----------] 1 test from SmokeTest [ RUN ] SmokeTest.EnvironmentWorks [ OK ] SmokeTest.EnvironmentWorks (0 ms) [----------] 1 test from SmokeTest (0 ms total) [==========] 1 test from 1 test suite ran. (0 ms total) [ PASSED ] 1 test.

注意:如果编译时报undefined reference to testing::InitGoogleTest,八成是忘了链接libgtest.a;如果报undefined reference to main,那是忘了libgtest_main.a。这两个错误信息我见过太多次了,先查链接库准没错。


3. 核心骨架:TEST 宏与断言体系

3.1 TEST 宏展开之后到底发生了什么

TEST(TestSuiteName, TestName)这个宏看起来只是个语法糖,但它背后做的事其实挺多。它会自动生成一个类,继承自::testing::Test,并在类里定义一个TestBody()方法,你写的花括号里的内容就是TestBody的实现。同时它会在全局注册表里登记这个类,这样RUN_ALL_TESTS()遍历的时候就能找到它。

TestSuiteName是测试套件的名字,TestName是具体用例名,两者拼起来就是全名TestSuiteName.TestName。这个名字很重要,因为后面跑测试、过滤测试、看报告都靠它。命名我建议遵循一个约定:套件名用被测的类名或模块名,用例名用方法名_场景_预期结果的格式。比如TEST(StringUtil, Trim_WithLeadingSpaces_ReturnsTrimmed),这样光看名字就知道它在验证什么,出问题的时候不用回去翻代码。

还有一条必须刻在脑子里的规则:同一个套件里,用例名不能重复。gtest 检测到重复会直接报编译错误或者运行期报错,不会默默覆盖。我见过有人复制粘贴忘了改名字,排查半天才发现是重名。

3.2 ASSERT 和 EXPECT 到底怎么选

gtest 的断言分两大类,区别在于失败之后是否继续往下跑:

  • ASSERT_*系列失败会直接return,当前用例剩下的语句不再执行
  • EXPECT_*系列失败只记录结果,继续跑完当前用例

这个区别为什么重要?举个例子,你写了一段从容器里取数据再计算的代码。如果容器是空的,取元素这一步就已经错了,后面再验证计算结果毫无意义,还会输出一堆误导性的失败信息。这时候应该用ASSERT。反过来,如果你要验证一个对象的五个字段,其中一个字段不对不代表其他字段也不能看,应该用EXPECT,让自己一次看到全部问题。

我个人的经验法则是:前置条件、指针解引用、容器取值这类"后面依赖前面"的操作,用ASSERT;独立的语义验证,一律用EXPECT。默认倾向EXPECT,因为它能给你更完整的信息。还有一点,ASSERT在返回void的函数里才能用,如果你在辅助函数里用ASSERT但函数返回值不是void,编译会直接报错,这个错误提示通常不太友好,看到它先想想是不是自己在返回值函数里用了ASSERT。

3.3 常用断言速查表

断言种类很多,全背下来没必要。我把实际写代码时最常用的整理成表,剩下的遇到再查文档就行。

断言用途是否致命
ASSERT_TRUE(cond)/EXPECT_TRUE(cond)布尔判断ASSERT 致命
ASSERT_EQ(a, b)/EXPECT_EQ(a, b)相等判断ASSERT 致命
ASSERT_NE/EXPECT_NE不等ASSERT 致命
ASSERT_LT/LE/GT/GE大小比较ASSERT 致命
EXPECT_FLOAT_EQ(a, b)浮点近似相等,容忍 4 ULP 误差否
EXPECT_NEAR(a, b, eps)指定误差范围,自定义精度否
EXPECT_STREQ(a, b)C 字符串内容比较否
EXPECT_THROW(stmt, type)期望抛出指定类型异常否
EXPECT_NO_THROW(stmt)期望不抛异常否
EXPECT_ANY_THROW(stmt)期望抛任意异常否
SUCCEED()/FAIL()强制标记成功 / 失败FAIL 致命

关于浮点比较我要多说两句,这是新人最容易写错的地方。EXPECT_EQ(0.1 + 0.2, 0.3)会失败,因为浮点数在计算机里是二进制近似表示,加法之后有个极小的误差。正确做法是EXPECT_NEAR(0.1 + 0.2, 0.3, 1e-9),明确告诉框架"我允许这么大的误差"。这个误差阈值怎么定?看你的业务精度要求:图像处理里 1e-4 可能都够了,金融计算可能得 1e-12。别直接抄别人的值,按你自己的量纲算。

字符串同理,EXPECT_EQ(str1, str2)比较的是std::string对象本身,这个是可以的;但如果你手上是const char*,必须用EXPECT_STREQ,否则比较的是两个指针地址,几乎永远不相等——这个坑我见过太多人踩了,测试莫名其妙失败,其实只是比较了一个地址。


4. 测试夹具 TEST_F:把重复初始化收干净

4.1 SetUp / TearDown 的调用时机

你很快就会遇到一个问题:好几个用例都需要构造同一个对象、加载同一份测试数据。如果每个用例里都写一遍,代码又长又容易改漏。gtest 提供了夹具(Fixture)来解决这件事。

写法是继承::testing::Test,在里面重写SetUp()和TearDown(),然后所有用例用TEST_F而不是TEST。

#include <gtest/gtest.h> #include <vector> class StackTest : public ::testing::Test { protected: void SetUp() override { // 每个用例执行前都会跑一次 for (int i = 1; i <= 3; ++i) { stack_.push(i); } } void TearDown() override { // 每个用例执行后都会跑一次,即使 SetUp 抛异常也会执行 // 一般用来释放文件句柄、断开连接 } std::vector<int> stack_; }; TEST_F(StackTest, SizeAfterSetupIsThree) { EXPECT_EQ(stack_.size(), 3u); } TEST_F(StackTest, TopElementIsThree) { ASSERT_FALSE(stack_.empty()); EXPECT_EQ(stack_.back(), 3); }

这里有几个关键点必须讲清楚。SetUp和TearDown是每个用例各跑一次,不是所有用例跑一次。这意味着用例之间天然隔离,一个用例里对stack_的改动不会影响下一个用例。这也是我推荐夹具而不是全局变量的原因——全局状态是单元测试最大的敌人。

如果你确实需要"所有用例跑一次"的共享资源,比如启动一次内存数据库,那应该用SetUpTestSuite()和TearDownTestSuite()(老版本叫SetUpTestCase,1.10 之后改名了,编译报错的话先查这个)。这两个是静态方法,在套件里所有用例开始前和结束后各跑一次。

TEST_F里能直接访问夹具的成员,是因为它生成的类继承自你的夹具类,成员是protected所以子类能看见。注意成员必须写成protected或者public,写成private的话子类访问不到,编译会报错。

4.2 夹具使用中的几个坑

第一个坑:构造和析构 vs SetUp 和 TearDown 的选择。夹具类的构造函数和析构函数也会在每个用例前后被调用。那为什么不用构造函数?因为构造函数不能安全地使用ASSERT_*,断言失败时它需要return,而构造函数没有返回值,会导致未定义行为。SetUp()返回void,可以随便用断言。所以规则是:初始化逻辑里要写断言,就放SetUp;纯赋值、纯构造,放构造函数甚至更省事。

第二个坑:TearDown里不要用ASSERT。因为TearDown执行时用例结果已经基本确定了,此时失败信息有时候会被吞掉,也不好定位。清理逻辑应该写成"尽力而为"的形式,出错就记一条日志,别指望断言能帮你定位到什么。

第三个坑:夹具不能复用同一个名字。每个夹具类对应一个测试套件,你用TEST_F(StackTest, Xxx)和TEST_F(StackTest, Yyy)没问题,但不能再定义第二个同名的夹具类。这个限制在大型项目里偶尔会造成困扰,解决办法是把夹具放到不同的命名空间里。

4.3 一个贴近实战的夹具例子

光看vector不过瘾,我拿一个真实的场景说:测试一个简单的账户类,每个用例都需要一个初始余额 1000 的账户。

class Account { public: explicit Account(double balance) : balance_(balance) {} bool Withdraw(double amount) { if (amount <= 0 || amount > balance_) return false; balance_ -= amount; return true; } double balance() const { return balance_; } private: double balance_; }; class AccountTest : public ::testing::Test { protected: void SetUp() override { account_ = std::make_unique<Account>(1000.0); } std::unique_ptr<Account> account_; }; TEST_F(AccountTest, WithdrawValidAmountSucceeds) { EXPECT_TRUE(account_->Withdraw(300.0)); EXPECT_DOUBLE_EQ(account_->balance(), 700.0); } TEST_F(AccountTest, WithdrawMoreThanBalanceFails) { EXPECT_FALSE(account_->Withdraw(1500.0)); EXPECT_DOUBLE_EQ(account_->balance(), 1000.0); } TEST_F(AccountTest, WithdrawZeroFails) { EXPECT_FALSE(account_->Withdraw(0.0)); EXPECT_DOUBLE_EQ(account_->balance(), 1000.0); }

注意第二个用例:取款失败之后我仍然验证余额没变。这是很多人会漏掉的一步。只验证返回值,测不到"失败路径有没有意外修改状态"这类 bug,而这类 bug 恰恰是最难在生产环境复现的。多写一行EXPECT_DOUBLE_EQ,成本几乎为零,收益却很大。


5. 进阶玩法:参数化、类型化与死亡测试

5.1 值参数化 TEST_P:一行代码测十组数据

当你需要对同一段逻辑用多组输入验证时,别复制粘贴十个用例,用参数化测试。核心是三个东西:继承::testing::TestWithParam<T>、用TEST_P写用例、用INSTANTIATE_TEST_SUITE_P注册参数集。

class IsPrimeTest : public ::testing::TestWithParam<std::tuple<int, bool>> {}; TEST_P(IsPrimeTest, MatchesExpected) { auto [input, expected] = GetParam(); EXPECT_EQ(IsPrime(input), expected) << "输入值为 " << input; } INSTANTIATE_TEST_SUITE_P( PrimeCases, IsPrimeTest, ::testing::Values( std::make_tuple(1, false), std::make_tuple(2, true), std::make_tuple(17, true), std::make_tuple(100, false), std::make_tuple(997, true) ) );

有几个细节值得展开。INSTANTIATE_TEST_SUITE_P的第一个参数是实例名前缀,它会拼在测试套件名前面,所以最终用例全名类似PrimeCases/IsPrimeTest.MatchesExpected/0,末尾的数字是参数索引。这个名字在 CI 报告里会显示,前缀起得好能一眼看出这组参数的用途。

版本兼容坑:1.10 之前的 gtest 用的是INSTANTIATE_TEST_CASE_P,1.10 之后改成TEST_SUITE系列。如果你拿着老代码在新版本上编译,会看到一堆废弃警告,甚至在开了-Werror的时候直接编译失败。改名字就行,语义完全一致。

还有一个我特别喜欢的用法:EXPECT_EQ(...) << "输入值为 " << input;。这个<<是给断言加自定义失败信息用的,参数化测试一旦失败,你不知道是第几组参数挂的,加上这行能直接定位。参数化测试不加自定义信息,等于给自己挖坑。

参数生成的工具函数也很全:Values(a, b, c)列具体值,Range(start, end)生成步长为 1 的区间,Range(start, end, step)带步长,Bool()生成true/false两个值,ValuesIn(container)从容器读。组合起来用Combine(g1, g2),可以生成笛卡尔积。

5.2 类型参数化 TYPED_TEST:一套逻辑测多种类型

如果你写的是模板代码,同一个算法要在int、float、double上跑,用TYPED_TEST最合适。它和值参数化的区别在于:类型参数化是把类型当参数,每一组参数会编译出一份独立的代码,因此可以做类型相关的事,比如声明该类型的变量。

template <typename T> class CalculatorTest : public ::testing::Test { protected: T value_ = static_cast<T>(2); }; using NumberTypes = ::testing::Types<int, long, float, double>; TYPED_TEST_SUITE(CalculatorTest, NumberTypes); TYPED_TEST(CalculatorTest, AdditionIsCommutative) { T a = this->value_; T b = static_cast<T>(3); EXPECT_EQ(a + b, b + a); }

在TYPED_TEST里访问夹具成员必须用this->,因为模板基类的成员名在派生类里不会自动可见,这是 C++ 模板的查找规则决定的,忘了写this->会报"未声明的标识符",新手常常被这个卡住。还有个小细节,如果你用的类型列表里有重复类型,TYPED_TEST_SUITE会报错,去重一下就好。

5.3 死亡测试:验证程序真的按预期崩了

有些代码的约定就是"遇到非法输入就主动终止",比如防御性编程里的assert、或者某些必须 fail-fast 的初始化路径。这类行为用普通断言测不了,得用死亡测试。

void CrashingFunction() { int* p = nullptr; *p = 1; // 故意空指针解引用 } TEST(DeathTest, NullDereferenceKillsProcess) { EXPECT_DEATH(CrashingFunction(), ""); }

死亡测试的原理是 fork 出一个子进程去执行,父进程观察子进程是否异常退出。这带来两个重要限制:第一,它比较慢,每次都要创建进程;第二,它和线程有兼容性问题,多线程环境下在子进程里执行代码很容易出诡异结果,所以 gtest 官方建议死亡测试尽量在单线程的用例里跑,或者干脆用testing::FLAGS_gtest_death_test_style = "threadsafe"切到线程安全模式,代价是更慢。

死亡的匹配串支持正则,但 gtest 用的是 POSIX 扩展正则,不是 ECMAScript 那套,写.*、[0-9]+都没问题。我一般会尽量匹配关键信息而不是写空串,这样能确认崩的原因对不对,而不是随便崩了就算过。


6. gMock 入门:把烦人的依赖隔离掉

6.1 为什么需要 Mock

测试一个订单服务,它要调支付网关、要写数据库、要发消息队列。真连这些依赖跑测试,问题一大堆:慢、不稳定、需要环境、失败原因难定位。而且你根本没法测"支付网关超时的时候订单服务会怎样"这种场景,除非你能控制网关的行为。

Mock 的思路是:定义一个假的接口实现,你想让它返回什么就返回什么,想让它被调用几次就调用几次,还可以断言它有没有被正确调用。这才叫真正的单元测试——只测你自己的逻辑,外部世界全部可控。

6.2 MOCK_METHOD 与 EXPECT_CALL

先定义一个接口:

class PaymentGateway { public: virtual ~PaymentGateway() = default; virtual bool Charge(const std::string& orderId, double amount) = 0; };

然后用 gMock 生成假实现:

#include <gmock/gmock.h> class MockPaymentGateway : public PaymentGateway { public: MOCK_METHOD(bool, Charge, (const std::string& orderId, double amount), (override)); };

MOCK_METHOD的参数依次是:返回类型、方法名、参数列表(用括号包一层)、修饰符。这里必须注意参数列表要再套一层括号,这是宏解析的需要,不写的话编译错误信息会比较晦涩。(override)是 C++11 的说明符,加上它能让编译器帮你检查签名是否匹配,强烈建议加。

测试里这样用:

TEST(OrderServiceTest, SuccessfulCharge) { MockPaymentGateway gateway; OrderService service(&gateway); EXPECT_CALL(gateway, Charge("order-001", 99.9)) .Times(1) .WillOnce(::testing::Return(true)); EXPECT_TRUE(service.Pay("order-001", 99.9)); }

EXPECT_CALL表达的是"我期望这个调用发生",如果测试跑完这个调用一次都没发生,用例直接失败。这个特性非常有用,它把"验证行为"和"验证结果"合到了一起。很多人只验证返回值,忘了验证"该调用的方法到底调了没有",结果代码里少调了一次支付,测试还是绿的——这种情况在重构中最容易出现。

6.3 匹配器与行为设置

参数不固定的时候,用匹配器:

匹配器含义
_任意值
Eq(v)/Ne(v)等于 / 不等于
Gt(v)/Ge(v)/Lt(v)/Le(v)大小比较
IsNull()/NotNull()指针判空
HasSubstr(s)字符串包含
StartsWith(s)/EndsWith(s)字符串前后缀
Ref(v)按引用匹配,用于输出参数

设置返回行为的常见写法:.WillOnce(Return(x))表示调用一次返回 x,多次调用就链多个WillOnce;.WillRepeatedly(Return(y))表示后续所有调用都返回 y;.Times(n)精确控制次数,.Times(AtLeast(n))/AtMost(n)/AnyNumber()是范围形式。

我给你一个实战里经常用到的组合技——模拟"前两次失败、第三次成功"的重试逻辑:

EXPECT_CALL(gateway, Charge(_, _)) .WillOnce(::testing::Return(false)) .WillOnce(::testing::Return(false)) .WillOnce(::testing::Return(true)); EXPECT_TRUE(service.PayWithRetry("order-002", 50.0, 3));

这个测试能一次性验证你的重试次数够不够、重试之后有没有正确处理成功路径,不用真的去等网络超时,几毫秒就跑完。

还有几个实用开关:StrictMock<T>会对任何没被EXPECT_CALL声明的调用直接报错,适合你想严格约束调用面的场景;NiceMock<T>反之,未声明的调用静默放行,适合你只关心少数几个方法的时候。默认的Mock处于中间态——未声明的调用会给个警告但不失败。我自己的偏好是默认 Mock 就够了,只在确保交互契约非常明确的时候才上 StrictMock,不然重构的时候改一个调用就要修一堆测试,得不偿失。

注意:Mock 对象必须在所有EXPECT_CALL预期都被验证完之后才能析构,否则 gmock 也会报错。所以不要把 Mock 对象定义在某个提前退出的作用域里。


7. 工程化落地:CMake 集成与日常怎么跑

7.1 CMakeLists 怎么写才算规范

一个能用的测试构建脚本大概长这样:

cmake_minimum_required(VERSION 3.16) project(my_project 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 ) set(gtest_force_shared_crt ON CACHE BOOL "" FORCE) FetchContent_MakeAvailable(googletest) enable_testing() add_executable(unit_tests test_account.cpp test_string_util.cpp ) target_link_libraries(unit_tests PRIVATE my_library GTest::gtest_main GTest::gmock ) include(GoogleTest) gtest_discover_tests(unit_tests)

这里面的重点是用目标名而不是文件路径来链接,也就是GTest::gtest_main而不是-L/xxx/libgtest_main.a。现代 CMake 的目标会自动带上头文件路径、编译选项和传递依赖,写起来干净,换平台也不容易出错。gtest_discover_tests是关键的一步,它在构建时运行一次测试程序、把里面所有用例的名字抓出来,注册成 CTest 能识别的独立测试项。这样ctest就能单独跑某一个用例,CI 上也能显示具体哪个用例失败了,而不是笼统的"测试程序返回非零"。

7.2 运行期的那些命令行开关

gtest 自带一批命令行参数,你可能从来没用过,但都挺实用:

参数作用
--gtest_list_tests只列出所有用例,不执行
--gtest_filter=Foo.*:*.Bar只跑匹配的用例,-是排除
--gtest_repeat=10重复跑十遍,查不稳定用例
--gtest_shuffle打乱执行顺序
--gtest_break_on_failure失败时触发断点,方便挂调试器
--gtest_output=xml:report.xml输出 XML 报告,给 CI 用
--gtest_also_run_disabled_tests连DISABLED_前缀的用例也跑

--gtest_filter是我日常用得最多的。定位问题的时候,全量跑一次要几分钟,加上过滤就只跑相关的几个,秒级出结果。语法是套件名.用例名,*通配,冒号分隔多个模式,前面加-表示排除。比如--gtest_filter=AccountTest.*-AccountTest.WithdrawZeroFails就是"跑 AccountTest 下除了 WithdrawZeroFails 之外的全部用例"。

--gtest_shuffle配合--gtest_repeat是个排查隐藏依赖的神器。你写测试的时候可能不小心让用例 B 依赖了用例 A 留下的全局状态,正常顺序跑永远绿,一打乱就红。我第一次遇到这个问题的时候查了整整一个下午,从此养成习惯:新模块的测试写完,先跑一次--gtest_shuffle --gtest_repeat=5,能提前暴露不少隐患。

--gtest_output=xml:输出的是 JUnit 风格的 XML,几乎所有主流 CI 工具都能直接解析,用来在流水线页面上展示测试通过率和失败详情。这个格式别自己拼,用框架自带的。

7.3 让测试在 CI 上有意义

集成了 CMake 之后,CI 上的命令就是构建加ctest --output-on-failure。--output-on-failure这个参数建议永远加上,否则失败的用例只能看到"1 test failed",看不到具体断言信息,等于白跑。

另外有个容易忽略的点:测试失败必须让流水线失败。有些同学为了"让流水线看起来是绿的",把ctest的返回码忽略了,比如写成ctest || true。这等于把单元测试变成了装饰品,比不写还糟糕,因为它会让人误以为有保护。真想让某几个已知失败的用例暂时不阻塞,正确做法是加上DISABLED_前缀并附上 issue 链接说明,而不是全局吞掉失败。


8. 常见问题与排查实录

8.1 链接类错误

报错原因解决
undefined reference to testing::InitGoogleTest没链接 gtest 库加GTest::gtest
undefined reference to main没链gtest_main也没自己写 main加GTest::gtest_main或补 main
undefined reference to testing::internal::...链接器和编译器标准不一致统一CMAKE_CXX_STANDARD
cannot find -lgtest库路径没告诉链接器用 CMake 目标名,别手写-l

链接错误的核心排查思路是:看未定义的符号属于哪个库。testing::开头的肯定是 gtest,testing::internal::开头的有时是因为编译 gtest 和你自己代码的 C++ 标准不同导致符号名不匹配,这个坑比较隐蔽,遇到"明明链接了库还说找不到符号"的时候,先检查两边标准是否一致。

8.2 运行期的诡异现象

段错误但没输出任何失败信息。这种情况多半是测试代码本身崩了,不是断言失败。典型原因有:访问了悬垂指针、容器越界、Mock 对象在EXPECT_CALL还没验证完就被销毁了。排查手段是先用gdb跑起来,bt看栈。我提一个更省事的办法:在 gtest 里加--gtest_catch_exceptions=0,让异常和崩溃直接暴露出来,别被框架兜住。虽然默认是 0,但有些环境变量会改掉它,显式写一下更稳。

测试通过但逻辑明显不对。八成是断言用错了。最常见的两个:const char*用EXPECT_EQ比了地址,浮点数直接EXPECT_EQ。还有一种更隐蔽的——EXPECT_EQ(a, b)里两个数的类型不同,发生了隐式转换,比如int和unsigned比较,负号会变成巨大的正数。这类问题排查起来特别费劲,解决办法是把类型对齐,或者用EXPECT_EQ(static_cast<long long>(a), static_cast<long long>(b))显式统一。

用例顺序一变就挂。前面说过,用--gtest_shuffle一打乱你就知道了。根本原因通常是全局单例、静态变量、文件系统残留、网络端口占用。解决方向是让每个用例都在自己的夹具里创建所需状态,退出时清理干净。真有共享的昂贵资源,用SetUpTestSuite管好生命周期,别让它跨套件泄漏。

8.3 一套我自己常用的排查流程

遇到测试问题,我基本按下面这个顺序走,通常两三步就能定位:

  1. 用--gtest_filter只跑失败的那个用例,排除干扰
  2. 看断言信息,确认是"期望值错了"还是"实际值错了"——前者是测试写错,后者是代码有问题,别搞反了
  3. 如果用例崩溃,用--gtest_break_on_failure加gdb挂上去看栈
  4. 如果断言全过但结果不对,检查有没有用错断言的类型(指针、浮点、跨类型比较)
  5. 单跑没问题、全跑有问题,上--gtest_shuffle,查状态污染

实操心得:给每个断言加<< 自定义信息这个习惯,在只有几十个测试的时候显得啰嗦,等测试涨到几百个的时候就是救命稻草。我从一开始嫌麻烦,到后来变成强迫症,就是因为被"某个断言失败了但完全看不出是哪组数据"折磨过好几次。

最后再分享一个小经验。我写测试的顺序通常是先写一个一定能通过的用例,把框架跑通了,再去写会失败的边界用例。这样编译错误、链接错误、环境问题都在第一步暴露干净,后面专心写逻辑就好。反过来先写复杂的边界用例,一旦报错你分不清是环境问题还是逻辑问题,排查成本翻倍。这个习惯我用了好几年,每次带新人都推荐他们照做,反馈都不错。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询