5、单元测试框架:Google Test 入门,测试夹具与参数化测试,Mocking 与依赖注入
单元测试这件事,说实话,我入行头三年基本没当回事。那时候觉得「代码能跑就行」,测试是测试工程师的事。直到有一次,我负责的一个支付模块,改了底层一个工具函数,结果线上出了好几笔重复扣款……嗯,从那以后,我再也不敢不写单元测试了。
今天咱们聊 Google Test,简称 GTest。这是 C++ 社区最主流的测试框架,没有之一。你想想看,Chromium、LLVM、OpenCV 这些巨无霸项目都在用,说明它经得起考验。
5.1 快速上手:第一个测试用例
先来个最简单的。假设你有一个加法函数:
int Add(int a, int b) {
return a + b;
}
用 GTest 怎么写测试?看下面:
#include <gtest/gtest.h>
TEST(AddTest, PositiveNumbers) {
EXPECT_EQ(Add(2, 3), 5);
}
TEST(AddTest, NegativeNumbers) {
EXPECT_EQ(Add(-1, -2), -3);
}
int main(int argc, char **argv) {
::testing::InitGoogleTest(&argc, argv);
return RUN_ALL_TESTS();
}
TEST 宏的第一个参数是测试套件名,第二个是测试用例名。我个人习惯用「模块名+场景」来命名套件,比如 AddTest、UserServiceTest。这样跑测试时,一眼就能看出哪个模块出了问题。
EXPECT_EQ 而不是 ASSERT_EQ。前者失败后还会继续跑后面的断言,后者直接终止当前测试。我一般只在「后续代码完全依赖这个条件」时才用 ASSERT。
5.2 测试夹具(Test Fixture):别重复造轮子
写测试时,经常遇到「每个测试用例都需要创建同样的对象」。比如测试一个数据库连接池,每个用例都要初始化连接、准备数据、最后清理。你如果每个用例都写一遍,那代码就太丑了。
GTest 提供了 Test Fixture 来解决这个问题。说白了,就是定义一个基类,把公共的初始化和清理逻辑放进去。
class DatabaseTest : public ::testing::Test {
protected:
void SetUp() override {
// 每个测试用例开始前执行
conn_ = new DatabaseConnection("localhost", 3306);
conn_->Connect();
}
void TearDown() override {
// 每个测试用例结束后执行
conn_->Disconnect();
delete conn_;
}
DatabaseConnection* conn_;
};
TEST_F(DatabaseTest, CanInsertRecord) {
conn_->Execute("INSERT INTO users VALUES (1, 'alice')");
auto result = conn_->Query("SELECT * FROM users WHERE id=1");
EXPECT_FALSE(result->Empty());
}
TEST_F(DatabaseTest, CanQueryEmptyTable) {
auto result = conn_->Query("SELECT * FROM logs");
EXPECT_TRUE(result->Empty());
}
注意看,这里用的是 TEST_F 而不是 TEST。F 代表 Fixture。我在项目中遇到过一种情况:有人把 Fixture 里的成员变量定义成 static,结果多个测试用例之间互相干扰,查了半天才发现。记住,每个 TEST_F 都会创建一个新的 Fixture 实例,成员变量是独立的。
5.3 参数化测试:一个测试跑遍所有场景
有时候,你需要用不同的输入来测试同一个函数。比如测试一个排序算法,你得试空数组、单元素、已排序、逆序、有重复值……如果每个场景写一个 TEST,那代码就太冗余了。
GTest 的参数化测试就是干这个的。看个例子:
class SortTest : public ::testing::TestWithParam<std::vector<int>> {
};
TEST_P(SortTest, SortsCorrectly) {
auto input = GetParam();
std::sort(input.begin(), input.end());
for (size_t i = 1; i < input.size(); ++i) {
EXPECT_LE(input[i - 1], input[i]);
}
}
INSTANTIATE_TEST_SUITE_P(
SortScenarios,
SortTest,
::testing::Values(
std::vector<int>{},
std::vector<int>{1},
std::vector<int>{3, 1, 2},
std::vector<int>{5, 4, 3, 2, 1},
std::vector<int>{1, 2, 2, 3, 1}
)
);
TestWithParam 是 GTest 提供的模板类,GetParam() 获取当前参数。然后用 INSTANTIATE_TEST_SUITE_P 来注入参数列表。跑测试时,GTest 会自动为每个参数生成一个独立的测试用例。
我个人特别喜欢这个特性。你想想看,如果哪天发现了一个边界值 bug,只需要往参数列表里加一行,就能自动回归测试。不用写新的 TEST,不用改任何逻辑。
5.4 Mocking 与依赖注入:把外部依赖「关在门外」
写单元测试时,最头疼的就是外部依赖。比如你的代码要调用一个支付接口、要读写数据库、要发 HTTP 请求。这些依赖在测试环境里要么不可用,要么太慢,要么有副作用(比如真的扣了钱)。
怎么办?Mock。说白了,就是造一个假的依赖对象,让它返回你期望的结果。
GTest 自带的 mocking 库叫 Google Mock(现在已合并到 GTest 中)。用法分三步:
- 定义接口(纯虚类)
- 用 MOCK_METHOD 宏生成 mock 类
- 在测试中设置期望行为
看个例子。假设你有一个支付服务:
class PaymentGateway {
public:
virtual ~PaymentGateway() = default;
virtual bool Charge(const std::string& userId, double amount) = 0;
};
class MockPaymentGateway : public PaymentGateway {
public:
MOCK_METHOD(bool, Charge, (const std::string&, double), (override));
};
然后你的业务代码依赖这个接口:
class OrderService {
public:
explicit OrderService(PaymentGateway* gateway) : gateway_(gateway) {}
bool PlaceOrder(const std::string& userId, double amount) {
// ... 一些业务逻辑 ...
return gateway_->Charge(userId, amount);
}
private:
PaymentGateway* gateway_;
};
测试时,把 mock 对象注入进去:
TEST(OrderServiceTest, PlaceOrderSuccess) {
MockPaymentGateway mockGateway;
OrderService service(&mockGateway);
EXPECT_CALL(mockGateway, Charge("alice", 100.0))
.Times(1)
.WillOnce(::testing::Return(true));
bool result = service.PlaceOrder("alice", 100.0);
EXPECT_TRUE(result);
}
这里 EXPECT_CALL 设置了期望:Charge 方法应该被调用一次,参数是 "alice" 和 100.0,并且返回 true。如果实际调用次数、参数和期望不符,测试就会失败。
5.5 知识体系总览
下面这张图总结了本章的核心内容,你可以把它当作一个快速索引:
5.6 写在最后
单元测试这件事,说白了就是「用代码来验证代码」。刚开始写的时候会觉得麻烦,但一旦养成习惯,你会发现它带来的安全感是无可替代的。我现在的习惯是:每写一个函数,先写它的测试。哪怕这个函数只有三行。
GTest 的功能远不止这些,比如还有死亡测试、类型参数化测试、事件监听器等。但今天讲的这四个——基础用法、测试夹具、参数化测试、Mocking——已经能覆盖你日常 90% 的需求了。先把这些用熟,其他的遇到再学也不迟。