第26章 测试与调试:单元测试框架(Google Test)、断言与静态断言、GDB调试技巧、Valgrind内存检测、代码覆盖率

写代码这件事,说白了就是「写一半,改一半,debug 一半」。我做了十几年 C++,最深的体会就是:不会测试和调试,等于没学会写代码。这一章我们聊聊怎么让你的代码「靠谱」起来。

本章核心脉络:从单元测试到内存检测,再到覆盖率分析,这是一条完整的质量保障链路。

测试与调试体系 单元测试框架 Google Test 断言机制 断言 + 静态断言 GDB调试 断点/回溯/修改变量 Valgrind 内存泄漏/越界检测 代码覆盖率 gcov / lcov 五大工具协同工作,覆盖开发全流程 编写 → 测试 → 调试 → 检测 → 优化

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
print 打印变量值 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