C++ 测试的三道坎:隔离、节奏与不稳定性
写 C++ 的人大多不缺「测试意识」,缺的是一套让测试真正跑起来、跑稳定、还能活过半年的工程配置。我在这条路上走过三个弯路,一一拆开来说。
第一坎:测试隔离
C++ 测试失败,有一半的根因是全局状态污染。某个单例在测试 A 里被修改,测试 B 跑进去时状态已经脏了。顺序跑没问题,随机顺序就挂。这种 flaky test 是最难追的问题——它不是 bug,是架构问题。
解法是彻底拥抱依赖注入。不要让被测代码直接访问全局对象、静态变量或 Singleton::instance();把依赖以接口引用的形式传入构造函数。这样测试里可以传入一个 fake 或 mock,现实中用真实实现,两者互不干扰。
// 反模式:被测代码内部拉全局依赖
void Service::Publish(const std::string &msg) {
GlobalNotifier::instance().Send(msg); // 测试里无法替换
}
// 正模式:构造函数注入接口引用
class Service {
public:
explicit Service(Notifier ¬ifier) : notifier_(notifier) {}
void Publish(const std::string &msg) { notifier_.Send(msg); }
private:
Notifier ¬ifier_;
};测试里传入 MockNotifier,生产代码传入真实实现,两条路径完全独立。
另一个常见污染源是临时文件路径硬编码。测试 A 写了 /tmp/test.bin,测试 B 也写同一个路径——如果并行跑就会互相踩。每个 fixture 应该用唯一临时目录(可以用测试名哈希或随机后缀),TearDown 里清理干净。
第二坎:TDD 节奏
Red → Green → Refactor 这个循环,理论上人人都懂。实际操作里最容易断掉的是 Green 阶段的自我约束:刚让测试变红,就忍不住在 Green 阶段一次性写出「优雅的最终实现」。
这是个陷阱。Green 阶段的唯一目标是让测试通过,不是写好代码。哪怕是硬编码返回值也没关系。把「好代码」留给 Refactor 阶段——那时测试是绿的,你有完整的安全网,可以随意重构而不怕行为被悄悄改掉。
GoogleTest 里两者的语义不同:ASSERT_* 失败会立即终止当前测试函数(适合前提条件检查),EXPECT_* 失败后继续执行(适合一次检查多个相关断言)。用对了,失败时的错误信息会精确得多,定位根因更快。
第三坎:测试本身的稳定性
flaky test 是测试体系的慢性病。有几类根源需要专门防范:
时间依赖:绝对不要在单元测试里 sleep 等待。应该把「时钟」抽象成可注入的接口,测试里传入 fake clock,直接推进时间,不依赖 wall clock。并发测试里等待异步操作完成,用 condition variable 或 latch,而不是「sleep 100ms 应该够了」。
随机性:如果测试用了随机输入,必须固定 seed 并在失败时打印出来,否则失败无法复现。
内存和竞态:这两类 bug 很多时候测试跑通了但代码有问题,因为恰好没触发。CI 里应该常态化跑 sanitizer 构建:
cmake -S . -B build-asan -DENABLE_ASAN=ON
cmake --build build-asan -j
ctest --test-dir build-asan --output-on-failureAddressSanitizer 和 ThreadSanitizer 会把「偶发的段错误」变成「必现的断言失败」,这是在单测阶段捕捉内存和竞态问题最高效的手段,比事后用 Valgrind 排查要快一个数量级。
Mock 还是 Fake?
这是 C++ 测试里一个容易混淆的取舍:
Mock 适合验证「有没有被调用、用什么参数调用」——即交互正确性。EXPECT_CALL(notifier, Send("hello")).Times(1) 这类断言。
Fake 适合有状态的依赖——比如一个内存版的数据库或消息队列。Fake 实现了接口的完整语义,只是用内存替代真实 I/O。Fake 的测试用例更接近真实行为,但维护成本比 Mock 高。
过度 mock 是反模式:如果一个测试里有七八个 MOCK_METHOD,通常说明被测代码的职责太多,或者测试在验证实现细节而非行为。遇到这种情况,先重新审视被测模块的边界,而不是继续堆 mock。
一句话心法
好的 C++ 测试不是在验证代码跑了什么,而是在约定代码必须做什么——依赖注入让这个约定可以独立检验,sanitizer 让隐藏的错误无处遁形。