20、单元测试与测试驱动:Google Test框架、Mock对象、覆盖率测试、测试驱动开发(TDD)实践
说实话,很多C++程序员对测试的态度挺微妙的。
嘴上说着「测试很重要」,实际项目里却经常是「先跑起来再说」。我以前也这样,觉得写测试浪费时间。直到有一次,我重构一个核心模块,改了不到200行代码,结果线上挂了三天。那三天我几乎没合眼,一行一行地查,最后发现是一个边界条件没处理好。
嗯,从那以后,我再也不敢不写测试了。
为什么单元测试在C++里这么重要?
C++不像Java或者Python那样有官方的测试工具链。它太灵活了——指针、模板、多继承、手动内存管理……这些特性让代码强大,但也让bug藏得更深。你想想看,一个野指针可能跑几个月才暴露出来,到时候你根本不知道是哪次改动引入的。
单元测试的作用,说白了就是给代码上「保险」。每次改动后跑一遍,如果绿了,心里就有底。如果红了,立刻知道哪里出了问题,不用等到线上崩溃。
核心观点:单元测试不是「额外工作」,而是「节省时间」的手段。越早发现bug,修复成本越低。
Google Test 框架入门
Google Test(简称gtest)是目前C++社区最流行的单元测试框架。我个人的习惯是,新项目一开始就把gtest集成进去,哪怕只写一个空测试用例。为什么?因为等代码写到一万行再想加测试,那成本就高了。
基本用法
先看一个最简单的例子:
#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()宏定义了一个测试用例,第一个参数是测试套件名,第二个是测试名。EXPECT_EQ是断言宏,如果失败会继续执行后面的代码。还有ASSERT_EQ,失败会直接终止当前测试。- 我建议用
EXPECT_*系列,因为一个测试用例里可能有多个检查点,你希望看到所有失败的地方,而不是卡在第一个。
小技巧:测试用例的名字要能反映测试意图。比如 AddTest.PositiveNumbers 一看就知道是在测正数相加。别用 AddTest.Test1 这种名字,三个月后你自己都看不懂。
测试夹具(Test Fixture)
当多个测试用例需要共享相同的初始化代码时,可以用 TEST_F:
class MyQueueTest : public ::testing::Test {
protected:
void SetUp() override {
// 每个测试用例执行前调用
q_.push(1);
q_.push(2);
}
void TearDown() override {
// 每个测试用例执行后调用
// 一般用来清理资源
}
std::queue<int> q_;
};
TEST_F(MyQueueTest, PopWorks) {
q_.pop();
EXPECT_EQ(q_.size(), 1);
}
TEST_F(MyQueueTest, FrontWorks) {
EXPECT_EQ(q_.front(), 1);
}
我在项目中遇到过一个问题:某个测试用例因为共享状态导致偶发失败。后来发现是 SetUp 里忘了重置全局变量。所以记住——每个测试用例之间应该是隔离的,不要依赖执行顺序。
Mock对象:隔离外部依赖
单元测试的核心原则是「只测当前单元」。如果你的函数调用了数据库、网络或者文件系统,那测试就变得不可控了。这时候就需要 Mock 对象。
Google Mock 是 gtest 配套的 mock 框架。它的思路很简单:你定义一个接口,然后生成一个 mock 类,指定每个方法被调用时的行为。
一个例子
// 接口定义
class Database {
public:
virtual ~Database() = default;
virtual bool Connect(const std::string& url) = 0;
virtual std::string Query(const std::string& sql) = 0;
};
// Mock类
class MockDatabase : public Database {
public:
MOCK_METHOD(bool, Connect, (const std::string& url), (override));
MOCK_METHOD(std::string, Query, (const std::string& sql), (override));
};
// 被测函数
class UserService {
public:
explicit UserService(Database* db) : db_(db) {}
std::string GetUserName(int id) {
auto result = db_->Query("SELECT name FROM users WHERE id=" + std::to_string(id));
return result.empty() ? "unknown" : result;
}
private:
Database* db_;
};
// 测试
TEST(UserServiceTest, GetUserNameReturnsName) {
MockDatabase mock_db;
EXPECT_CALL(mock_db, Query("SELECT name FROM users WHERE id=1"))
.WillOnce(::testing::Return("Alice"));
UserService service(&mock_db);
EXPECT_EQ(service.GetUserName(1), "Alice");
}
这里 EXPECT_CALL 是关键:它告诉 mock 对象「你期望被调用一次,参数是某个SQL,返回 'Alice'」。如果实际调用次数或参数不匹配,测试会失败。
避坑指南:我曾经在 mock 时忘了设置 .WillOnce(),结果 mock 方法返回了默认值(比如空字符串),测试通过了但逻辑是错的。所以记得——每个 EXPECT_CALL 都要配上返回值,除非你只关心调用次数。
覆盖率测试:你测够了吗?
覆盖率是个双刃剑。太低肯定不行,说明很多代码没测到。但追求100%也没必要——有些代码(比如日志打印、错误处理分支)测起来成本高收益低。
常用的覆盖率工具有 gcov 和 lcov。用法很简单:
# 编译时加 -coverage 选项
g++ -coverage -o test_program test.cpp
# 运行测试
./test_program
# 生成覆盖率报告
gcov test.cpp
lcov --capture --directory . --output-file coverage.info
genhtml coverage.info --output-directory coverage_report
我个人习惯关注三个指标:
- 行覆盖率:代码中有多少行被执行了。低于80%要警惕。
- 分支覆盖率:if/else、switch 等分支是否都走到了。这个比行覆盖率更重要。
- 函数覆盖率:所有函数是否都被调用过。没被调用的函数可能是死代码。
| 覆盖率类型 | 建议目标 | 说明 |
|---|---|---|
| 行覆盖率 | ≥ 80% | 核心逻辑应更高 |
| 分支覆盖率 | ≥ 70% | 条件判断容易藏bug |
| 函数覆盖率 | ≥ 90% | 未覆盖函数可能是死代码 |
我的经验:别把覆盖率当成KPI。我见过团队为了凑覆盖率,写了一堆「测了等于没测」的测试用例。比如只测了正常路径,异常路径全跳过。这样覆盖率数字好看,但bug该出还是出。
测试驱动开发(TDD)实践
TDD 的流程很简单:红 → 绿 → 重构。
- 红:先写一个会失败的测试用例。
- 绿:写最少的代码让测试通过。
- 重构:优化代码,同时保证测试仍然是绿的。
听起来很简单对吧?但实际做起来,很多人会卡在第一步——「我还没写代码呢,怎么知道测什么?」
我的建议是:从接口开始想。比如你要实现一个排序函数,先想好它的签名:
std::vector<int> SortNumbers(const std::vector<int>& input);
然后写测试:
TEST(SortNumbersTest, EmptyInput) {
EXPECT_TRUE(SortNumbers({}).empty());
}
TEST(SortNumbersTest, SingleElement) {
auto result = SortNumbers({42});
EXPECT_EQ(result.size(), 1);
EXPECT_EQ(result[0], 42);
}
TEST(SortNumbersTest, AlreadySorted) {
auto result = SortNumbers({1, 2, 3});
EXPECT_EQ(result, (std::vector<int>{1, 2, 3}));
}
TEST(SortNumbersTest, Unsorted) {
auto result = SortNumbers({3, 1, 2});
EXPECT_EQ(result, (std::vector<int>{1, 2, 3}));
}
这时候测试肯定是红的(因为你还没实现 SortNumbers)。然后你写一个最简单的实现:
std::vector<int> SortNumbers(const std::vector<int>& input) {
auto result = input;
std::sort(result.begin(), result.end());
return result;
}
测试变绿。然后你可以考虑重构——比如换成更高效的排序算法,或者支持自定义比较函数。只要测试还是绿的,你就知道没改坏。
关键点:TDD 的核心不是「先写测试」,而是「用测试驱动设计」。当你先想测试用例时,你会自然地写出更模块化、更易测试的代码。我见过太多人把代码写成一坨,然后发现「这没法测啊」——这就是典型的没有 TDD 思维。
知识体系总览
下面这张图总结了本章的核心内容:
写在最后
单元测试和TDD,说白了是一种「投资」。前期多花点时间写测试,后期就能少花几倍的时间修bug。我见过太多项目,前期赶进度不写测试,结果到了后期,改一行代码要测半天,反而更慢。
所以我的建议是:从今天开始,每个新功能都先写测试。哪怕只写一个简单的 EXPECT_TRUE,也比不写强。慢慢你会发现,测试不再是负担,而是你重构代码时的「安全网」。
最后一个小建议:别追求完美。测试覆盖率从0%到80%很容易,但从80%到100%可能要多花一倍的时间。把精力放在核心逻辑和容易出bug的地方,比追求数字好看更重要。
公众号:蓝海资料掘金营,微信deep3321