单元测试与基准测试:Google Test、Google Benchmark、微基准测试的陷阱
说实话,很多C++程序员写代码时有个坏习惯——写完就跑,跑通就算。我早年也这样,直到有一次线上服务因为一个边界条件崩溃,查了两天才发现是某个函数在特定输入下性能退化到O(n²)。从那以后,我把测试和基准测试当成了吃饭喝水一样的基本功。
这一章,咱们聊聊Google Test和Google Benchmark这两个工具。它们不是银弹,但用好了,能让你少熬很多夜。
Google Test:不只是断言
Google Test(简称gtest)是C++社区最流行的单元测试框架之一。它的核心价值不在于“能跑”,而在于“能让你快速定位问题”。
核心要点:单元测试不是为了证明代码没错,而是为了在出错时第一时间告诉你哪里错了。
先看一个最简单的例子:
#include <gtest/gtest.h>
int Add(int a, int b) {
return a + b;
}
TEST(AddTest, PositiveNumbers) {
EXPECT_EQ(Add(2, 3), 5);
EXPECT_EQ(Add(0, 0), 0);
}
TEST(AddTest, NegativeNumbers) {
EXPECT_EQ(Add(-1, -2), -3);
EXPECT_EQ(Add(-5, 5), 0);
}
这里有个细节:EXPECT_EQ和ASSERT_EQ的区别。前者失败后继续执行,后者直接终止当前测试。我个人习惯在“后续测试依赖当前结果”时用ASSERT,否则用EXPECT。比如测试一个链表,如果头节点是空指针,那后面的遍历就没意义了,这时候用ASSERT更合适。
测试夹具(Test Fixture)
当多个测试用例需要相同的初始化逻辑时,别重复写。用TEST_F:
class MyVectorTest : public ::testing::Test {
protected:
void SetUp() override {
v.push_back(1);
v.push_back(2);
v.push_back(3);
}
std::vector<int> v;
};
TEST_F(MyVectorTest, Size) {
EXPECT_EQ(v.size(), 3);
}
TEST_F(MyVectorTest, Access) {
EXPECT_EQ(v[1], 2);
}
我在项目中遇到过一种情况:某个测试夹具的SetUp里开了文件句柄,但TearDown忘了关。结果跑完测试,文件描述符泄露了。嗯,这里要注意——资源清理一定要在TearDown里做,别依赖析构函数,因为异常安全的问题可能让析构跑不到。
Google Benchmark:别靠感觉优化
“我觉得这个函数很快”——这是性能优化的头号杀手。Google Benchmark能给你真实数据。
基本用法:
#include <benchmark/benchmark.h>
static void BM_StringCreation(benchmark::State& state) {
for (auto _ : state) {
std::string empty_string;
}
}
BENCHMARK(BM_StringCreation);
static void BM_StringCopy(benchmark::State& state) {
std::string x = "hello";
for (auto _ : state) {
std::string copy(x);
}
}
BENCHMARK(BM_StringCopy);
BENCHMARK_MAIN();
跑完之后,你会看到类似这样的输出:
----------------------------------------------------------
Benchmark Time CPU Iterations
----------------------------------------------------------
BM_StringCreation 2.34 ns 2.34 ns 299999808
BM_StringCopy 5.67 ns 5.67 ns 123456789
注意那个Iterations列。Google Benchmark会自动调整迭代次数,确保统计结果稳定。你不需要手动写循环,框架帮你做了。
微基准测试的陷阱
这部分我想重点说说。微基准测试看起来简单,但坑特别多。我曾经被一个微基准测试误导过,花了两天优化一个“看起来慢”的函数,结果发现是测试代码本身的问题。
常见陷阱清单:
- 编译器优化:你测的代码可能被优化掉了
- 内存布局:缓存命中率影响巨大
- 冷启动 vs 热启动:第一次调用和后续调用差异很大
- 系统噪声:后台进程、中断、频率缩放
咱们一个个说。
陷阱一:编译器优化
看这个例子:
static void BM_Compute(benchmark::State& state) {
int x = 0;
for (auto _ : state) {
x = x + 1;
}
// 编译器可能把整个循环优化掉,因为x没被使用
}
BENCHMARK(BM_Compute);
解决办法是用benchmark::DoNotOptimize:
static void BM_Compute(benchmark::State& state) {
int x = 0;
for (auto _ : state) {
benchmark::DoNotOptimize(x = x + 1);
}
}
BENCHMARK(BM_Compute);
这个函数告诉编译器:“别动这个变量,它很重要”。虽然它本质上是个空操作,但能阻止优化。
陷阱二:内存布局
你想想看,同样的算法,处理连续内存和不连续内存,性能可能差10倍。我在项目中遇到过:一个哈希表的查找操作,在微基准测试里表现很好,但放到实际系统里就慢得离谱。为什么?因为微基准测试里所有数据都在L1缓存里,而实际系统里数据分散在内存各处。
解决办法:用benchmark::ClobberMemory()模拟真实的内存访问模式,或者干脆用更大的数据集。
陷阱三:冷启动
Google Benchmark默认会做预热(warm-up)迭代,但如果你测的是“第一次调用”的性能,需要手动控制:
static void BM_ColdStart(benchmark::State& state) {
for (auto _ : state) {
// 每次迭代都重新初始化
MyObject obj;
obj.Init();
obj.DoSomething();
}
}
BENCHMARK(BM_ColdStart)->Iterations(100)->WarmUpCount(0);
这里WarmUpCount(0)表示不做预热。但要注意,这样测出来的数据方差会很大,需要多跑几次取中位数。
知识体系图
下面这张图总结了单元测试和基准测试的核心逻辑:
一些实用技巧
个人经验总结:
- 单元测试的命名要清晰,比如
TEST(ClassName_MethodName, Scenario),这样失败时一眼能看出是哪个场景出了问题。 - 基准测试的结果不要只看平均值,关注方差和最小值。方差大说明系统不稳定,最小值往往代表“最佳情况”。
- 我习惯在CI里同时跑单元测试和基准测试。单元测试保证正确性,基准测试防止性能退化。一旦基准测试结果比上次慢了20%以上,自动告警。
- 微基准测试的结果要跟实际场景对比。如果微基准测试显示某个函数很快,但实际系统里慢,那多半是测试环境跟生产环境不一致。
最后说一句:测试和基准测试不是一次性工作。代码在变,编译器在变,硬件也在变。定期重新跑一遍,才能确保你的代码一直“又快又稳”。
公众号:蓝海资料掘金营,微信deep3321