1. 项目概述:为什么我们需要Gtest和GMock?
在C/C++项目里摸爬滚打十几年,我见过太多因为测试不充分而导致的深夜加班和线上事故。单元测试,尤其是对复杂依赖模块的测试,一直是C++开发中的一个痛点。你写了一个功能模块A,它依赖模块B,而模块B又依赖一个外部数据库或者网络服务。为了测试A,难道每次都要把B、数据库、网络服务全部搭起来吗?这显然不现实,测试会变得极其缓慢、脆弱且难以维护。
这就是Google Test(Gtest)和Google Mock(GMock)这对黄金搭档大显身手的地方。Gtest是一个强大的C++单元测试框架,它提供了丰富的断言宏、测试固件和死亡测试等功能,帮你组织和管理测试用例。而GMock,则是专门用来解决“依赖”这个难题的。它允许你创建“模拟对象”(Mock Object)来替代那些真实但笨重、不稳定或难以构造的依赖项。你可以精确地设定这个模拟对象的行为:某个方法应该被调用几次?调用时传入什么参数?它应该返回什么值?甚至调用顺序是否符合预期?GMock都能帮你验证。
简单来说,Gtest负责定义“做什么测试”和“验证什么结果”,而GMock则负责在测试环境中“扮演”那些不配合的依赖角色,让你能孤立地、快速地对目标代码进行测试。这次,我们就从一个最简单的示例入手,手把手带你跑通Gtest + GMock的整个流程,让你理解其核心思想,并能立刻在自己的项目中用起来。
2. 环境准备与项目搭建
2.1 依赖安装:获取Gtest/GMock库
首先,我们需要把Gtest和GMock的库弄到本地。现在最推荐的方式是使用CMake的FetchContent模块,它能直接从Git仓库拉取代码并集成到你的构建系统中,无需手动下载编译,干净又省事。
假设我们有一个最简单的CMake项目,目录结构如下:
my_gtest_project/ ├── CMakeLists.txt ├── include/ │ └── calculator.h ├── src/ │ ├── calculator.cpp │ └── main.cpp └── tests/ ├── CMakeLists.txt └── calculator_test.cpp主CMakeLists.txt负责定义项目、添加子目录。我们重点看tests/CMakeLists.txt,这里会集成Gtest和GMock。
# tests/CMakeLists.txt cmake_minimum_required(VERSION 3.14) project(MyProjectTests) # 使用FetchContent获取googletest (包含gtest和gmock) include(FetchContent) FetchContent_Declare( googletest GIT_REPOSITORY https://github.com/google/googletest.git GIT_TAG release-1.12.1 # 建议使用稳定的发布版本 ) FetchContent_MakeAvailable(googletest) # 将测试目录添加到头文件搜索路径 include_directories(${CMAKE_SOURCE_DIR}/include) # 添加你的被测代码库(这里假设主项目已将源码编译为`my_lib`) # 如果源码简单,也可以直接添加源文件 add_library(my_lib STATIC ../src/calculator.cpp) # 或者直接链接主项目生成的库 target_link_libraries(calculator_test my_lib) # 创建测试可执行文件 add_executable(calculator_test calculator_test.cpp) # 链接GTest、GMock以及你的库 target_link_libraries(calculator_test PRIVATE my_lib GTest::gtest_main GTest::gmock ) # 将测试用例注册到CTest include(GoogleTest) gtest_discover_tests(calculator_test)注意:
GTest::gtest_main这个target会自动提供一个main()函数,所以你不需要在自己的测试文件中写main。如果你需要自定义main函数(例如进行一些全局的初始化和清理),则应该链接GTest::gtest,并在代码中调用RUN_ALL_TESTS()。
2.2 第一个Gtest测试:验证基础功能
在接触Mock之前,我们先确保Gtest本身能工作。假设我们有一个超级简单的Calculator类。
include/calculator.h:
#ifndef CALCULATOR_H #define CALCULATOR_H class Calculator { public: int Add(int a, int b); int Subtract(int a, int b); }; #endifsrc/calculator.cpp:
#include "calculator.h" int Calculator::Add(int a, int b) { return a + b; } int Calculator::Subtract(int a, int b) { return a - b; }现在,我们来写一个朴素的Gtest测试用例。
tests/calculator_test.cpp:
#include <gtest/gtest.h> #include "calculator.h" // 测试套件(Test Suite)名称,通常对应一个类 class CalculatorTest : public ::testing::Test { protected: // 如果需要在每个测试用例前/后执行代码,可以重写SetUp/TearDown void SetUp() override { // 测试前的初始化,例如构造被测对象 } void TearDown() override { // 测试后的清理 } // 测试套件内共享的对象 Calculator calc; }; // 使用TEST_F宏定义属于CalculatorTest套件的测试用例 TEST_F(CalculatorTest, AddPositiveNumbers) { EXPECT_EQ(calc.Add(2, 3), 5); // 断言:期望相等 ASSERT_EQ(calc.Add(0, 0), 0); // ASSERT_* 失败会终止当前测试用例 } TEST_F(CalculatorTest, SubtractPositiveNumbers) { EXPECT_EQ(calc.Subtract(5, 3), 2); EXPECT_NE(calc.Subtract(3, 5), 0); // 断言:期望不相等 EXPECT_LT(calc.Subtract(1, 100), 0); // 断言:期望小于 } // 也可以使用TEST宏,不依赖于测试固件 TEST(CalculatorSimpleTest, AddNegativeNumbers) { Calculator calc; EXPECT_EQ(calc.Add(-1, -1), -2); }编译并运行测试:
cd my_gtest_project mkdir build && cd build cmake .. make ./tests/calculator_test # 或者使用 ctest 命令如果一切顺利,你会看到类似这样的输出:
[==========] Running 3 tests from 2 test suites. [----------] Global test environment set-up. [----------] 2 tests from CalculatorTest [ RUN ] CalculatorTest.AddPositiveNumbers [ OK ] CalculatorTest.AddPositiveNumbers (0 ms) [ RUN ] CalculatorTest.SubtractPositiveNumbers [ OK ] CalculatorTest.SubtractPositiveNumbers (0 ms) [----------] 2 tests from CalculatorTest (0 ms total) [----------] 1 test from CalculatorSimpleTest ... [==========] 3 tests passed.恭喜,你的Gtest环境已经跑通了!但这只是热身,真正的挑战在于测试那些有外部依赖的代码。
3. GMock核心概念与第一个Mock
3.1 场景引入:当测试遇到“讨厌”的依赖
现在,假设我们的Calculator升级了,它不再自己计算,而是依赖一个外部的Adder服务来执行加法。这个Adder可能是一个访问速度很慢的远程服务,或者其状态在测试环境中难以模拟。
include/adder.h:
#ifndef ADDER_H #define ADDER_H class Adder { public: virtual ~Adder() = default; // 虚析构函数,对于多态基类至关重要! virtual int Add(int a, int b) = 0; // 纯虚函数,定义接口 // 可能还有其他方法... }; #endifinclude/advanced_calculator.h:
#ifndef ADVANCED_CALCULATOR_H #define ADVANCED_CALCULATOR_H #include "adder.h" class AdvancedCalculator { public: // 构造函数注入依赖,这是实现可测试性的关键模式 AdvancedCalculator(Adder& adder) : adder_(adder) {} int AddUsingService(int a, int b) { // 一些业务逻辑... int result = adder_.Add(a, b); // ... 更多的业务逻辑,比如记录日志、转换结果等 return result; } private: Adder& adder_; // 持有依赖接口的引用 }; #endif现在问题来了:我们想测试AdvancedCalculator::AddUsingService的业务逻辑是否正确,但我们不想(或不能)在单元测试中启动那个真实的、缓慢的Adder服务实现。这时,Mock就该登场了。
3.2 创建你的第一个Mock类
GMock的核心就是帮你自动生成一个实现了指定接口的Mock类。这个Mock类会记录对其方法的调用,并允许你预设这些调用的行为和返回值。
tests/mock_adder.h:
#ifndef MOCK_ADDER_H #define MOCK_ADDER_H #include <gmock/gmock.h> #include "../include/adder.h" // 引入要模拟的接口 // Mock类名通常以`Mock`为前缀 class MockAdder : public Adder { public: // 使用MOCK_METHOD宏来模拟虚函数。 // 格式:MOCK_METHOD(返回值类型, 方法名, (参数列表), (限定符)); // 对于const方法,限定符为(const, override) // 对于非const方法,限定符为(override) MOCK_METHOD(int, Add, (int a, int b), (override)); }; #endif看,就这么简单!一行MOCK_METHOD就替代了手动实现一个完整的子类。这个宏会在背后生成一大堆代码,帮你处理调用计数、参数匹配、返回值设定等所有脏活累活。
实操心得:
MOCK_METHOD宏的第三个参数是方法的参数列表,必须用括号括起来,即使只有一个参数,如(int a)。第四个参数是限定符,对于要重写的虚函数,务必加上override,这是一个良好的编程习惯,能让编译器帮你检查签名是否正确。如果模拟的是const成员函数,则写(const, override)。
3.3 在测试中使用Mock对象
有了Mock类,我们就可以在测试中创建它的实例,并设定我们对它的“期望”。
tests/advanced_calculator_test.cpp:
#include <gtest/gtest.h> #include <gmock/gmock.h> #include "../include/advanced_calculator.h" #include "mock_adder.h" // 引入Mock类 using ::testing::Return; // 引入常用的Action,如Return // 测试套件 class AdvancedCalculatorTest : public ::testing::Test { protected: // 在SetUp中创建Mock对象和被测对象是一种常见模式 void SetUp() override { // 创建Mock对象 mockAdder = std::make_unique<MockAdder>(); // 创建被测对象,注入Mock依赖 calculator = std::make_unique<AdvancedCalculator>(*mockAdder); } // 使用智能指针管理资源,防止内存泄漏 std::unique_ptr<MockAdder> mockAdder; std::unique_ptr<AdvancedCalculator> calculator; }; TEST_F(AdvancedCalculatorTest, AddUsingService_CallsAdderAndReturnsResult) { // 1. 设定期望(Expectation) // 我们期望mockAdder的Add方法会被调用一次,参数是(10, 20),并且返回30。 EXPECT_CALL(*mockAdder, Add(10, 20)) .Times(1) // 期望被调用1次 (可以省略,因为默认是Times(1)) .WillOnce(Return(30)); // 当被调用时,返回30 // 2. 执行被测代码(Exercise) int result = calculator->AddUsingService(10, 20); // 3. 验证结果(Verify) // GTest断言验证返回值 EXPECT_EQ(result, 30); // 4. Mock对象的验证在析构时自动进行! // 当测试结束,mockAdder析构时,GMock会检查Add(10,20)是否被恰好调用了一次。 // 如果没有调用,或调用了多次,或参数不对,测试都会失败。 }编译并运行这个测试。如果AdvancedCalculator正确调用了mockAdder->Add(10,20),测试就会通过。如果AddUsingService内部调用了两次Add,或者参数传成了(10, 30),GMock就会在测试输出中给出清晰的错误信息,告诉你期望和实际的差异。
这就是GMock最基本的工作流程:设定期望 -> 执行代码 -> 自动验证。它把测试的重点从“依赖能否工作”转移到了“我的代码是否正确使用了依赖”。
4. 深入GMock:期望、匹配器与行为
4.1 设定精确的调用期望
EXPECT_CALL是GMock的灵魂。它的完整形式提供了强大的控制力。
EXPECT_CALL(mock_object, Method(argument_matchers)) .Times(cardinality) // 调用次数 .InSequence(sequences...) // 调用顺序 .WillOnce(action) // 第一次调用的行为 .WillRepeatedly(action) // 后续所有调用的行为 .RetiresOnSaturation(); // 达到调用次数后,此期望“退休”调用次数(Times):
.Times(3):精确调用3次。.Times(AtLeast(2)):至少调用2次。.Times(AtMost(5)):最多调用5次。.Times(AnyNumber()):调用任意次数,包括0次。常用于设置“默认”或“不关心”的期望。.Times(0):明确禁止调用。如果被调用,测试失败。
调用顺序(InSequence): 默认情况下,GMock不关心多个期望之间的调用顺序。但你可以通过InSequence对象来强制要求顺序。
TEST_F(AdvancedCalculatorTest, OperationsInSequence) { // 创建一个序列对象 testing::InSequence seq; // 在seq作用域内声明的期望,必须按声明顺序发生 EXPECT_CALL(*mockAdder, Add(1, 1)).WillOnce(Return(2)); EXPECT_CALL(*mockAdder, Add(2, 2)).WillOnce(Return(4)); // 如果先调用Add(2,2),测试会立即失败 // 假设Calculator有一个新方法,会按顺序调用两次Add // calculator->DoSequentialOperations(); }4.2 参数匹配器:让期望更灵活
你不可能总是知道调用依赖时的精确参数。GMock提供了丰富的参数匹配器来灵活设定期望。
using ::testing::_; // 通配符,匹配任何值 using ::testing::Ge; // 大于等于 using ::testing::Lt; // 小于 using ::testing::NotNull; // 指针非空 using ::testing::StartsWith; // 字符串以...开头 // 示例 EXPECT_CALL(*mockAdder, Add(_, _)); // 接受任意两个int参数 EXPECT_CALL(*mockAdder, Add(Ge(0), Lt(100))); // 第一个参数>=0,第二个参数<100 EXPECT_CALL(*mockAdder, Add(10, _)).Times(2); // 第一个参数必须是10,第二个任意,且调用两次对于非重载函数,你甚至可以省略参数列表,表示不关心任何参数:
EXPECT_CALL(*mockAdder, Add); // 匹配对Add方法的任何调用,无论参数是什么注意事项:匹配器是GMock中非常强大的功能,但滥用会导致测试过于宽松,掩盖bug。一个好的原则是:只匹配你真正关心的参数。例如,如果你测试一个发送邮件的函数,你关心收件人邮箱是否正确,但可能不关心邮件内容的每一个字。这时可以对邮箱参数使用
Eq("user@example.com"),对内容参数使用_。
4.3 指定Mock行为:WillOnce与WillRepeatedly
.WillOnce()和.WillRepeatedly()用于定义当Mock方法被调用时的行为,最常用的行为是返回一个值。
using ::testing::Return; using ::testing::ReturnRef; EXPECT_CALL(*mockAdder, Add(_, _)) .WillOnce(Return(10)) // 第一次调用返回10 .WillOnce(Return(20)) // 第二次调用返回20 .WillRepeatedly(Return(30)); // 第三次及以后的所有调用都返回30 // 调用顺序:第一次返回10,第二次返回20,第三次返回30,第四次返回30...重要陷阱:EXPECT_CALL中的行为子句(如Return(n++))只在设置期望时求值一次。
int value = 100; // 错误示例:你以为每次调用会返回100, 101, 102... EXPECT_CALL(*mockAdder, Add(_, _)) .Times(3) .WillRepeatedly(Return(value++)); // 错误!value++只在设置期望时执行一次,后续调用都返回100。 // 正确做法:使用多个WillOnce EXPECT_CALL(*mockAdder, Add(_, _)) .WillOnce(Return(100)) .WillOnce(Return(101)) .WillOnce(Return(102));除了Return,GMock还内置了其他行为:
ReturnRef(variable):返回变量的引用。SetArgReferee<N>(value):将第N个引用参数设置为指定值。SetArgPointee<N>(value):将第N个指针参数指向的值设置为指定值。Invoke(function):调用一个普通函数、函数对象或lambda。
使用Lambda实现复杂行为:
EXPECT_CALL(*mockAdder, Add(_, _)) .WillOnce([](int a, int b) -> int { // 可以在这里添加日志、条件判断等 std::cout << "Mock Add called with " << a << ", " << b << std::endl; if (a < 0 || b < 0) { return -1; // 模拟错误情况 } return a + b; });5. 实战:测试一个更复杂的场景
让我们构建一个更贴近现实的例子:一个UserManager类,它依赖一个UserRepository接口来持久化用户数据,依赖一个EmailService接口来发送邮件。
5.1 定义接口与Mock
interfaces/user_repository.h:
class UserRepository { public: virtual ~UserRepository() = default; virtual bool SaveUser(const std::string& username, const std::string& email) = 0; virtual std::optional<std::string> FindEmailByUsername(const std::string& username) = 0; };interfaces/email_service.h:
class EmailService { public: virtual ~EmailService() = default; virtual bool SendWelcomeEmail(const std::string& to) = 0; };tests/mocks.h:
#include <gmock/gmock.h> #include "../interfaces/user_repository.h" #include "../interfaces/email_service.h" class MockUserRepository : public UserRepository { public: MOCK_METHOD(bool, SaveUser, (const std::string& username, const std::string& email), (override)); MOCK_METHOD(std::optional<std::string>, FindEmailByUsername, (const std::string& username), (override)); }; class MockEmailService : public EmailService { public: MOCK_METHOD(bool, SendWelcomeEmail, (const std::string& to), (override)); };5.2 实现被测业务逻辑
user_manager.h / .cpp:
#include "interfaces/user_repository.h" #include "interfaces/email_service.h" class UserManager { public: UserManager(UserRepository& repo, EmailService& emailService) : repo_(repo), emailService_(emailService) {} // 业务逻辑:注册用户。先保存,如果成功则发送欢迎邮件。 bool RegisterUser(const std::string& username, const std::string& email) { if (username.empty() || email.empty()) { return false; } bool saveOk = repo_.SaveUser(username, email); if (!saveOk) { // 保存失败,记录日志等... return false; } bool emailOk = emailService_.SendWelcomeEmail(email); // 即使邮件发送失败,用户也算注册成功了(可能记录告警) // 这里我们简单返回true,但实际可能记录日志。 return true; } // 另一个业务:通过用户名查找邮箱 std::optional<std::string> GetUserEmail(const std::string& username) { return repo_.FindEmailByUsername(username); } private: UserRepository& repo_; EmailService& emailService_; };5.3 编写完整的单元测试
tests/user_manager_test.cpp:
#include <gtest/gtest.h> #include <gmock/gmock.h> #include "../user_manager.h" #include "mocks.h" using ::testing::Return; using ::testing::_; // 通配符 using ::testing::NiceMock; // 稍后解释 using ::testing::StrictMock; class UserManagerTest : public ::testing::Test { protected: void SetUp() override { // 使用NiceMock包装,避免对“不感兴趣”的调用发出警告(后面会讲) mockRepo = std::make_unique<NiceMock<MockUserRepository>>(); mockEmail = std::make_unique<NiceMock<MockEmailService>>(); userManager = std::make_unique<UserManager>(*mockRepo, *mockEmail); } std::unique_ptr<MockUserRepository> mockRepo; std::unique_ptr<MockEmailService> mockEmail; std::unique_ptr<UserManager> userManager; }; TEST_F(UserManagerTest, RegisterUser_Success_SavesUserAndSendsEmail) { const std::string username = "testuser"; const std::string email = "test@example.com"; // 设定对依赖的期望 // 期望SaveUser被调用一次,参数匹配,并返回true EXPECT_CALL(*mockRepo, SaveUser(username, email)) .WillOnce(Return(true)); // 期望SendWelcomeEmail被调用一次,参数匹配,并返回true EXPECT_CALL(*mockEmail, SendWelcomeEmail(email)) .WillOnce(Return(true)); // 执行 bool result = userManager->RegisterUser(username, email); // 验证 EXPECT_TRUE(result); // Mock的验证在析构时自动进行 } TEST_F(UserManagerTest, RegisterUser_SaveFails_ReturnsFalseAndNoEmailSent) { const std::string username = "testuser"; const std::string email = "test@example.com"; // 期望SaveUser被调用,并返回false EXPECT_CALL(*mockRepo, SaveUser(username, email)) .WillOnce(Return(false)); // 关键:期望SendWelcomeEmail NEVER被调用,因为保存失败了 EXPECT_CALL(*mockEmail, SendWelcomeEmail(_)) .Times(0); // 明确禁止调用 bool result = userManager->RegisterUser(username, email); EXPECT_FALSE(result); } TEST_F(UserManagerTest, RegisterUser_InvalidInput_ReturnsFalseAndNoCalls) { // 输入为空,业务逻辑应直接返回false,不调用任何依赖 EXPECT_CALL(*mockRepo, SaveUser(_, _)).Times(0); EXPECT_CALL(*mockEmail, SendWelcomeEmail(_)).Times(0); bool result = userManager->RegisterUser("", "valid@email.com"); EXPECT_FALSE(result); result = userManager->RegisterUser("validuser", ""); EXPECT_FALSE(result); } TEST_F(UserManagerTest, GetUserEmail_DelegatesToRepository) { const std::string username = "alice"; const std::string expectedEmail = "alice@example.com"; // 模拟Repository返回一个邮箱 EXPECT_CALL(*mockRepo, FindEmailByUsername(username)) .WillOnce(Return(std::optional<std::string>(expectedEmail))); auto result = userManager->GetUserEmail(username); ASSERT_TRUE(result.has_value()); // 断言有值 EXPECT_EQ(*result, expectedEmail); } TEST_F(UserManagerTest, GetUserEmail_RepositoryReturnsEmpty_ReturnsEmpty) { const std::string username = "unknown"; // 模拟Repository返回空(std::nullopt) EXPECT_CALL(*mockRepo, FindEmailByUsername(username)) .WillOnce(Return(std::optional<std::string>())); // 或 Return(std::nullopt) auto result = userManager->GetUserEmail(username); EXPECT_FALSE(result.has_value()); }这个测试套件展示了如何测试一个具有多个依赖、分支逻辑(成功、失败、无效输入)的业务类。每个测试用例都清晰地设定了Mock对象的预期行为,从而孤立地验证了UserManager在各种场景下的逻辑是否正确。
6. 高级技巧与避坑指南
6.1 NiceMock, StrictMock 与 NaggyMock
默认情况下,GMock对于你没有设置任何期望的调用(即“不感兴趣”的调用),会发出警告(“uninteresting call”),但不会导致测试失败。这种行为称为Naggy。
有时这些警告是噪音,特别是当你Mock的接口有很多方法,而你的测试只关心其中一两个时。这时可以使用NiceMock来包装你的Mock对象,它会静默处理所有不感兴趣的调用。
// 在SetUp或测试用例中 NiceMock<MockUserRepository> niceRepo; // 不感兴趣的调用不会产生警告 StrictMock<MockUserRepository> strictRepo; // 任何不感兴趣的调用都会导致测试失败 MockUserRepository naggyRepo; // 默认行为,警告但不失败 // 通常建议在测试固件中使用NiceMock,减少噪音 void SetUp() override { mockRepo = std::make_unique<NiceMock<MockUserRepository>>(); }如何选择?
NiceMock:默认推荐。让测试专注于你真正关心的交互,减少干扰。- 默认(Naggy):折中方案,能看到警告,有助于发现测试遗漏的交互。
StrictMock:最严格。适用于需要精确控制所有依赖调用的场景,但会让测试变得脆弱(对代码重构敏感)。
6.2 期望的“粘性”与作用域
GMock的期望默认是“粘性”的。这意味着,即使一个期望达到了其设定的调用次数(例如.Times(2)),它仍然保持活动状态。如果后续又有匹配的调用,GMock会继续用这个“已饱和”的期望去匹配,并报告“调用次数超过上限”的错误。
EXPECT_CALL(mock, Foo(_)).Times(2); mock.Foo(1); // 第一次调用,匹配 mock.Foo(1); // 第二次调用,匹配,期望饱和 mock.Foo(1); // 第三次调用!仍然匹配这个“粘性”期望,导致测试失败。RetiresOnSaturation():让期望在达到调用次数后“退休”,不再参与后续的匹配。
EXPECT_CALL(mock, Foo(_)) .Times(2) .RetiresOnSaturation(); mock.Foo(1); // 匹配 mock.Foo(1); // 匹配,期望饱和并退休 mock.Foo(1); // 没有活跃的期望与之匹配,如果这是不感兴趣的调用,NiceMock会忽略,Naggy会警告,StrictMock会失败。作用域:期望的生命周期与其所在的EXPECT_CALL语句的作用域绑定。通常,在测试函数开始处设置的期望,会持续到测试函数结束(或Mock对象析构)。你可以利用局部作用域来限制期望的有效期。
{ // 在这个作用域内,期望SaveUser被调用 EXPECT_CALL(*mockRepo, SaveUser(_, _)).WillOnce(Return(true)); userManager->DoSomethingThatShouldSave(); // 这个调用应该触发SaveUser } // 作用域结束,这个期望不再有效 // 之后的代码如果再调用SaveUser,就不会匹配到这个期望了。6.3 模拟重载函数、模板函数和非虚函数
重载函数:需要指定参数类型来消除歧义。
class MyInterface { public: virtual int Process(int a) = 0; virtual int Process(double a) = 0; // 重载 }; class MockMyInterface : public MyInterface { public: MOCK_METHOD(int, Process, (int a), (override)); MOCK_METHOD(int, Process, (double a), (override)); // 两个都要模拟 };模板函数:GMock不能直接模拟模板函数。通常的解决方案是将模板函数的功能委托给一个虚函数,然后模拟这个虚函数。
非虚函数:模拟非虚函数非常棘手,通常需要修改设计(例如提取接口)或使用更高级的“接缝”技术(如链接期替换)。对于普通单元测试,强烈建议依赖抽象接口而非具体类,这是可测试性设计的核心原则。
6.4 常见问题排查
“Actual function call count doesn‘t match”:这是最常见的错误。意味着你设定的调用次数期望(如
.Times(2))与实际调用次数不符。仔细检查你的业务逻辑,看是否漏掉了调用,或者条件分支导致调用未发生。“Unexpected mock function call”:你调用了一个Mock方法,但没有匹配的、活跃的
EXPECT_CALL。可能的原因:- 你忘记为这个调用设置期望。
- 你设置的期望参数太严格(例如期望
Add(10, 20),实际调用是Add(10, 30))。 - 你设置的期望已经“退休”(
RetiresOnSaturation)或不在作用域内。
“Uninteresting mock function call”警告:你调用了Mock对象的一个方法,但从未用
EXPECT_CALL声明过对它的兴趣。如果你确实不关心这个调用,使用NiceMock来包装Mock对象。如果你发现一个应该被测试覆盖的调用产生了这个警告,说明你的测试用例不完整,需要补充期望。Mock对象在测试结束后才被验证:GMock的验证发生在Mock对象析构时。确保Mock对象在测试函数结束前被销毁。通常使用局部变量或测试固件中的成员变量(在
TearDown中清理)即可。如果使用动态分配,务必确保删除。在多线程测试中使用GMock:GMock本身不是线程安全的。如果你在多个线程中调用同一个Mock对象,需要自己加锁。更好的做法是,将并发逻辑封装好,在单元测试中只测试单线程下的正确性,并发问题通过其他方式(如压力测试)来验证。
7. 集成到开发流程与最佳实践
7.1 将测试集成到CMake和CI/CD
一个健康的项目应该能做到一键运行所有测试。在CMake中,我们之前已经使用了gtest_discover_tests,它会自动将测试可执行文件中的用例注册为CTest测试。
在项目根目录的CMakeLists.txt中,可以启用测试:
enable_testing() # 启用CTest add_subdirectory(tests) # 添加测试子目录然后,在构建后,可以使用以下命令运行测试:
cd build ctest # 运行所有测试 ctest -V # 运行所有测试并显示详细输出 ctest -R CalculatorTest # 运行名称匹配*CalculatorTest*的测试 ctest --output-on-failure # 如果测试失败,打印其输出在CI/CD流水线(如GitHub Actions, GitLab CI, Jenkins)中,将ctest或直接运行测试可执行文件作为构建步骤之一,确保每次代码提交都经过测试验证。
7.2 测试固件共享与数据驱动测试
对于多个测试用例需要相同设置的情况,使用TEST_F和测试固件类。你可以在固件类的SetUp中初始化公共资源,在TearDown中清理。
对于需要用不同输入数据运行相同测试逻辑的情况,可以使用TEST_P(参数化测试)。
class CalculatorParamTest : public ::testing::TestWithParam<std::tuple<int, int, int>> { protected: Calculator calc; }; TEST_P(CalculatorParamTest, Add) { int a = std::get<0>(GetParam()); int b = std::get<1>(GetParam()); int expected = std::get<2>(GetParam()); EXPECT_EQ(calc.Add(a, b), expected); } INSTANTIATE_TEST_SUITE_P( VariousAdditions, CalculatorParamTest, ::testing::Values( std::make_tuple(1, 2, 3), std::make_tuple(-1, 1, 0), std::make_tuple(0, 0, 0), std::make_tuple(100, 200, 300) ) );7.3 测试设计原则
FIRST原则:
- Fast:测试要快。使用Mock的核心目的之一就是让单元测试快速运行。
- Independent:测试用例之间应该相互独立,不依赖执行顺序。使用GMock时,确保每个测试用例都创建新的Mock对象。
- Repeatable:测试在任何环境下都应该能重复通过。
- Self-Validating:测试应该能自动判断通过与否,无需人工检查。
- Timely:测试应该与产品代码同时编写。
只测试一件事:一个测试用例应该只验证一个行为或一个场景。如果测试失败,你应该能立刻知道是哪个功能出了问题。
使用描述性的测试名:
TEST_F(CalculatorTest, Add_TwoPositiveNumbers_ReturnsSum)比TEST_F(CalculatorTest, Test1)要好得多。测试行为,而非实现:你的测试应该关注“这个类做了什么”,而不是“它怎么做的”。过度使用GMock来验证内部调用的具体细节(例如“必须调用SaveUser三次”),会导致测试变得脆弱,一旦重构内部实现,即使外部行为不变,测试也会失败。应该验证最终状态或输出。
合理使用Mock:不要过度Mock。如果一个依赖很简单、很快、很稳定(比如一个纯粹的计算工具类),直接使用真实对象可能更好。Mock主要用于那些有副作用、速度慢、不稳定的依赖(如数据库、网络、文件系统、复杂第三方库)。
从简单的断言开始,到使用Mock隔离依赖,再到组织复杂的测试套件,Gtest和GMock为C++单元测试提供了一套强大而灵活的工具。记住,工具是手段,写出可靠、可维护的代码才是目的。良好的测试习惯,是通往高质量软件工程的必经之路。