第27章:测试与质量保障——单元测试框架、Mock技术、代码覆盖率、持续集成
说实话,很多C++程序员对测试的态度是「写代码已经很累了,测试能跑就行」。我以前也这么想,直到有一次线上崩溃,查了两天发现是一个边界条件没覆盖到。嗯,从那以后,我把测试当成了代码的一部分,而不是附加品。
这一章,我们来聊聊C++项目里怎么把测试做扎实。我会结合自己的实战经验,把单元测试、Mock、覆盖率、CI这些话题串起来讲。
27.1 单元测试框架:Google Test 实战
Google Test(简称gtest)是目前C++社区最流行的测试框架。我个人的习惯是,每个新项目的第一件事就是搭好gtest环境。为什么?因为测试框架越早引入,后期重构的成本越低。
27.1.1 基本用法
先看一个最简单的例子:
#include <gtest/gtest.h>
int Add(int a, int b) {
return a + b;
}
TEST(AddTest, PositiveNumbers) {
EXPECT_EQ(Add(1, 2), 3);
EXPECT_EQ(Add(10, 20), 30);
}
TEST(AddTest, NegativeNumbers) {
EXPECT_EQ(Add(-1, -2), -3);
EXPECT_EQ(Add(-5, 5), 0);
}
int main(int argc, char **argv) {
::testing::InitGoogleTest(&argc, argv);
return RUN_ALL_TESTS();
}
这里有两个关键点:
TEST()宏的第一个参数是测试套件名,第二个是测试用例名。我建议套件名按模块或类来命名,比如NetworkTest、CacheTest。EXPECT_EQ是「非致命断言」——即使失败了,测试还会继续跑。还有ASSERT_EQ,一旦失败就终止当前测试。我一般用EXPECT_*,除非后续代码依赖前面的结果。
27.1.2 Test Fixture:共享测试环境
当多个测试需要相同的初始化代码时,用TestFixture可以避免重复。我曾经在一个网络库项目里,每个测试都要创建socket、绑定端口……写了20遍重复代码后才想起用Fixture。
class QueueTest : public ::testing::Test {
protected:
void SetUp() override {
q_.push(1);
q_.push(2);
}
void TearDown() override {
// 清理资源,比如关闭文件句柄
}
std::queue<int> q_;
};
TEST_F(QueueTest, IsEmptyInitially) {
EXPECT_FALSE(q_.empty());
}
TEST_F(QueueTest, PopWorks) {
q_.pop();
EXPECT_EQ(q_.front(), 2);
}
注意:使用Fixture时,宏要写成TEST_F,不是TEST。我见过有人写错,编译报错后一脸懵。
27.2 Mock技术:隔离外部依赖
单元测试的核心原则是「只测当前单元」。如果你的函数调用了数据库、网络、文件系统,那测试就变成了集成测试。Mock技术就是用来解决这个问题的。
Google Mock是gtest的搭档,专门用来生成模拟对象。我举个例子:
class Database {
public:
virtual ~Database() = default;
virtual bool Save(const std::string& key, const std::string& value) = 0;
virtual std::string Load(const std::string& key) = 0;
};
class MockDatabase : public Database {
public:
MOCK_METHOD(bool, Save, (const std::string&, const std::string&), (override));
MOCK_METHOD(std::string, Load, (const std::string&), (override));
};
TEST(ServiceTest, SaveAndLoad) {
MockDatabase mock_db;
EXPECT_CALL(mock_db, Save("name", "Alice"))
.WillOnce(Return(true));
EXPECT_CALL(mock_db, Load("name"))
.WillOnce(Return("Alice"));
// 这里传入mock对象,测试业务逻辑
Service svc(&mock_db);
svc.StoreUser("name", "Alice");
auto result = svc.GetUser("name");
EXPECT_EQ(result, "Alice");
}
.WillOnce(),结果测试一直卡住。记住:EXPECT_CALL只是声明期望,WillOnce或WillRepeatedly才是定义行为。缺一不可。
Mock的核心价值在于:你不需要真的启动数据库、不需要真的发HTTP请求,就能验证你的业务逻辑是否正确。说白了,就是「假扮」外部依赖,让测试又快又稳。
27.3 代码覆盖率:别被数字骗了
覆盖率工具(比如gcov、lcov)能告诉你哪些代码被执行了。但我要说一句大实话:100%覆盖率不代表没有bug。
我在一个金融项目里见过一个模块,覆盖率95%,但线上还是出了事故。为什么?因为那5%没覆盖到的代码,恰好是极端行情下的清算逻辑。覆盖率只能告诉你「哪些代码跑了」,不能告诉你「跑得对不对」。
27.3.1 如何设置覆盖率目标
| 模块类型 | 建议覆盖率 | 说明 |
|---|---|---|
| 核心算法 | ≥ 90% | 分支、边界条件必须覆盖 |
| 业务逻辑 | ≥ 80% | 主要路径覆盖,异常路径尽量覆盖 |
| 工具类/辅助函数 | ≥ 70% | 重点覆盖公共接口 |
| 第三方封装层 | ≥ 60% | 主要关注调用是否正确 |
我个人习惯是:先跑一遍覆盖率报告,看看哪些代码完全没被执行。如果发现一个函数从来没被测试调用过,那就要问自己——这个函数是不是死代码?还是测试漏写了?
27.4 持续集成:让测试自动跑起来
手动跑测试?不存在的。现代C++项目必须上CI(持续集成)。我常用的方案是GitHub Actions + CMake + gtest。
27.4.1 一个简单的CI配置
name: C++ CI
on:
push:
branches: [ main, develop ]
pull_request:
branches: [ main ]
jobs:
build-and-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Install dependencies
run: sudo apt-get install -y cmake g++ libgtest-dev
- name: Configure
run: cmake -B build -DCMAKE_BUILD_TYPE=Debug
- name: Build
run: cmake --build build
- name: Run tests
run: cd build && ctest --output-on-failure
- name: Generate coverage
run: |
sudo apt-get install -y lcov
lcov --capture --directory . --output-file coverage.info
lcov --remove coverage.info '/usr/*' --output-file coverage.info
lcov --list coverage.info
这个配置做了几件事:
- 每次push或PR到main分支,自动触发构建
- 编译Debug版本(方便调试)
- 运行所有测试,失败时输出详细信息
- 生成覆盖率报告
你想想看,如果每次提交代码后,CI都能在5分钟内告诉你「你的改动有没有破坏已有功能」,那该多省心。我经历过没有CI的日子——每次合并前手动跑测试,跑完还要截图发群里……现在回想起来,那简直是原始社会。
27.4.2 CI的常见坑
- 测试太慢:如果CI跑一次要半小时,没人愿意等。我建议把测试分成「快速测试」(几分钟)和「完整测试」(可以跑在夜间构建)。
- 环境不一致:本地能过,CI上就挂。最常见的原因是编译器版本不同、依赖库路径不同。用Docker可以解决这个问题。
- 忽略失败的测试:有些人看到CI变红,直接忽略。这是大忌。我见过一个项目,CI红了两个月没人管,最后重构时才发现一堆bug。
27.5 知识体系总览
下面这张图总结了本章的核心内容,你可以把它当作测试质量保障的「地图」:
这张图展示了四个核心维度的关系:单元测试框架是基础,Mock技术帮你隔离外部依赖,覆盖率告诉你测试的「广度」,而持续集成则把这一切自动化。四者缺一不可。
27.6 我的建议
如果你现在才开始在项目里引入测试,我建议你从这三步开始:
- 先搭gtest环境,写一个能跑的测试。哪怕只是测试一个加法函数。先让流程跑通。
- 给最核心的模块加Mock。比如网络、数据库、文件操作。这些地方最容易出问题,也最适合用Mock。
- 配置CI,让测试自动运行。别等到项目大了再搞,那时候改起来成本高得多。
好了,这一章的内容就到这里。测试这件事,说白了就是「花时间省时间」。前期多花点心思写测试,后期就能少熬夜查bug。你想想看,是不是这个理?