第26章 测试与调试:单元测试框架(Google Test)、断言与静态断言、GDB调试技巧、Valgrind内存检测、代码覆盖率
写代码这件事,说白了就是「写一半,改一半,debug 一半」。我做了十几年 C++,最深的体会就是:不会测试和调试,等于没学会写代码。这一章我们聊聊怎么让你的代码「靠谱」起来。
本章核心脉络:从单元测试到内存检测,再到覆盖率分析,这是一条完整的质量保障链路。
26.1 单元测试框架:Google Test 实战
单元测试是什么?说白了就是「给每个函数写个体检报告」。我见过太多人写完代码直接上线,结果线上崩了才回头找 bug。嗯,这习惯得改。
Google Test(简称 gtest)是目前 C++ 社区最流行的单元测试框架。我个人习惯把它和 Google Mock 一起用,效果翻倍。
26.1.1 安装与基本结构
先看一个最简单的例子。假设你写了一个计算器类:
// calculator.h
class Calculator {
public:
int Add(int a, int b) { return a + b; }
int Divide(int a, int b) {
if (b == 0) throw std::invalid_argument("除数不能为0");
return a / b;
}
};
对应的测试代码长这样:
// test_calculator.cpp
#include <gtest/gtest.h>
#include "calculator.h"
TEST(CalculatorTest, AddWorks) {
Calculator calc;
EXPECT_EQ(calc.Add(2, 3), 5);
EXPECT_EQ(calc.Add(-1, 1), 0);
EXPECT_EQ(calc.Add(0, 0), 0);
}
TEST(CalculatorTest, DivideWorks) {
Calculator calc;
EXPECT_EQ(calc.Divide(10, 2), 5);
EXPECT_THROW(calc.Divide(1, 0), std::invalid_argument);
}
小技巧:TEST 宏的第一个参数是测试套件名,第二个是测试用例名。我习惯按模块分套件,比如 DatabaseTest、NetworkTest,这样跑测试时能快速定位问题区域。
26.1.2 常用断言宏
| 断言宏 | 作用 | 失败时行为 |
|---|---|---|
| EXPECT_EQ(a, b) | 检查 a == b | 继续执行 |
| EXPECT_NE(a, b) | 检查 a != b | 继续执行 |
| EXPECT_TRUE(cond) | 检查条件为真 | 继续执行 |
| ASSERT_EQ(a, b) | 检查 a == b | 立即终止当前测试 |
| EXPECT_THROW(stmt, ex) | 检查是否抛出异常 | 继续执行 |
这里有个关键区别:EXPECT_* 系列是「软检查」,失败了还会继续跑后面的代码;ASSERT_* 系列是「硬检查」,一旦失败直接退出当前测试用例。我在项目中遇到过这样的情况:某个测试用例有 10 个检查点,前 3 个用 ASSERT,后面用 EXPECT。为什么?因为前 3 个如果错了,后面的结果毫无意义,没必要浪费时间。
26.2 断言与静态断言
断言分两种:运行时断言和编译期断言。很多人只用了前者,忽略了后者——其实静态断言能帮你省下大量调试时间。
26.2.1 运行时断言
标准库的 assert 宏,在 debug 模式下检查条件,release 模式下被移除:
#include <cassert>
void ProcessBuffer(const char* buf, size_t len) {
assert(buf != nullptr); // 指针不能为空
assert(len > 0); // 长度必须大于0
// ... 处理逻辑
}
注意:千万别在 assert 里写有副作用的代码!比如 assert(++x > 0),release 模式下 ++x 不会执行,bug 就藏起来了。我曾经在一个项目里排查了整整两天,最后发现是 assert 里藏了个赋值操作……
26.2.2 静态断言(C++11 起)
静态断言在编译期检查,不产生任何运行时开销。它的语法是:
static_assert(条件表达式, "错误信息");
// 实际例子
template<typename T>
class SafeContainer {
static_assert(std::is_integral_v<T>, "SafeContainer 只支持整数类型");
// ...
};
你想想看,如果有人在你的模板里传了个 string 进来,编译直接报错,而不是等到运行时才崩溃——这多省心。我个人习惯在写模板类时,第一件事就是加 static_assert 约束类型参数。
26.3 GDB 调试技巧
GDB 是 Linux 下最强大的调试器,没有之一。虽然现在 IDE 很流行,但服务器上出问题的时候,GDB 是你唯一的依靠。
26.3.1 基本操作
编译时记得加 -g 选项:
g++ -g -o myapp myapp.cpp
启动 GDB 后,最常用的命令就这几个:
| 命令 | 作用 | 示例 |
|---|---|---|
| break | 设置断点 | b main.cpp:42 |
| run | 开始运行 | r arg1 arg2 |
| next | 单步执行(跳过函数) | n |
| step | 单步执行(进入函数) | s |
| 打印变量值 | p x | |
| backtrace | 查看调用栈 | bt |
| continue | 继续运行到下一个断点 | c |
26.3.2 实用技巧三则
技巧一:条件断点
有时候你只想知道某个循环在第 1000 次时发生了什么:
(gdb) break main.cpp:88 if i == 1000
技巧二:修改变量值
调试时临时改个值,不用重新编译:
(gdb) set var x = 42
技巧三:查看内存
检查指针指向的内容:
(gdb) x/16xb ptr # 以十六进制显示 ptr 指向的 16 个字节
我的经验:遇到段错误(Segmentation Fault)时,第一件事就是 bt 看调用栈。然后 frame N 切换到对应层级,info locals 查看局部变量。80% 的段错误都能在 5 分钟内定位。
26.4 Valgrind 内存检测
C++ 程序员最头疼的问题之一:内存泄漏。你写了个服务跑了一周,内存从 100MB 涨到 2GB——不用怀疑,肯定有泄漏。
Valgrind 就是干这个的。用法极其简单:
valgrind --leak-check=full ./myapp
它会输出类似这样的信息:
==12345== 40 bytes in 1 blocks are definitely lost in loss record 1 of 1
==12345== at 0x4C2E0EF: malloc (vg_replace_malloc.c:299)
==12345== by 0x4005E7: main (main.cpp:12)
看到没?它直接告诉你在 main.cpp:12 的 malloc 泄漏了 40 字节。我曾经接手过一个遗留项目,用 Valgrind 一跑,好家伙,200 多处泄漏。一个一个修,修完内存占用直接降了 70%。
注意:Valgrind 会让程序运行速度慢 10-20 倍。别在生产环境跑,在测试环境跑就行。另外,它只检测「堆内存」的泄漏,栈上的问题它管不了。
26.5 代码覆盖率
最后一个话题:你怎么知道你的测试用例覆盖了哪些代码?
GCC 自带的 gcov 工具可以帮你。编译时加两个选项:
g++ -fprofile-arcs -ftest-coverage -o myapp myapp.cpp
跑完程序后,会生成 .gcda 和 .gcno 文件。然后用 gcov 分析:
gcov myapp.cpp
输出结果会告诉你每行代码被执行了多少次。配合 lcov 还能生成漂亮的 HTML 报告:
lcov --capture --directory . --output-file coverage.info
genhtml coverage.info --output-directory coverage_report
打开生成的 HTML,绿色表示覆盖到了,红色表示没覆盖到。我一般要求团队的核心模块覆盖率不低于 80%。低于这个数,说明你的测试用例写得太「客气」了,没把边界情况测全。
一句话总结:Google Test 帮你写测试,断言帮你做检查,GDB 帮你找运行时问题,Valgrind 帮你抓内存泄漏,覆盖率帮你评估测试质量。这五样东西配合起来,你的代码质量会有质的飞跃。
公众号:蓝海资料掘金营,微信deep3321