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 宏的第一个参数是测试套件名,第二个是测试用例名。我个人习惯用「模块名+场景」来命名套件,比如 AddTestUserServiceTest。这样跑测试时,一眼就能看出哪个模块出了问题。

小技巧: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 实例,成员变量是独立的。

避坑指南: 我曾经在 Fixture 的 SetUp 里申请了资源,但忘记在 TearDown 里释放。结果跑完几千个测试后,内存直接爆了。后来我养成了习惯:SetUp 和 TearDown 成对写,就像 new 和 delete 一样。

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,不用改任何逻辑。

核心要点: 参数化测试适合「逻辑相同、输入不同」的场景。如果每个测试用例的逻辑都不一样,那就老老实实用 TEST_F。

5.4 Mocking 与依赖注入:把外部依赖「关在门外」

写单元测试时,最头疼的就是外部依赖。比如你的代码要调用一个支付接口、要读写数据库、要发 HTTP 请求。这些依赖在测试环境里要么不可用,要么太慢,要么有副作用(比如真的扣了钱)。

怎么办?Mock。说白了,就是造一个假的依赖对象,让它返回你期望的结果。

GTest 自带的 mocking 库叫 Google Mock(现在已合并到 GTest 中)。用法分三步:

  1. 定义接口(纯虚类)
  2. 用 MOCK_METHOD 宏生成 mock 类
  3. 在测试中设置期望行为

看个例子。假设你有一个支付服务:

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。如果实际调用次数、参数和期望不符,测试就会失败。

个人经验: 我刚开始用 mock 时,喜欢把每个方法都 mock 掉。后来发现这样测试太脆弱了——只要接口改个参数名,测试就崩。现在我更倾向于只 mock 外部 IO 相关的依赖(网络、数据库、文件系统),业务逻辑尽量用真实对象。

5.5 知识体系总览

下面这张图总结了本章的核心内容,你可以把它当作一个快速索引:

Google Test 核心知识体系 基础用法 TEST / EXPECT_* / ASSERT_* 测试夹具 TEST_F / SetUp / TearDown 参数化测试 TestWithParam / Values Mocking MOCK_METHOD / EXPECT_CALL 常用断言 EXPECT_EQ / EXPECT_NE EXPECT_TRUE / EXPECT_FALSE ASSERT_EQ / ASSERT_NE ASSERT_THROW / EXPECT_THROW 生命周期 SetUp():每个用例前执行 TearDown():每个用例后执行 SetUpTestSuite():套件前 TearDownTestSuite():套件后 参数生成器 Values(a, b, c) Range(begin, end) Combine(g1, g2) Bool() / IsEmpty() 匹配器与动作 Times(n) / AtLeast(n) WillOnce / WillRepeatedly Return(v) / Throw(e) DoAll(a1, a2) 核心原则:测试应该快速、独立、可重复 不依赖外部环境 | 不依赖测试顺序 | 不依赖共享状态 依赖注入是 Mock 的基础:通过接口传递依赖,测试时替换为 Mock 对象 参数化测试 + Mock 结合使用,可以大幅减少测试代码量

5.6 写在最后

单元测试这件事,说白了就是「用代码来验证代码」。刚开始写的时候会觉得麻烦,但一旦养成习惯,你会发现它带来的安全感是无可替代的。我现在的习惯是:每写一个函数,先写它的测试。哪怕这个函数只有三行。

GTest 的功能远不止这些,比如还有死亡测试、类型参数化测试、事件监听器等。但今天讲的这四个——基础用法、测试夹具、参数化测试、Mocking——已经能覆盖你日常 90% 的需求了。先把这些用熟,其他的遇到再学也不迟。


公众号:蓝海资料掘金营,微信 deep3321