写代码的人没有谁愿意承认,但心里都清楚一件事:改一行"看着人畜无害"的代码,很可能把三个月前的某个功能给悄悄弄坏。C 语言那个年代,人们用 "It compiles" 来安慰自己——能编译过去就算交差。可编译通过只代表语法没毛病,不代表行为还对。于是有了测试,更进一步,有了自动化测试:让机器替我们把"这个功能现在还正常吗"这个问题,一遍又一遍地、无怨无悔地问下去。
这篇文章我们专讲 C++ 方向。我会以一套真正能编译、能运行、能出报告的最小工程为骨架,带你走完"选型 → 搭工程 → 写被测代码 → 写测试用例 → 编译运行 → 覆盖率 → 接进 CI"的完整闭环。全程代码都不带省略号,你照着敲就能跑。废话不多说,我们从"为什么要这么做"讲起。
为什么 C++ 项目也要写自动化测试
先花一点时间,把"自动化测试能替我们挡下什么"这件最根本的事聊透,否则后面所有工具都只是"为了用而用"。
自动化测试,说白了就是"用代码去验收代码"。你先凭直觉(或者产品需求)定下一套"应该怎样"的预期,然后用断言把它写死进一个可执行文件里,由机器去检查被测代码的实际行为是否和预期一致。你每次改动代码后跑一遍它,就等于把整套预期重新核对了一遍。
它对 C++ 尤其重要,原因有三:
- C++ 是"改一处、崩一片"的重灾区。 指针、引用、内存、异常这些特性,牵一发而动全身。你重构某个深埋的函数签名,编译或许能过,但所有调用它的地方行为可能都变了。测试是把连锁反应提前暴露出来的最廉价手段。
- 回归风险高。 所谓回归(regression),指"原本正常的功能,在后续改动中又被搞坏了"。没有自动化测试时,回归只能靠人肉回归——把主要功能手动点一遍,枯燥、易漏、还占着人的时间。自动化测试把这份"苦力"交给了机器。
- C++ 被用在犯错的代价极高的地方。 底层库、音视频、嵌入式、游戏引擎、量化交易,一旦出错,不是报错退出了事,而是可能让整个系统崩溃或算错账。能早一步在测试里抓住的 bug,绝不留在线上爆雷。
这里有个经常出现的概念分层要辨析清楚:单元测试、集成测试、端到端(E2E)测试是不同粒度的测试。
- 单元测试:只测"一个函数 / 一个很小模块"的单一行为,不碰外部依赖(文件、网络、数据库)。它跑得最快、定位最准,是本文的主角。
- 集成测试:验证"多个模块拼起来"是否正确,比如你的登录模块和加密模块配合起来对不对。
- 端到端测试:从用户入口一路测到底,模拟真实操作整个系统(比如之前博客教程里用 Selenium 点网页、填表单、点按钮的那套)。它最贴近真实,但也最慢、最脆,所以数量通常最少。
这三者的数量通常呈"金字塔"形状:底层单元测试最多,往上越来越少。因为越底层越稳定、越快、越便宜。有人会用一句顺口溜概括这种偏好——"单元测试多,端到端测试少,划算"。
好,还没开始写一行测试代码,先记住四个关键词:自动化测试让我们摆脱人肉;回归是它要防的主要敌人;单元测试是最优先要做的、跑得最快的那个层级;回归测试专门用来防止历史功能被改坏。至于"套件、用例、Fixture"这些更细的术语,等真正用 GoogleTest 写起来的时候我再逐个拆。
思考题:为什么"能编译"不等于"行为正确"?
给一个具体例子帮你想:下面这段代码能编译、能运行,但它真的"正确"吗?
// 一个"看起来没问题"的求平均值函数
double average(int arr[], int n) {
double sum = 0;
for (int i = 0; i < n; ++i) {
sum += arr[i];
}
return sum / n;
}答案与详解(点开)
单看这一段,"能编译、不崩溃"是成立的,但"行为正确"可不一定。真正的正确性由"约定"决定,而约定往往不在代码里:
- 入参边界没守住:如果调用方传
n = 0,代码里sum / n会触发"除数为 0",在整数里是崩溃(UB,未定义行为),在浮点里可能得到inf/nan。这属于"能跑但不正确"的典型。 - 为什么不写测试就看不出问题:因为编译器根本不检查"数学上合不合理",它只检查"语法合不合法"。想让这类问题被抓住,必须有人(或测试)把"空数组、单元素、负数元素、元素和溢出"这些边界场景的预期值写出来,然后用断言逐一核对。
- 结论:编译通过是"代码能跑"的必要条件,测试通过才是"行为符合预期"的证据。"能编译"顶多算 60 分,剩下 40 分要靠测试去挣。
这正是本文后面所有内容的起点:测试不是装点门面,而是把"约定"变成"机器可复核的客观标准"。
测试框架选型:GoogleTest、Catch2 还是自己造轮子
确定要做自动化测试后,第一件让人挠头的事就是选框架。C++ 世界的主流选择其实就三类,我用"搬运工"的视角给你把它们排开聊。
GoogleTest(gtest/gmock):工业界的默认答案
由 Google 主导开发,是目前生态最完整、用的人最多的 C++ 测试框架,绝大多数公司项目、开源库都在用。它的强项是:
- 断言极其丰富(值、字符串、浮点、异常、谓词、显式失败),还有一整套搭配好的"死亡测试""参数化测试""Fixture";
- 自带 mock 库 gmock,测试一类需要"伪造依赖"的代码时几乎人手必备;
- 输出格式、失败信息、XML 报告都做得非常规整,和 CI、覆盖率工具配合得天衣无缝。
代价是:它不是一个"塞一个头文件就能用"的东西,需要依赖 CMake/构建系统把它编译并链接进来(通常是 FetchContent 拉源码或系统包安装)。也就是说,选它,就等于你接受了一套稍微重一点、但非常标准化的工程习惯。
Catch2:轻量、读写自然,适合小而美的工程
Catch2 的口碑核心在"写起来像在读英文句子、而不像在写一堆宏"——它的用例名直接就是一段字符串描述,断言就是普通的 REQUIRE(...) / CHECK(...) 布尔表达式。这让测试代码的可读性极好,尤其适合小工具库、算法练习、入门教学。
版本上有个你必须知道的坑:Catch2 v2 是"单头文件"时代,你只需把 catch.hpp(或 v3 的分发文件)扔进工程、#define CATCH_CONFIG_MAIN 一下就有了 main;而 v3 起它被拆成了多个头,并改成了"编译成静态库后链接"的模型(CMake 目标为 Catch2::Catch2WithMain)——换来的是显著更快的编译时间,代价是集成方式变了。所以说"单头文件即插即用"这句话,只在 v2 成立,在 v3 不成立。我这里在不确定的版本坑上,按官方迁移文档的说明给出了正确姿势:v3 用 FetchContent + 链接 Catch2::Catch2WithMain,v2 才支持 #define CATCH_CONFIG_MAIN + 单头文件。
自制简易框架:真·零依赖,但只适合"很轻"的场景
不想引入任何第三方依赖,你完全可以自己写一个极简框架:一个断言宏 + 一个收集结果、报告失败并返回非 0 的 main 就够用了。它的优势是"零安装、零网络、零学习成本",适合教学演示和极小的内部工具;弱点也很明显——没有 Fixture、没有参数化、没有漂亮的报告和过滤器,规模一大就裸奔。
到底怎么选:一张对比表给你拍板
| 维度 | GoogleTest | Catch2 | 自制 |
|---|---|---|---|
| 集成成本 | 高(要链接,需 CMake) | 中(v3 要链接,v2 单头即可) | 最低(零依赖) |
| 用例写法 | 宏 + 套件/用例名 | 字符串描述 + 断言表达式 | 自由,全看你 |
| 断言与匹配器 | 丰富 | 丰富 | 基础 |
| Mock 能力 | 内置 gmock | 需第三方 | 无 |
| Fixture/参数化 | 完善 | 完善 | 一般要自己造 |
| 报告与 CI 集成 | 极好 | 好 | 要自己写 |
| 适合场景 | 中大型正式工程 | 中小型、可读性优先 | 教学、极小工具 |
我的建议很直接:如果你在做正式一点的工程,别犹豫,上 GoogleTest;图省事、写起来舒服、项目不大,选 Catch2;明确想"零依赖零配置跑起来先看看",才轮到自制。你要是只跟本文走一遍闭环,我们就用 GoogleTest 当主线,因为它最接近你将来在生产里遇到的东西。Catch2 和自制的写法我也会捎带给你看,做到"见识过、能读懂"。
思考题:下面这个场景该选哪个框架?
一个小公司要给内部命令行小工具(几千行,无网络依赖,团队三人)加测试,既不想折腾构建系统,又希望测试代码读起来像自然语言。选 GoogleTest、Catch2 还是自制?
答案与详解(点开)
首选 Catch2(尤其 v2 或 v3 配 FetchContent 都行)。 理由层层递进:
- 需求对上了 Catch2 的强项:工具小、无外部依赖,正好用得上 Catch2 省铠甲一般的集成方式(v2 一个头文件即插即用);团队要"可读性好",恰是 Catch2"用例名即英文句子"的看家本领。
- 自制也可作备选,但有上限:几千行工具靠自制框架也能测,可一旦用例上百,没有 Fixture/参数化/失败定位,维护会很难受。自制更适合"几百行的教学 demo",到这个规模就该上正式框架了。
- GoogleTest 偏重,不是不能但不划算:它功能确实更强,但对一个"无网络小工具"来说,大部分能力用不上,还白白背上了更重的构建集负担。选型的原则永远是"匹配规模与团队",而不是"越强越好"——这也是为什么本文把三种都摊开,而不是只推一个。
组装一个最小可运行的 C++ 测试工程
选完型,直接进入正题。这一节我们搭一个"麻雀虽小、五脏俱全"的工程,它由下面这些文件和目录构成:
testing-demo/
├── CMakeLists.txt # 顶层构建脚本:决定编译什么、怎么接入测试
├── src/ # 被测源码
│ ├── CMakeLists.txt # (本文不拆分,源文件直接挂到顶层)
│ ├── calculator.h # 数学工具库:接口声明
│ ├── calculator.cpp # 数学工具库:实现
│ ├── auth.h # 登录校验模块:接口声明
│ └── auth.cpp # 登录校验模块:实现
└── tests/ # 测试源码
├── CMakeLists.txt # 定义"测试可执行文件",并登记进 CTest
├── calculator_test.cpp # 数学模块的测试
└── auth_test.cpp # 登录模块的测试(含 Fixture 和参数化)
工程结构先记住一个心法:被测代码和测试代码分开目录放。src/ 放产品代码,tests/ 放测试代码。这样将来算覆盖率时可以干净地"只统计 src、剔除 tests",也不会把测试狠狠地塞进正常的构建产物里。
顶层构建脚本 CMakeLists.txt
先写根目录的构建脚本。它负责三件事:声明项目、把被测代码编成一个静态库、拉取并接入 GoogleTest。
# CMakeLists.txt —— 顶层构建脚本,全部用注释解释每行在干什么
cmake_minimum_required(VERSION 3.16) # 限定 CMake 最低版本(FetchContent 至少需 3.11)
project(MyTestProject LANGUAGES CXX) # 声明项目名,并告诉 CMake 这是纯 C++ 项目
set(CMAKE_CXX_STANDARD 17) # 统一用 C++17 标准编译
set(CMAKE_CXX_STANDARD_REQUIRED ON) # 编译器达不到 C++17 就直接报错,不偷偷回退
# 把 src 下两个源文件打成一个静态库,目标名叫 utils
add_library(utils STATIC
src/calculator.cpp
src/auth.cpp
)
# 声明这个库对外公开 src 目录作为头文件搜索路径,
# 后续链接 utils 的测试代码因此能直接 #include "calculator.h"
target_include_directories(utils PUBLIC src)
# 以下三行负责"拉取 GoogleTest 源码并作为子项目一起构建"
include(FetchContent) # 开启"获取第三方 content"的能力
FetchContent_Declare(
googletest # 这段获取逻辑的名字
GIT_REPOSITORY https://github.com/google/googletest.git
GIT_TAG v1.14.0 # 锁定一个稳定版本标签,保证可复现
)
FetchContent_MakeAvailable(googletest) # 真正下载并把它加入构建
enable_testing() # 开启 CTest 框架支持,add_test() 之后才生效
add_subdirectory(tests) # 进入 tests 目录继续配置,那里定义测试可执行文件有两个细节值得现在就点破,否则你会卡住:
- 关于版本标签:我锁的是
v1.14.0。这是一个需要 C++14 及以上的长期稳定版,配合我们 C++17 的工程毫无压力,而且标签定死能保证"今天下载、明年下载编译结果一致"。GoogleTest 更新很快,目前已有更新的 v1.17 / v1.18 等(其中 v1.17.0 起要求至少 C++17)。生产里你可以按需把GIT_TAG抬高,教学用锁定我这里这个即可。 - 关于网络:
FetchContent_MakeAvailable(googletest)会在第一次配置时从 GitHub 克隆一份 googletest 源码到本机。如果你在的网络访问 GitHub 很吃力,这个步骤会卡很久甚至失败。届时可以改用国内镜像仓库(比如把GIT_REPOSITORY换成 googletest 的 Gitee 镜像),或者干脆用系统包管理器装好 gtest 再换成find_package(GTest REQUIRED)。这是"构建依赖"这一坑的一个预告,后面专门讲。
tests 目录的构建脚本
src/ 下面我不拆 CMakeLists.txt(源文件少,直接挂在顶层即可),但 tests/ 里必须有一个,因为它要定义"测试可执行文件"并登记进 CTest。
# tests/CMakeLists.txt —— 专门定义测试可执行文件并接入 CTest
# 一个"测试可执行文件"对应一个 .cpp,里面可以放任意多个用例
# 分别编译两个测试程序
add_executable(calculator_test calculator_test.cpp)
target_link_libraries(calculator_test PRIVATE
utils # 被测库
gtest_main # GoogleTest:提供 int main() 的部分,链接后有程序入口
)
add_executable(auth_test auth_test.cpp)
target_link_libraries(auth_test PRIVATE utils gtest_main)
# 把两个测试程序登记进 CTest,之后一条 ctest 命令就能全量跑
add_test(NAME calculator_test COMMAND calculator_test)
add_test(NAME auth_test COMMAND auth_test)这里最要紧的一行是 gtest_main:GoogleTest 贴心地拆成了 gtest(框架本体)和 gtest_main(替你写好 main,它会调用 RUN_ALL_TESTS() 并返回 0/1)。你链接 gtest_main 就不用自己写 main 了。如果漏了它、或者链接成 gtest 而不是 gtest_main,会迎来一堆"找不到 main"的链接错误——这就是"构建依赖"挖的又一个坑,记住了。
此时工程骨架就位。但你也注意到了:calculator_test.cpp、auth_test.cpp 这些测试文件还没写,被测函数也还没定义。顺序上我建议"先把被测代码写得可测,再写测试",所以下一节先看被测代码长什么样。
被测代码:先写出天生能被测试的函数
很多人写测试写到一半发现"这鬼东西没法测"——因为被测代码烂成一团:函数里塞着文件读写、网络请求、全局状态,测试根本给不出一个稳定的输入。所以能否被测试,往往是设计出来的,而不是测试时补救的。
我们的被测代码刻意选了两个纯粹、好测的小模块:一个数学工具库,一个登录校验模块。它们的共同点是:输入输出一目了然、不依赖外部世界、行为完全可重复。这就是"可测性"(testability)的样板。
数学工具库:calculator.h
// src/calculator.h —— 工具函数库的接口声明
#ifndef CALCULATOR_H // 头文件防重复包含守卫:#ifndef 与 #endif 成对出现
#define CALCULATOR_H // 第一次进来就定义这个宏,防止同一头文件被 #include 多次
// 一段全都依赖纯参数、不碰外部资源的函数声明
namespace calc { // 放进 calc 命名空间,避免和别处撞名
// 两个整数相加,返回和
int add(int a, int b);
// 判断一个整数是否为偶数
bool is_even(int n);
// 求 n 的阶乘;n 为负数时按约定返回 -1,表示"非法输入"
long long factorial(int n);
// 除法;除数为 0 时抛出 std::invalid_argument 异常
double divide(double a, double b);
} // namespace calc
#endif头文件守卫(include guard)到底在防什么?
上面这段 #ifndef ... #define ... #endif 合起来叫头文件守卫(include guard),它的作用是抬一道闸:第一次被 #include 时,CALCULATOR_H 还没被定义,于是进入代码、顺手把这个宏定义掉;之后若又有别的编译单元再次 #include 同一个头文件,#ifndef 一检查发现宏已存在,整段函数体就直接跳过——从而防止"同一个头文件的内容被复制多份"而引发重复定义的报错。
最要命的一个坑是宏拼写不一致:如果 #ifndef 检查的是 CALCULATOR_H,而 #define 里却打错拼成别的(比如 CALCULARATOR_H),守卫就被架空了,同一个头文件被多文件包含时照样报重复定义。这类错误很难肉眼发现,几乎全靠编译报错替你揪出来。所以规则就一条:#ifndef X 与 #define X 里的 X 必须一字不差。上面这段代码是正确示范,直接把这份头文件原样存成 src/calculator.h 即可。
数学工具库:calculator.cpp
// src/calculator.cpp —— 工具函数库的实现
#include "calculator.h"
#include <stdexcept> // 抛异常需要用 std::invalid_argument
namespace calc {
int add(int a, int b) {
return a + b; // 最简单的加法,行为一眼可预期,是测试的"马前卒"
}
bool is_even(int n) {
// 能被 2 整除即为偶数。可能有同学担心负数取模在 C++ 里结果为负,
// 但判断"是否等于 0"完全不受正负号影响,所以负偶数也成立
return n % 2 == 0;
}
long long factorial(int n) {
if (n < 0) {
return -1; // 负数阶乘在数学上无定义,按我们的约定返回 -1 表达"出错"
}
long long result = 1;
// 从 2 一路乘到 n;当 n 为 0 或 1 时循环一次都不跑,直接返回 1(数学约定)
for (int i = 2; i <= n; ++i) {
result *= i;
}
return result;
}
double divide(double a, double b) {
if (b == 0.0) {
// 除数为 0:抛标准异常,让上层决定怎么兜底,而不是悄悄返回错误值
throw std::invalid_argument("除数不能为 0");
}
return a / b;
}
} // namespace calc可以看到,这几个函数没有任何文件、网络、全局依赖,给定同一组入参,永远得到同一个输出。单元测试最喜欢这种"纯函数",因为它让"预期值可以手写死"。
登录校验模块:auth.h 与 auth.cpp
再准备一个"更像业务"的模块。它模拟账号校验:给一个用户名和密码,返回 Success / WrongPassword / UserNotFound / InvalidInput 四种结果之一。放在这里是为了演示——被测的绝不只有数学函数,业务逻辑同样要测。
// src/auth.h —— 登录校验模块接口声明
#ifndef AUTH_H
#define AUTH_H
#include <iosfwd> // 只声明 std::ostream 的前向声明,供下面的输出符用,不需要完整定义
#include <string> // 用户名和密码用 std::string 表示
// 登录结果:一个枚举把"这几种情况"一次性说清楚
enum class LoginResult {
InvalidInput, // 输入非法(用户名或密码为空)
Success, // 登录成功
WrongPassword, // 用户名存在但密码错误
UserNotFound // 用户名不存在
};
// 给 LoginResult 声明一个流输出符:GoogleTest 在断言失败打印枚举值时需要它。
// 声明放在头文件,定义放在源文件,这样测试文件 #include 后也能看到它。
std::ostream& operator<<(std::ostream& os, LoginResult r);
// 登录校验主函数:模拟"查账号 + 对密码"的核心业务逻辑
LoginResult login(const std::string& username, const std::string& password);
#endif// src/auth.cpp —— 登录校验模块实现
#include "auth.h"
#include <ostream> // 流输出符的完整实现需要 <ostream>
// 放在匿名命名空间里的"账号表":真实项目通常是数据库或下游服务,
// 这里用静态表让测试完全离线、可重复、足够快。
namespace {
// 一个数组,每条记录是 { 用户名, 密码 }
const struct {
const char* name; // 用户名
const char* pass; // 密码
} kAccounts[] = {
{"admin", "admin123"},
{"coder", "123456"},
};
} // namespace
// 实现 LoginResult 的流输出:断言失败时能把枚举打印成人话,方便排查
std::ostream& operator<<(std::ostream& os, LoginResult r) {
switch (r) {
case LoginResult::InvalidInput: return os << "InvalidInput";
case LoginResult::Success: return os << "Success";
case LoginResult::WrongPassword: return os << "WrongPassword";
case LoginResult::UserNotFound: return os << "UserNotFound";
}
return os << "Unknown"; // 防御性兜底:正常不会走到
}
LoginResult login(const std::string& username, const std::string& password) {
// 空输入:用户名或密码任意一个为空,都算非法请求
if (username.empty() || password.empty()) {
return LoginResult::InvalidInput;
}
// 先按用户名在账号表里找
for (const auto& acc : kAccounts) {
if (username == acc.name) {
// 找到了:比对密码,对就成功,不对就密码错误
return (password == acc.pass) ? LoginResult::Success
: LoginResult::WrongPassword;
}
}
// 账号表里根本没有这个用户名
return LoginResult::UserNotFound;
}顺带解释一个你可能第一次见的写法:
enum class LoginResult是 C++11 的"有作用域枚举",它的枚举值(Success等)不会泄漏到外层,访问时必须写LoginResult::Success。它和普通enum相比更安全,是我们这路工程里的约定。
现在骨架、被测代码都齐了,终于可以开始写测试了。下一节起,GoogleTest 的大门正式打开。
断言:EXPECT 与 ASSERT 的门道
先回答一个大问题:一段测试代码的"最小单位"是什么? 是用例(test case)。所谓用例,就是"一个完整的、可独立判断成败的验证场景",在 GoogleTest 里用一个 TEST(...) 来表达。若干个相关用例凑在一起,就叫一个套件(test suite),用来给用例分组、方便分类浏览和过滤。
一个用例的灵魂,是那一句句断言——用宏写出的"此处必须如何"的检查。GoogleTest 的断言分成两大派:EXPECT_* 和 ASSERT_*。它们的区别只有一个,但极其关键:
EXPECT_*失败不致命:本次断言记下一笔失败,但当前用例继续往后执行。适合"我想一下看能查出多少个问题"的场景。ASSERT_*失败即中断:一旦失败,该用例立刻中止(框架会去跑下一个用例)。适合"这一步崩了后面就没意义"的场景,比如参数都断言错了、再往下断言纯属浪费。
这就是英文原词的含义:EXPECT(期望)、ASSERT(断言/声明)。用错它俩的典型后果——你拿 ASSERT 去查一大堆互相独立的小点,结果第一个点挂了后面全被跳过,日志看得一头雾水;反过来拿 EXPECT 做"前置条件"的检查,条件不满足后面继续跑,连锁崩一片。经验法则是:前置条件用 ASSERT_*,然后的每一项独立结果用 EXPECT_*。
我们直接写第一份测试 calculator_test.cpp,把数学库测起来。这份文件把"常用断言家族"基本都覆盖到了:
// tests/calculator_test.cpp —— 数学工具库的测试
#include <gtest/gtest.h> // GoogleTest 的全部宏定义
#include <stdexcept> // 断言"抛异常"那一组宏要引用异常类型
#include "calculator.h" // 被测函数的声明(utils 库传递给我们头文件路径)
// 第一行注释回顾:TEST(套件名, 用例名)。套件分组、用例单个验证。
TEST(CalcAdd, HandlesPositive) {
EXPECT_EQ(calc::add(1, 2), 3); // EXPECT_EQ:期望左边等于右边
EXPECT_EQ(calc::add(100, 200), 300); // 再来一个正数场景
}
TEST(CalcAdd, HandlesNegative) {
EXPECT_EQ(calc::add(-1, -2), -3); // 负数相加
EXPECT_EQ(calc::add(10, -3), 7); // 一正一负
}
TEST(CalcEven, Basic) {
EXPECT_TRUE(calc::is_even(4)); // EXPECT_TRUE:期望表达式为真
EXPECT_TRUE(calc::is_even(0)); // 0 是偶数
EXPECT_FALSE(calc::is_even(7)); // EXPECT_FALSE:期望表达式为假
}
TEST(CalcFactorial, Normal) {
EXPECT_EQ(calc::factorial(0), 1); // 数学约定:0! = 1
EXPECT_EQ(calc::factorial(5), 120); // 5! = 120
}
TEST(CalcFactorial, InvalidNegative) {
// 负数阶乘:按源码约定,应返回 -1 表示"出错了"
EXPECT_EQ(calc::factorial(-3), -1);
}
TEST(CalcDivide, Normal) {
// 浮点数比较不要用 EXPECT_EQ:二进制浮点舍入会让"看起来相等"的两个
// double 不严格相等。EXPECT_DOUBLE_EQ 按"位级相等"判断,更适合精确场景。
EXPECT_DOUBLE_EQ(calc::divide(10.0, 2.0), 5.0);
}
TEST(CalcDivide, ThrowsOnDivideByZero) {
// 断言"这里必须抛出异常":除数传 0,应抛出 invalid_argument
EXPECT_THROW(calc::divide(1.0, 0.0), std::invalid_argument);
}这一份小文件,就把 GoogleTest 最常用的断言家族点全了:
EXPECT_EQ(a, b)、EXPECT_NE(不等)、EXPECT_LT/Gt/LE/GE(大小比较);EXPECT_TRUE/EXPECT_FALSE(真/假);EXPECT_THROW(语句, 异常类型)(必须抛、抛得对)、还有EXPECT_ANY_THROW(抛了就行)和EXPECT_NO_THROW(必须不抛);EXPECT_DOUBLE_EQ(浮点位级相等)和EXPECT_NEAR(a, b, 误差)(在误差范围内接近);- 字符串断言
EXPECT_STREQ(C 字符串相等)、EXPECT_EQ(std::string, std::string)(std::string直接值相等)。
把这些 EXPECT_* 换成 ASSERT_*(如 ASSERT_EQ),就得到"失败即中断"版本,其余行为不变。
关于浮点比较,值得把话说明白:为什么 EXPECT_EQ(0.1 + 0.2, 0.3) 很可能是反例?因为 0.1、0.2、0.3 在二进制里都不是有限小数,0.1 + 0.2 算出的是一个四舍五入后的近似值,和字面量 0.3 往往不严格相等。所以凡是浮点,要么用 EXPECT_NEAR 给误差,要么用 EXPECT_DOUBLE_EQ 做位级一致。裸用 EXPECT_EQ 比浮点数,是新手最容易写出的"看似在测、实则必挂"的断言。
思考题:下面这条断言,为什么"测了个寂寞"?
double r1 = 0.1 + 0.2;
double r2 = 0.3;
EXPECT_EQ(r1, r2);答案与详解(点开)
在绝大多数平台上,这条断言会直接失败——但它失败的原因不是"代码有 bug",而是"拿相等的语义在测不等的数"。
- 根因:
double是二进制浮点,0.1和0.2在二进制里都是无限循环小数,只能存近似值。0.1 + 0.2经过一次舍入,得到的值和直接写0.3的字面量严格比较时经常不相等。这属于"浮点表示误差",跟程序逻辑对错无关。 - 它误导人:如果这个断言挂在这里,你第一反应是去改
add的实现,但改了也白改——错根本不在加法,而在"选错了比较方式"。这正是"测试失效"里最坑的一种:断言自己不靠谱,把代码带沟里。 - 正解:数值精度无所谓的场景改用
EXPECT_NEAR(r1, r2, 1e-9)(差在误差范围内就过);严谨的精确场景用EXPECT_DOUBLE_EQ。教训一句话:浮点永远别裸比相等。
用例组织:套件、参数化和 Fixture
断言会写了,但用例一多就会冒出两个问题:同一种逻辑要测十组数据怎么办?每个用例都需要"开一些公共前置环境"怎么办?这一节就是 GoogleTest 的高阶组织手段,也是你将来在别人代码里最常看到的三板斧。
参数化:同一套验证逻辑,喂十组数据
上面 Login 模块的 Success / WrongPassword / UserNotFound / InvalidInput 走的是同一套 EXPECT_EQ(login(u, p), 期望) 流程。与其复制粘贴四份,不如用参数化测试:把"输入 + 期望"装进一个结构体,用 INSTANTIATE_TEST_SUITE_P 一组组灌进去,让框架替你逐个跑。我们把它写成 auth_test.cpp:
// tests/auth_test.cpp —— 登录模块测试:覆盖普通用例、参数化、Fixture
#include <gtest/gtest.h>
#include <ostream>
#include <string>
#include "auth.h"
// ---------- 一、普通用例:一个 TEST 一个场景 ----------
TEST(Login, Success) {
// 账号表里有 admin/admin123,正确的账密应成功
EXPECT_EQ(login("admin", "admin123"), LoginResult::Success);
}
TEST(Login, WrongPassword) {
// 用户名对、密码错,应返回 WrongPassword
EXPECT_EQ(login("admin", "wrong"), LoginResult::WrongPassword);
}
TEST(Login, UserNotFound) {
// 用户名不存在,应返回 UserNotFound
EXPECT_EQ(login("nobody", "x"), LoginResult::UserNotFound);
}
TEST(Login, EmptyInput) {
// 空用户名、空密码都属于非法输入
EXPECT_EQ(login("", "x"), LoginResult::InvalidInput);
EXPECT_EQ(login("admin", ""), LoginResult::InvalidInput);
}
// ---------- 二、参数化用例:同一套逻辑,多组数据分别验证 ----------
// 定义一条"输入 + 期望"结构体,作为一组参数
struct LoginParam {
std::string user; // 用户名
std::string pass; // 密码
LoginResult expected; // 期望的登录结果
};
// 给结构体配一个流输出符:断言失败时能打印关键字段,方便定位是哪组数据挂的
std::ostream& operator<<(std::ostream& os, const LoginParam& p) {
return os << "user=" << p.user;
}
// 声明一个参数化测试套件:它继承 TestWithParam<LoginParam>
class LoginParamTest : public ::testing::TestWithParam<LoginParam> {};
// 用 INSTANTIATE_TEST_SUITE_P 注入一组数据:每条都会跑一遍下面的 TEST_P
INSTANTIATE_TEST_SUITE_P(
LoginCases, // 命名前缀,用来给"展开出的每个用例"一起加这一段前缀
LoginParamTest, // 上面那个套件类
::testing::Values( // 列出要投喂的多组参数
LoginParam{"admin", "admin123", LoginResult::Success},
LoginParam{"coder", "123456", LoginResult::Success},
LoginParam{"admin", "wrong", LoginResult::WrongPassword},
LoginParam{"nobody", "x", LoginResult::UserNotFound},
LoginParam{"", "x", LoginResult::InvalidInput}
));
// TEST_P 是参数化用例的写法;通过 GetParam() 拿到当前这一组参数
TEST_P(LoginParamTest, Verify) {
const LoginParam p = GetParam(); // 取出本组数据
EXPECT_EQ(login(p.user, p.pass), p.expected);
}
// ---------- 三、Fixture:为每个用例准备"干净的环境" ----------
// Fixture(夹具):定义一个公共的测试环境类。这里用来演示"每个用例执行前后
// 各有一个钩子",保证用例之间状态互不污染。
class LoginFixtureTest : public ::testing::Test {
protected:
void SetUp() override {
// 每个用例执行前,GoogleTest 都会先调用这里
try_count = 0;
}
void TearDown() override {
// 每个用例执行后都会调用这里,一般用来释放资源、清理现场
// 本模块没有真实资源要释放,体现"钩子存在"即可
}
int try_count; // 一个"每用例清零"的成员,用来演示用例隔离
};
// TEST_F(夹具类名, 用例名):每个用例都构造一个全新的 LoginFixtureTest 实例,
// 所以 SetUp 里刚清零的 try_count 绝不会残留上一次用例的痕迹。
TEST_F(LoginFixtureTest, IsolatedCounter) {
++try_count; // 只有本用例能看到 +1 之后的值
EXPECT_EQ(try_count, 1);
}
TEST_F(LoginFixtureTest, StillIsolated) {
// 上一个用例里 try_count 已被 +1,但这里重新是 0,恰恰证明两个用例被隔离开了
EXPECT_EQ(try_count, 0);
}**Fixture(夹具)**这词看起来唬人,拆穿了就是"给一组用例准备的公共脚手架"。你把需要的前置数据、共享资源放成类的成员,在 SetUp() 里初始化、在 TearDown() 里清理,然后用例用 TEST_F(该类, 用例名) 去继承它。它的价值有两个:一是消除重复(不用每个用例各自啰嗦地初始化一遍);二而且是更要命的——给你"强制隔离"。下面 Parameterized 与 Fixture 我各留一道思考题,但先把更重要的"隔离"这个鬼放到最后一节专门收拾。
思考题:TEST 和 TEST_F 里的 "F" 是什么意思?
答案与详解(点开)
F 代表 Fixture(夹具)。 也就是说 TEST_F(MyClass, Name) 的 MyClass 必须是一个继承自 ::testing::Test 的夹具类,而不是普通字符串。
落到执行语义上,它和 TEST 最大的不同是:每跑一个用例,GoogleTest 都会重新构造一个全新的 MyClass 实例,然后自动依次调用其 SetUp()(准备)、用例体、TearDown()(清理)。所以不同用例之间完全共享不到成员变量——这是"隔离"的第一道保障。
顺带一个深水区知识(以后用到了再细看):如果你将来写的是 TEST_F(...),那对应的类命名时不能以 F 或 P 这些"被框架占用的前缀"开头、也不能碰框架保留的命名,否则容易和框架内部机制冲突。初学者现在知道"F = Fixture、实例每用例重建"就足够建立直觉了。
编译、运行与产出汇总
代码写齐了,终于到了"点火"的时刻。这一节所有命令都在项目根目录(装 CMakeLists.txt 那一层)下执行。我用两套命令分别演示"CMake 三步走 + CTest",以及"直接跑单测二进制 + 挑关键命令行参数"。
标准姿势:配置 → 编译 → 跑测试
# 进入工程根目录(含顶层 CMakeLists.txt 的那一层)
cd testing-demo
# 1)配置:生成构建文件到 build 目录。首次会自动下载 googletest 源码,需联网
cmake -S . -B build
# 2)编译:把被测库和两个测试可执行文件都编出来(可加 -j 并行加速)
cmake --build build
# 3)跑测试:用 ctest 统一运行所有登记过的测试,失败时打印详情
ctest --test-dir build --output-on-failureCTest 会把我们登记的两个测试程序挨个执行,输出大致长这样(这就是"产出汇总"的精髓——一眼看到绿了几个、红了几个):
Test project /path/to/testing-demo/build
Start 1: calculator_test
1/2 Test #1: calculator_test ............. Passed 0.02 sec
Start 2: auth_test
2/2 Test #2: auth_test ................... Passed 0.03 sec
100% tests passed, 0 tests failed out of 2
更细的查看:直接跑测试可执行文件
ctest 适合"一键全量 + 接 CI",但你想细看某个套件的完整输出、或者只挑出某个用例来分析,就直接运行那个测试二进制:
# 直接运行登录模块的测试程序,看完整的分色输出(绿=通过,红=失败)
./build/tests/auth_test
# 列出这个程序里都有哪些用例(过滤选项的预演)
./build/tests/auth_test --gtest_list_tests
# 只跑套件 Login 里的用例(用例过滤)
./build/tests/auth_test --gtest_filter=Login.*
# 更精细:只跑 Login.Success 这一个用例
./build/tests/auth_test --gtest_filter=Login.Success
# 生成一份 XML 报告到当前目录,供 CI / 看板工具消费
./build/tests/auth_test --gtest_output=xml:report.xmlcalculator_test 的完整通过输出大致是:
[==========] Running 7 tests from 4 test suites.
[----------] 1 test from CalcFactorial
[ RUN ] CalcFactorial.Normal
[ OK ] CalcFactorial.Normal (0 ms)
...
[----------] 7 tests from 4 test suites ran. (1 ms total)
[ PASSED ] 7 tests.
auth_test 则因为参数化,会看到 LoginCases/LoginParamTest.Verify/5 这样的"展开用例名"——数字后缀代表第几组参数,定位失败时非常管用。
失败了长什么样:先学会"读红汤"
自动化测试最实用的技能之一,是读懂失败输出。我们故意假设有一个断言挂了,比如 CalcAdd.HandlesPositive 里 calc::add(1, 2) 实际返回了 4,输出会是:
[ RUN ] CalcAdd.HandlesPositive
/path/tests/calculator_test.cpp:10: Failure
Expected equality of these values:
calc::add(1, 2)
Which is: 4
3
[ FAILED ] CalcAdd.HandlesPositive (0 ms)
逐行翻译给你听:
- 第一行告诉你是哪个用例挂了(
CalcAdd.HandlesPositive); - 第二行给出源文件与行号(
calculator_test.cpp:10),直接定位到那条断言; Expected equality以下,是"期望值"与"实际值"的对照:Which is: 4表示实际算出来是 4,第 4 行显示期望是 3;- 最后一行把该用例标成
FAILED。
这段"红汤"就是排查的导航地图:拿到文件行号,就找到了断言的落点;拿到实际值与期望值,就知道"实现偏了多少"。所以写断言时那些 << "额外信息"(流式补充说明),就是为了让这锅红汤更可口——GoogleTest 允许你加 EXPECT_EQ(...) << "用户 context 信息" 来附加可读说明。
关于"每次跑都要回到同一入口"的一点提醒
上面两条路径(ctest 和直接跑二进制)的共同点是:它们都从构建目录里取产物、且都以"测试程序返回码"判定成败。GoogleTest 有一个很硬的规定——只要有断言失败,main 返回值就不为 0,ctest 看到非 0 就标红。这个"非 0 即失败"的契约,正是后面接 CI 时判断"这次构建红没红"的命根子。把它记牢,我们后面有用。
思考题:ctest 和"直接运行测试程序",谁更适合本地调试?
答案与详解(点开)
本地 Debug 首选"直接跑测试可执行文件 + 过滤参数",验证整体工程健康则首选 ctest。 两者不是竞争关系,而是分工不同:
- 直接跑二进制:你能看到完整、带颜色的逐用例输出,还能用
--gtest_filter精确锁到某个用例、用--gtest_list_tests快速浏览全用量。它是"揪细节"的工具。 ctest:把工程里"所有登记的测试"(以后可能还有别家框架、集成脚本)统一调度、统一统结果,并给 CI 一个统一出入口。它是"看整体、给机器用"的工具。- 一句话分工:人肉定位用前者,机器验收用后者。这也是为什么两套命令我都写全——生产里你两个都要会按需切。
覆盖率:找出还没被测的代码
测试全绿,是不是就万事大吉?远不是。全绿只能说明"已经有测试覆盖到的路径是好的",覆盖不到的死角照样可能藏雷。于是有了覆盖率(test coverage)——一个用来评估"被测代码被测试执行到了多少"的量化指标。
覆盖率通常按不同维度统计:
- 语句覆盖率(line/statement coverage):被至少执行一次的代码行数 / 总行数。最常被口头提到,也最直观。
- 分支覆盖率(branch coverage):
if/else、switch等每个分支是否都被走到。比语句覆盖率更严格,能暴露"某条分支从没被测过"。 - 函数覆盖率(function coverage):有多少个函数被调用过。
覆盖率工具链在 Linux/g++ 下通常是铁三角:编译器开 --coverage 插桩 → 运行测试生成 .gcda 数据 → 用 lcov(或 gcovr)汇总成 HTML 报告。我带你走一遍。
# 关键点:库和测试都要带 --coverage 编译,才能埋入"统计探针"。
# 注意 --coverage 要同时加在编译期和链接期。
cd testing-demo
# 1)用一个独立的构建目录重新配置,专为覆盖率而编
cmake -S . -B build-cov \
-DCMAKE_CXX_FLAGS="--coverage" \
-DCMAKE_EXE_LINKER_FLAGS="--coverage"
cmake --build build-cov
# 2)跑一遍测试,让"统计探针"把每个路径的真实现况写进 .gcda 文件
ctest --test-dir build-cov --output-on-failure
# 3)用 lcov 扫描整个构建目录,收集覆盖率原始数据
lcov --capture --directory build-cov --output-file coverage_all.info
# 4)剔除"不算你干的活":外部库(googletest)、测试自身、系统头文件都不该计入
lcov --remove coverage_all.info \
'*/tests/*' '*/_deps/*' '/usr/*' \
--output-file coverage.info
# 5)生成一份带目录树和逐文件百分比的 HTML 报告
genhtml coverage.info --output-directory coverage_html
# 浏览器打开 build-cov/../coverage_html/index.html 即可查看需要一个工具链说明:上面这套是 gcc/g++ + lcov/gcovr 的玩法,在 Linux/macOS 上都成立。如果你用 MSVC(Windows 的 Visual Studio 编译器),它不自带 gcov,覆盖率得走 VS 的 "Code Coverage" 或 cobertura 那一套,命令和报告格式都不太一样——我用 Linux 版本做主线,跨平台差异你在真实环境里据所用手册调整即可。
之所以要专门剔除 tests/ 和 _deps/(拉下来的 googletest),是因为**"测试代码自己的覆盖率"是没有意义的**——你要的是"被测的产品代码 src/ 被覆盖了多少"。不剔除,统计会被外部库和测试代码稀释,得到一个虚高的假数字。
覆盖率是个好东西,但绝对不是"越高越好、必须 100%"的迷信。绝大多数团队把核心逻辑的语句覆盖率定在 80% 左右当及格线,并配一个"低于阈值 CI 就报警"的关卡;为了硬凑 100% 而写的"擦边用例",往往既贵又脆,还容易在重构时拖后腿。所以要理性看待它:它告诉你的不是"有没有 bug",而是"还有哪些角落从没被探到过"。
思考题:语句覆盖率 100%,就一定没有 bug 吗?
答案与详解(点开)
不一定,覆盖率 100% 只能证明"每条语句都被执行过",证明不了"执行结果都对、且各种组合都对"。 拆开看三层漏洞:
- 覆盖率测的是"有没有走过",不是"走完对不对":一条语句被执行了,但没断言它算出的值,照样可能错。没写断言的测试跑再全,也是"空跑",行覆盖率照算 100%。
- 分支组合可能爆炸:两个
if叠加就有 4 种组合,语句覆盖率哪怕 100%,也可能只走过其中 1 种组合。分支覆盖率能再补一层的,但面对复杂组合依然有限。 - "未经证实的值"与"未覆盖的入参域":比如
divide(1.0, 2.0)覆盖了除法语句,可divide(0.1, 3.0)这种浮点边界的舍入问题,语句覆盖率根本看不见。
所以覆盖率是"排除法工具":它最有价值的用法,是帮你找出"完全没被测到的死角和从来没被调用的分支",而不是向你保证安全。判断"够不够"要综合看覆盖范围 + 断言质量,两者缺一不可。
CI 触发:让测试在每个提交上自动跑
本地测试想跑就能跑,但对团队协作来说还不够——因为总有人会"忘了跑测试"就提交。于是我们把测试搬上持续集成(Continuous Integration,CI):一台永远清醒的机器,只要收到代码事件就自动把"配置 → 编译 → 跑测试(→ 算覆盖率)"整套流程跑一遍,谁动代码、谁负责看清萝卜红没红。
先解释"CI 触发"这个词:它指的是"什么样的代码事件,会触发这一轮自动构建与测试"。最常见的两个是 push(推到远程仓库) 和 pull_request(发起合并请求)。也就是说,你不是"手动去点一下测试",而是某件事一发生,CI 服务就自动按你配好的步骤点火。
这里我用 GitHub Actions 写一份最简工作流,命令注释里把每一步干什么都讲清楚:
# .github/workflows/ci.yml —— GitHub Actions 的自动测试工作流
name: cpp-tests # 工作流的显示名
on: # CI 触发条件:哪些代码事件会启动这轮自动测试
push: # 任何人向任意分支推送代码,就触发
pull_request: # 有人发起合并请求(Pull Request),也触发
jobs: # 一组任务
test: # 这个 job 叫 test
runs-on: ubuntu-latest # 在一台干净的最新版 Ubuntu 虚拟机上跑
strategy:
matrix: # matrix(矩阵):同一段流程在多套环境里各跑一遍
compiler: [g++, clang++] # 换编译器跨两行同时跑,验证其对两个编译器的兼容性
steps:
- name: 检出代码
uses: actions/checkout@v4 # 把仓库代码拉进运行环境
- name: 安装构建与覆盖率依赖
run: sudo apt-get update && sudo apt-get install -y cmake lcov
- name: 配置 CMake(按矩阵里的编译器指定)
run: cmake -S . -B build -DCMAKE_CXX_COMPILER=${{ matrix.compiler }}
- name: 编译
run: cmake --build build
- name: 跑测试
run: ctest --test-dir build --output-on-failure这个工作流有几个"为什么"值得讲透:
.github/workflows/ci.yml这个路径是约定的:文件一旦提交进仓库,GitHub 就自动按name/on/jobs这套声明把它启起来。文件位置和文件名是硬约定,别改。- matrix 是"一跑当多跑":我们只写了一套步骤,但因
compiler是个有两个元素的矩阵,GitHub 会在g++和clang++两套上各跑一遍,等于给我们做了"双编译器兼容回归"。这正是 C++ 里换编译器可能暴露 UB/代码迁移问题的廉价保险。 ctest返回非 0 就判失败:这正是上一节强调的"非 0 即失败"契约。测试挂了 → 该 step 标红 → 整个 job 判失败 → 这个提交就进不了主干。"失败即红"在这里落地为一条让机器信服的判定规则。- 为什么非要在"干净环境"里跑:CI 每次都是一台全新的虚拟机,等于强迫你"从零搭建再跑测试",避免"本机能过、换台机器就挂"的玄学。这正是"测试要快、且要可复现"在 CI 层面的延伸。
覆盖率要接进 CI 也不难:在 ctest 那一步后面再接一个如 codecov/codecov-action@v4 之类的步骤,把 coverage.info(上节 lcov 产出的)上传到 Codecov 等平台,仓库就能持续展示"每次提交覆盖率涨了还是跌了"。CI 本身的节奏是"宁可多跑、不可漏跑,挂了就红给所有人看"。
思考题:为什么 CI 必须跑在"每次提交都能重现的干净环境"里,而不是开发者的本机?
答案与详解(点开)
核心原因是可复现性(reproducibility)与环境耦合(environment coupling)。 展开讲三点:
- 本机环境是"脏"的、不可控的:开发者机器上可能残留了旧库、手动改过环境变量、装了没记录进工程的依赖。某个测试在本机能过,很可能只是因为"碰巧撞上了本机的某个巧合状态",换台干净的机器就挂——这种"本机过、CI 挂"的玄学最耗团队心力。
- 可复现 = 可追责:CI 每次从零搭建,等于把"编译 + 测试"的每一步都标准化到能复现。哪天挂了,所有人都能在同一台干净环境里复现、定位,而不是各讲各的"我这能过"。
- 一致性才是协作的底气:让"验收标准"不再依赖"谁的机器",而是依赖"一条可重放的脚本"。C++ 又是特别吃环境(编译器版本、标准库、链接参数)的语言,干净环境尤其重要——它能替你把"换个编译器/换个操作系统就行为不一致"这类问题提前暴露在合并之前,而不是上线之后。
绕不开的坑与应对
文章快收尾了。这一节集中收拾自动化测试里最经典的四个坑。它们每一个我都见过真实的翻车现场,请务必逐条记下。
坑一:用例隔离(Isolation)——用例之间不许"暗送秋波"
症状:用例 A 改了某个全局状态,用例 B 依赖这个状态,表面上 A、B 各自都"通过",但单独跑 B 却挂了,或者跑的顺序一变结果就变了。这类测试叫顺序敏感(order-dependent),是最讨人厌的隐藏炸弹。
成因:测试代码里共享了可变状态。常见元凶有三种:全局/静态变量、匿名命名空间里被测试改写的内部状态、以及引用了"上一次用例留下的文件/数据库"。
应对,一句话:每个用例都必须是"自成一体、互不相认"的。
- 需要"每用例独立"的数据,就放进 Fixture 的
SetUp()里初始化(我们LoginFixtureTest那一节就是这么演示的——try_count每用例都从 0 开始); - 不要依赖"测试框架保证用例按某个顺序执行"——不确定顺序,就是不能依赖的顺序;
- 被测代码里的全局/静态状态,在设计时就该想办法去掉或改为可注入(injectable),让测试能控制它;
- 一旦用
ctest -j做并行测试(--jobs参数),这种共享状态的问题会被成倍放大,因为多个用例真的是同时跑。并行测试是隔离做的硬指标。
坑二:构建依赖(Build Dependency)——"没链接对"比"断言错"更难缠
症状:编译报错找不到符号、undefined reference、或者 "no matching function"。这类错误的 80% 都和"构建依赖没配好"有关,跟被测逻辑一点关系都没有。
几种高频翻车点,逐个给对策:
- 漏了
gtest_main:链接了gtest却忘了gtest_main(或搞反),报"main 未定义"。对策:确认target_link_libraries(... gtest_main)在。 - 头文件路径没传过去:测试里
#include "calculator.h"找不到。对策:确认被测库的target_include_directories(... PUBLIC src)已写,且测试是通过target_link_libraries链上这个库的(PUBLIC 的包含目录会沿链接关系自动传导给测试)。 - FetchContent 拉不动:首次配置卡在下载 googletest。对策:换国内镜像仓库、或改用系统包 +
find_package(GTest REQUIRED)。生产里也常把 googletest 作为git submodule固定一份源码,彻底绕开"每次都要下载"。 - 重复定义 / ODR 冲突:某个符号在多个编译单元重复定义。对策:检查是否误把实现写进了头文件又多次
#include;同时回看第三节"头文件守卫"——守卫宏拼写不一致会让#ifndef/#define失效,从而引发连锁的重复定义报错。
构建依赖的排障心法:先让"纯净地从一个干净目录重新配置"能通过,再去想业务断言。rm -rf build && cmake -S . -B build 是这里的第一板斧。
坑三:测试要快(Speed)——单元测试跑 10 分钟,就没人爱跑
症状:测试越写越多,跑一次要几分钟甚至更久,于是大家开始"攒一攒再跑""能跳过就跳过",最终退化成一堆没人维护的死代码。
成因:把"接近真实环境"的事塞进了单元测试——真去读写磁盘、真去发网络请求、真去 sleep 等异步。这些操作每个都不慢,但成百上千个加起来就是灾难;sleep(2) 更是把"确定性"也丢了(慢机器和快机器结果都不一样)。
应对,按照"测试金字塔"的意识分层:
- 单元测试必须毫秒级:只测纯逻辑,不碰外部世界。被测代码用到文件/网络/DB 的地方,用假实现(fake/stub)或 mock 替掉——GoogleTest 的 gmock 正是为这个量身定做的。
- 宁可不要
sleep,也要"轮询 + 超时"或真·同步设计:sleep(固定秒)既慢又不可靠,真需要等外部就绪的场景要用带超时的明确轮询,并拆到慢速测试组里。 - 把慢的重型验证单独归为一类(比如打上
[integration]或[slow]标签),让"日常开发只跑快的单元测试",CI 再全部跑一遍。"让高频测试保持闪电般快",是它能持续被跑起来的第一生产力。
坑四:失败即红(Fail-Red)——测了但要能真失败,否则是"假绿"
症状:测试永远全绿,哪怕你故意把被测逻辑改错,测试还是一片"绿"。这种测试叫恒真测试(tautological),比没有测试更糟——因为它给你虚假的安全感。
成因,三种典型:
- 断言没写全/没写关键点:只有
EXPECT_TRUE(...)这种"几乎恒真"的宽泛断言,没锁住具体的值; - 测了实现自己:比如
EXPECT_EQ(func(a,b), func(a,b)),两边都在调用同一个被测函数,无论它错成什么样,两边同时变,永远相等——典型的"测了个寂寞"; - 只测了成功分支:错误路径、边界(除零、空输入、负值、溢出)根本没测,等于给 bug 留了后门。
应对,给两把"试金石":
- 变异测试(mutation testing)的朴素版:写完测试后,故意去改一行被测代码(比如把
factorial里result的初值从1改成0),然后重跑测试。如果能变红,说明测试真的在咬住这个行为;如果还是绿,说明这条行为根本没被测住。 手动做这个动作,就是最便宜的"失败即红"体检。 - 清单式自查:断言落点是不是"具体的值"而不是"宽泛真值"?成功和失败两种路径是否都覆盖?边界参数(负值/空/零/溢出)是否都有用例?一个测试,"要么能证明正确、要么能在代码出错时报红",二者必须占其一,否则就该删掉重写。
这四个坑——隔离、构建依赖、要快、失败即红——是判断一套自动化测试"到底值不值得信任"的试金石。能同时守住它们,你的测试就不是摆设,而是真正守在功能门口的哨兵。
思考题:下面这段被测代码与测试,哪里埋着"失败即红"的雷?
// 被测函数
long long factorial(int n) {
long long result = 0; // 注意:这里初值是 0(应该为 1)
for (int i = 2; i <= n; ++i) result *= i;
return result;
}
// 测试
TEST(CalcFactorial, Normal) {
EXPECT_EQ(calc::factorial(0), 0); // 它测的是"0! == 0"
EXPECT_EQ(calc::factorial(5), 0); // 它断言 5! == 0
}答案与详解(点开)
这个测试从两份代码上分别埋了一颗雷,而且恰好互相抵消,成了一个"伪绿"的恒真测试。 拆开看:
- 被测代码的雷:
result初值被我们写成了0(正确应为1)。因为从0开始连乘任何数都还是0,所以任何非负整数输入(包括factorial(0)、factorial(5))都会返回0。 - 测试的雷:断言恰好也写成了"期望返回值等于
0"。注意这是照着有 bug 的实现去抄了期望值,而不是照着"数学上应该怎样"去写。0!=1、5!=120,正确的期望应该是1和120。 - 为什么它"伪绿":
factorial(5)实际返回0,测试期望的也是一0,于是断言通过、全绿。可数学上 5 的阶乘明明是 120——实现错了,测试却没报红,这就是最危险的"假绿"。 - 该怎么修:期望值必须来自"数学定义",而不是"当前实现"——应改成
EXPECT_EQ(calc::factorial(0), 1)和EXPECT_EQ(calc::factorial(5), 120)。改完后,一旦result初值变回0,测试立刻变红。 - 最大教训:写断言时,"期望值"永远要来自需求/数学定义/你对行为的设计,绝不来自"看一眼当前实现抄下来"。否则你只在给 bug 写"认证书",而不是给正确性写"证明书"。这也和上面"失败即红"的变异自检如出一辙:故意改坏实现,应该能看到红。
再给自己出三道综合题
题目覆盖全文,答案照旧放在折叠里,先别看。
第一题:我们已经搭好的这个工程里,calculator_test 和 auth_test 分别编译成两个独立可执行文件,都由 ctest 登记。请问:如果你想"只跑登录模块、不跑数学模块",有哪两种写命令的方法?
第二题:把 login 的账号表从"匿名命名空间里的静态数组"改成"从外部 JSON 文件读取",会对单元测试带来什么影响?要维持"单元测试快且可复现",最合理的改动是什么?
第三题:为什么单元测试里出现 sleep(2) + "整个页面加载"这种代码,通常意味着它"既慢又不可靠"?在 C++ 单元测试的语境下应该怎么避免?
第一题答案与详解
两条路都行,取决于你想锁多细:
-
在 CTest 层只挑一个程序:
ctest --test-dir build -R auth_test-R(include regex)让 ctest 只运行名字匹配auth_test的已登记测试。想跑数学模块就-R calculator_test。 -
直接跑目标二进制 + 用例过滤:
./build/tests/auth_test # 或进一步只跑某个套件/用例它只运行这一个程序,天然"只测登录"。想要更细可以配合
--gtest_filter,如./build/tests/auth_test --gtest_filter=Login.*。
两条路的分工正是前面讲过的:ctest -R 是"站在全局里选子集、保持一致入口",直接跑二进制是"本地手感最好的单程序压测"。行为等价,看你的习惯。
第二题答案与详解
影响非常直接:单元测试的"快且可复现"被破坏了。
- 不再离线:读取外部 JSON,测试第一次执行就要依赖一个真实文件的存在和内容,环境一变(文件路径不同、内容不同、权限不同)结果就可能变——破坏了"给定输入必有确定输出"的可复现性。
- 不再快 / 不再稳定:读文件属于 I/O,虽不至于像网络那样慢,但文件缺失、被并发测试抢占等都会引入不确定性;想测"账号表里有一万个用户、用户名大小写不等"等场景也麻烦。
最合理的改动是依赖注入(dependency injection):把"账号表的来源"从 login 内部硬编码,上移到传入参数或可注入的对象。例如让 login 接收一份账号表(结构体/容器)作为参数,而不是自己偷偷去读全局文件。这样测试就能:
- 在用例里"喂一份我们自己构造的小表",覆盖任意想测的数据,完全不碰真实文件;
- 保持纯逻辑、离线、毫秒级,可复现性拉满。
把"依赖"从函数内部解放到"可传入",正是 C++ 里让代码**可测(testable)**的核心改造手法,GoogleTest 的 gmock 假对象也建立在这套"可注入"的思路上。
第三题答案与详解
"慢"和"不可靠"是同一件事的两面,根源在单元测试不该去耦合真实外部时序。
- 慢:
sleep(2)一次固定等 2 秒,哪怕被测逻辑早就完成了也得干等。成百个用例各等几秒,整体立刻爆炸成"跑一次要 10 分钟"的重锤,违背"测试要快"的铁律。 - 不可靠:固定等待是对"外部真实耗时"的赌博——这 2 秒在 CI 的慢机器上可能不够(条件未就绪就往下跑,挂得出乎意料),在快机器上又可能溢出(白白多等)。也就是说,同一份测试,换个环境结果可能变,这正是最怕的顺序不稳定/时序炸弹。
- C++ 语境下怎么改:单元测试里不要真去等外部世界。被测代码用到外部资源时,用假实现(fake)/ mock 把"外部世界的耗时"替换成"瞬时的、确定性的返回"。真需要验证"等待并重试"这类行为时,也要用可注入的时钟(injectable clock)把时间变成可伪造的,而不是真去
sleep。把真正吃资源、必须等真实外部时序的验证(比如整条链路)隔离到"集成/端到端测试"那一层,让单元测试永远保持"毫秒级、可重复、不看环境脸色"。
到这里,从"为什么测"到"怎么选型、怎么搭工程、怎么写断言、怎么组织用例、怎么出报告、怎么算覆盖率、怎么接 CI、怎么避开四大坑",一条完整的 C++ 自动化测试实施链路就闭环了。你现在手头应该有一套能真的跑起来的 testing-demo:两个被测模块(数学库 + 登录校验),一批覆盖"值断言、浮点断言、异常断言、参数化、Fixture、用例隔离"的测试,一份能出 XML 与 HTML 报告、能统计覆盖率、能被 CI 触发跑在每次提交上的完整体检报告。
给你一个"课后再走一遍"的建议:回到那个"失败即红"的变异自检——去把 factorial 的 result 初值改成 0,然后跑一遍测试,亲眼看着 CalcFactorial.Normal 从绿变红、CTest 从 "100% passed" 变成失败;再改回正确值,看它重新变绿。就这么一个来回,你对"测试到底在守护什么、红绿到底意味着什么"的体感,会比读十篇文章都深。
下一站,自动化测试还可以走得更远:mock 掉依赖的分层测试、面向并发与性能的测试、以及把覆盖率压进 CI 门槛的持久化实践。但那是后话——先把今天这套"能跑、能看、能御敌"的工程攥稳,你就已经站在了 C++ 工程化最值钱的地基上了。
还没有评论 — 第一条由你来留。