【可测试性实践】C++单元测试:gtest & gmock
本文通过黄金思维圈来思考引入单元测试带来的价值,并基于C++工程来接入gtest和gmock来完成基础单测和mock场景的使用。写单测的ORI(投入产出比)问题,需要自上而下认可并愿意投入资源写单测需要程序员额外投入时间,并不算做KPI的业绩互联网产品迭代变化快,维护单测成本高国内程序员工程素养参差不齐,单测普及率不高或不知道怎么写存量代码不好测,改造成本较高。
引言
google test是目前C++主流的单元测试框架,本文介绍如何在工程引入gtest和gmock,并提供入门参考示例。根据黄金圈思维我们先思考Why(为什么做),为什么我们要进行单元测试,为什么要引入mock手段来测试代码,然后再来思考How(怎么做),最后思考What(取得了什么效果)。
Why:为什么引入单元测试?
我们先来看测试金字塔,如下图所示:
可以看到从下往上分别是:
- Unit tests:单元测试
- Service/Integration tests:集成测试、端到端测试
- User Interface tests:用户界面测试
越接近代码的测试,速度也就越快,成本也就越低。单元测试就最贴近代码的,在开发过程中执行测试就越容易发现问题。另外覆盖率是测试金字塔的核心,越接近底层的测试覆盖率应该越高,所以我们通常会以代码覆盖率和增量代码覆盖率来佐证单元测试的效果。
我们引入单元测试有两个目标:
- 提升测试速度和降低测试成本
- 提升代码可测试性
但最终的目的只有一个:提升质量。
再来说说为什么引入mock:
- 解决环境依赖的问题(网络、数据库等)
- 更早的实现接口逻辑(在后端服务未准备好之前),减少等待
- 通过模拟真实对象更好的驱动进行代码设计
当然Why层面可以做更多的深入思考,这里主要是抛砖引玉。
How:如何使用gtest & gmock?
示例工程:UnitTestProj
在src/hello_test.cpp
添加以下代码
#include <gtest/gtest.h>
TEST(HelloTest, BasicAssertions) {
// Expect two strings not to be equal.
EXPECT_STRNE("hello", "world");
// Expect equality.
EXPECT_EQ(7 * 6, 42);
}
代码解析:
TEST
宏定义了一个测试用例。HelloTest
是测试套件的名称,可以包含多个测试用例。BasicAssertions
是测试用例的名称,用于描述具体的测试内容。EXPECT_STRNE("hello", "world")
断言两个字符串不相等。EXPECT_EQ(7 * 6, 42)
断言两个数值相等。
在src/test_mock.cpp
添加以下代码
#include <gtest/gtest.h>
#include <gmock/gmock.h>
// 定义一个接口
class MyInterface {
public:
virtual ~MyInterface() = default;
virtual int Foo(int x) = 0;
};
// 使用gmock生成Mock类
class MockMyInterface : public MyInterface {
public:
MOCK_METHOD(int, Foo, (int x), (override));
};
TEST(MockTestSuite, MockTestCase) {
MockMyInterface mock;
EXPECT_CALL(mock, Foo(5)).Times(1).WillOnce(testing::Return(10));
ASSERT_EQ(mock.Foo(5), 10);
}
代码解析:
- 定义了一个纯虚接口
MyInterface
,其中包含一个纯虚函数Foo
,需要在派生类中实现。 - 使用 Google Mock 提供的
MOCK_METHOD
宏生成MockMyInterface
类,该类继承自MyInterface
并实现了Foo
函数。MOCK_METHOD
宏的参数包括返回类型、函数名、参数列表和覆盖说明符(override
)。 TEST(MockTestSuite, MockTestCase)
定义了一个测试用例,属于测试套件MockTestSuite
。- 使用
EXPECT_CALL
宏设置期望的函数调用。在这里,期望mock.Foo(5)
被调用一次,并返回10
。 - 使用
ASSERT_EQ
宏断言mock.Foo(5)
的返回值是否等于10
。
CMake配置示例
cmake_minimum_required(VERSION 3.14)
project(UnitTestProj)
# 设置 C++ 标准
set(CMAKE_CXX_STANDARD 14)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
include(FetchContent)
FetchContent_Declare(
googletest
URL https://github.com/google/googletest/archive/03597a01ee50ed33e9dfd640b249b4be3799d395.zip
)
# For Windows: Prevent overriding the parent project's compiler/linker settings
set(gtest_force_shared_crt ON CACHE BOOL "" FORCE)
FetchContent_MakeAvailable(googletest)
# 启用测试
enable_testing()
# 添加头文件目录
include_directories(${CMAKE_SOURCE_DIR}/src)
# 链接Google Test和Google Mock库
include_directories(${gtest_SOURCE_DIR}/include ${gmock_SOURCE_DIR}/include)
# 添加测试源文件
add_executable(
unit_test_demo
src/hello_test.cpp src/test_mock.cpp
)
# 链接Google Test、Google Mock
target_link_libraries(unit_test_demo gtest gmock gtest_main)
# 包含Google Test的发现测试功能
include(GoogleTest)
# 使用gtest_discover_tests命令自动发现并运行unit_test_demo目标中的所有测试
gtest_discover_tests(unit_test_demo)
运行测试结果
What:单元测试带来什么好处?
当然从这个简单的demo是很难体现单元测试带来的好处的,这里需要用实际的项目数据来进行佐证,但这往往是最难的部分,并且具有一定的滞后性,因为单测短期内对研发一定会带来成本的提升,需要自上而下认可并愿意投入资源去提升。
这里提供一些业界的参考指标,在我们引入单元测试后,尝试使用如下指标来衡量成果:
- 代码覆盖率:使用工具(如 gcov、JaCoCo)生成覆盖率报告,目标是达到 80% 以上的行覆盖率和分支覆盖率。
- 缺陷检测率:统计单元测试发现的缺陷数量和上线后发现的缺陷数量,目标是单元测试发现 70% 以上的缺陷。
- 测试执行时间:确保单元测试套件在 5 分钟内完成执行,以便快速反馈。
- 测试通过率:目标是测试通过率达到 95% 以上,确保代码的稳定性。
- 测试维护成本:定期评估测试用例的维护成本,确保在代码变更时需要较少的修改。
- 覆盖的功能模块:确保所有关键功能模块都有对应的单元测试覆盖。
更多参考
ASSERT_ vs EXPECT_
关于gtest有两种类型的断言,我们在使用的时候可以参考以下对比:
特性 | ASSERT_ 系列断言 | EXPECT_ 系列断言 |
---|---|---|
行为 | 断言失败时立即终止当前测试用例 | 断言失败时继续执行当前测试用例 |
适用场景 | 后续代码依赖于当前断言的结果 | 希望即使断言失败,后续代码仍然执行 |
示例 | ASSERT_EQ(a, b); | EXPECT_EQ(a, b); |
gmock 使用指南
Google Mock(gmock)是 Google Test 的一个扩展库,专门用于创建和使用模拟对象。在进行单元测试时,模拟对象可以用来替代真实对象,从而隔离待测代码和依赖的外部组件。
基本概念
- 模拟类(Mock Class):一个类的模拟实现,使用宏定义来替代实际方法的实现。
- 期望(Expectations):定义模拟对象的预期行为,比如函数调用的次数和返回值。
- 行为(Actions):指定模拟对象在满足期望时应该执行的操作,比如返回特定值或调用真实方法。
常用功能
1. 设置调用次数
Times(n)
:期望函数被调用 n 次。Times(testing::AtLeast(n))
:期望函数被调用至少 n 次。Times(testing::AtMost(n))
:期望函数被调用至多 n 次。
2. 设置返回值
WillOnce(testing::Return(value))
:指定函数一次调用返回value
。WillRepeatedly(testing::Return(value))
:指定函数多次调用返回value
。
3. 参数匹配器
testing::Eq(val)
:匹配等于val
的参数。testing::Ne(val)
:匹配不等于val
的参数。testing::Lt(val)
:匹配小于val
的参数。testing::Le(val)
:匹配小于等于val
的参数。testing::Gt(val)
:匹配大于val
的参数。testing::Ge(val)
:匹配大于等于val
的参数。testing::StrEq(str)
:匹配等于str
的字符串参数。
4. 动作(Actions)
WillOnce(testing::Return(value))
:指定函数一次调用返回value
。WillOnce(testing::Invoke(func))
:指定函数一次调用执行func
。WillRepeatedly(testing::Return(value))
:指定函数多次调用返回value
。WillRepeatedly(testing::Invoke(func))
:指定函数多次调用执行func
。
写在最后
本文通过黄金思维圈来思考引入单元测试带来的价值,并基于C++工程来接入gtest和gmock来完成基础单测和mock场景的使用。TDD的理念已经存在很久了,相信在互联网行业多多少少都听过,但为什么国内依然很少有团队能做好,这里面有很多原因,比如:
- 写单测的ORI(投入产出比)问题,需要自上而下认可并愿意投入资源
- 写单测需要程序员额外投入时间,并不算做KPI的业绩
- 互联网产品迭代变化快,维护单测成本高
- 国内程序员工程素养参差不齐,单测普及率不高或不知道怎么写
- 存量代码不好测,改造成本较高
所以本文并不是为了鼓吹单测有多好,而是提供一种提升代码质量的思路,TDD更像是一种理念,工具是用来提升效率的,需要持续打磨才能发挥效用。笔者在工程引入gtest的过程中也同样遇到不少问题,后续有机会继续分享实际的案例和效果。
附录
开放原子开发者工作坊旨在鼓励更多人参与开源活动,与志同道合的开发者们相互交流开发经验、分享开发心得、获取前沿技术趋势。工作坊有多种形式的开发者活动,如meetup、训练营等,主打技术交流,干货满满,真诚地邀请各位开发者共同参与!
更多推荐
所有评论(0)