一、综述
这篇文章介绍C++
单元测试工具gtest
、gmock
,以及自己在前段时间做单元测试时的一些方式方法。
二、单元测试浅谈
1、为什么要做单元测试
单元测试不但可以增加开发者对于所完成代码的自信,同时,好的单元测试用例往往可以在回归测试的过程中,很好地保证之前所发生的修改没有破坏已有的程序逻辑。因此,单元测试不但不会成为开发者的负担,反而可以在保证开发质量的情况下,加速迭代开发的过程。
2、好的单元测试应具备的特点
- 独立,单元测试用例的测试结果不应该受其他测试的影响;
- 当测试用例失败时,提供尽可能多的有效信息,方便定位和Debug。比如下文会说到的gtest,在EXPECT_*之后打印出已知信息,例如:
EXPECT_EQ(10, add(p1, p2)) << "p1:" << p1 << " p2:" << p2;
3、C++单元测试框架
C++的单元测试框架,我见过最常多的就是gtest
了。除此之外,boost
也提供了一个用于单元测试的框架,boost仅在学校时使用过,印象中与gtest
使用方式大同小异。
Mockcpp
与gtest和boost不同,Mockcpp
是C++的Mock框架,而后两者是单元测试框架(单元测试与Mock是相关但没有必然关联的两个东西)。Mockcpp
的优点是可以MockC函数
和静态成员函数
,下文有涉及到。
4、gtest & gmock基础
(1) gtest
单元测试框架通常会对一些变量或函数设置期望,若变量值或返回值符合预期,就认为单元测试用例通过。gtest也提供了下面一些断言:
ASSERT_*
系列的断言,当检查点失败时,立即退出单元测试; EXPECT_*
系列的断言,当检查点失败时,单元测试还是会继续执行,但结束后会标记所有ECPECT_*
失败的用例; EXPECT_CALL
设置函数调用之后期望的实现,比如直接返回某一个值。该断言后面没有.Times()
时,无论函数有没有调用都不会导致失败,如果有.Times()
时,不满足.Times()
设置的次数时就会导致期望失败;
(2) gmock
有时候对于一些接口,比如向服务器发送请求。但单元测试中有没有可用于测试的服务器,这个时候就需要mock这个请求接口。 mock工具的作用是指定函数的行为(模拟函数的行为)。可以对入参进行校验,对出参进行设定,还可以指定函数的返回值。
Mock的基本使用方法是:
1、 继承
某一个类;
2、 实现
或重写
类中的某个或某些虚方法
;
3、 创建Mock对象,设置重写方法的实现(大部分是直接返回,对于返回值是内置类型,即使不设置调用后的期望幸会,gmock也会设置默认返回值);
4、 调用被测接口,Mock对象调用重写方法,期望满足,测试通过。
三、单元测试中的一些方式方法
(1) Mock C函数和C++静态成员函数
因为Mock是基于多态
实现的,gmock是不支持Mock全局函数或者静态成员函数的。对于这些全局函数, 比较传统的做法是创建一个Wrapper, 用虚方法对这些静态函数进行包裹. 在测试的时候对Wrapper进行Mock便可控制被包裹的静态函数的行为。
更详细的Mock方式可以参考这篇 文章。里面不仅介绍如何使用Mockcpp
Mock静态成员函数,也对Mockcpp ‘Mock静态成员函数的一些缺陷’使用gmock
解决了。
(2) 测试具有依赖关系的case
单元测试的case不应该有直接的依赖关系,每一个case在SetUp之后应该达到可以直接测试的条件,在TearDown之后不应该残留任何状态。这里所说的[测试具有依赖关系的case]指的是:一个case测试的条件是另一个case执行正常路径之后的状态。说的有点绕,举个例子:
cese1:测试登录接口,没有前提,直接访问登录接口即可。 case2:测试添加商品到购物车,前提是已经登录成功; case3: 测试结账接口,前提是已经添加了若干商品到购物车;
对于这三个case,测试的时候代码该怎么写呢?难道测case2的时候要把case1的正常路径写到case2的开头?测case3的时候要把case1和case2的正常路径写到case3的开头?这代码得有多臃肿?如果还有case4、case5呢?
对于这种情况,应该充分利用SetUp和TearDown。我会这样写:
登录接口测试头文件:AuthenticateTest.h
class AuthenticateTest : public testing::Test // 继承gtest的测试类
{
public:
virtual void SetUp()
{
// 什么都不写,假如登录不需要前提条件
}
virtual void TearDown()
{
}
protected:
void authenticate_success()
{
// 正常case,可以使用EXPECT_*等条件
}
// 如果接下来的case需要一些公共变量
};
登录接口测试源文件:AuthenticateTest.cpp
TEST_F(AuthenticateTest authen_success)
{
authenticate_success();
}
TEST_F(AuthenticateTest, authen_failed)
{
// 错误case。这个就没必要封装函数了,因为其他地方也不会用到这个case
}
添加商品到购物车测试头文件:AddTest.h
class AddTest : public AuthenticateTest // 注意:这里要继承AuthenticateTest,而不是Test,因为AuthenticateTest类中有我们测试添加商品的条件:成功登陆
{
public:
virtual void SetUp()
{
AuthenticateTest::SetUp();
authenticate_success(); // 成功登陆
// 其他前提条件
}
virtual void TearDown()
{
// 清理环境
AuthenticateTest::TearDown(); // 父类的清理函数
}
protected:
void add_success()
{
// 正常case,可以使用EXPECT_*等条件
}
// 如果接下来的case需要一些公共变量
};
添加商品到购物车测试源文件:AddTest.cpp
TEST_F(AddTest add_success)
{
add_success();
}
TEST_F(AddTest, add_failed)
{
// 错误case。这个就没必要封装函数了,因为其他地方也不会用到这个case
}
结账测试头文件:PayTest.h
class PayTest : public AddTest // 注意:这里要继承AddTest,而不是Test,因为AddTest类中有我们测试添加商品的条件:成功添加商品到购物车
{
public:
virtual void SetUp()
{
AddTest::SetUp();
add_success(); // 成功添加商品
// 其他前提条件
}
virtual void TearDown()
{
// 清理环境
AddTest::TearDown(); // 父类的清理函数
}
};
结账测试源文件:PayTest.cpp
TEST_F(PayTest pay_success)
{
}
TEST_F(PayTest, pay_failed)
{
}
(3) 依赖注入
假如Mock类已经写好,那如何把实例化出来的Mock对象传入被测方法呢?(有时候被Mock的类或对象可能在被测接口内部使用)举个例子: 需要Mock的类:
class MT
{
public:
virtual void connectDB()
{
// 一些还未实现的,或不好用于测试的操作。比如访问数据库
}
};
被Mock的对象处于被测类内部
class Wrapper
{
public:
virtual void init() // 如果init方法不是virtual,为了单元测试也要加上virtual。为什么要virtual,下文说
{
_mt = new MT; // 难测点:待Mock类处于函数内,无法通过参数传入
// do something
}
virtual void toBeTestFunc() // 被测对象
{
_mt->func();
// other operator
}
private:
MT* _mt;
};
Mock:
class MockMT : public MT
{
public:
MOCK_METHOD0(connectDB, void());
}
很关键的依赖注入部分,对待测类也进行Mock:
class MockWrapper : public Wrapper // 依然需要Mock
{
public:
MOCK_METHOD0(init, void()); // 这里就是为什么Wrapper::init是virtual的原因,需要Mock
void realToBeTestFunc() // 定义一个函数,但是调用父类的接口,原因在单元测试case中说
{
Wrapper::toBeTestFunc();
}
};
单元测试case:
TEST_F(XXX, success)
{
MT* mockMT = new MockMT; // 创建Mock对象
Wrapper* mockWrapper = new MockWrapper; // 创建待测对象
// 依赖注入
EXPECT_CALL(mockWrapper, init()).Times(1).WillOnce(Invoke([this](){
_mt = mockMT; // 仅仅在这里修改传入Mock对象,其他任何操作都要保持原样
// do something
}));
// 设置Mock对象连接数据库的预期操作:直接返回,不做任何操作
EXPECT_CALL(mockMT, connectDB()).Times(1).WillOnce(Return());
// Wrapper::toBeTestFunc是我们要测的方法。但现在我们没有使用Wrapper,而是用了MockWrapper,那gmock就会对MockWrapper::toBeTestFunc进行默认操作,比如直接返回,这样就测不了Wrapper::toBeTestFunc了。
// 可以通过设置MockWrapper::toBeTestFunc的默认操作来调用Wrapper::toBeTestFunc来解决。这也是MockWrapper::realToBeTestFunc存在的意义。
EXPECT_CALL(mockWrapper, toBeTestFunc()).Times(1).WillRepeatedly(Invoke(mockWrapper, &MockWrapper::realToBeTestFunc));
// 执行调用逻辑
mockWrapper->init();
mockWrapper->toBeTestFunc();
// some EXPECT_EQ
// 对于new出来的堆对象,你可能需要下面两行。否则EXPECT_CALL可能会失败。感兴趣的话,原因可以自行查阅。
Mock::VerifyAndClearExpectations(mockMT);
Mock::VerifyAndClearExpectations(mockWrapper);
}
有时候Mock一个类,并不是实际意义的Mock,而是为了实现依赖注入,比如Wrapper
类。这样的类实例化之后更多的是要执行正常逻辑(此时应该忽略掉它是Mock的)。对于这样的类,需要做一些特殊处理:
- 作为依赖注入时,在调用mock对象之前,使用Invoke修改函数,传入
已经创建好的Mock对象
,函数其余逻辑保持原样
; - 作为正常测试类时,在调用mock对象之前,使用Invoke
修改函数调用父类的实现
,比如MockWrapper::realToBeTestFunc
;
一些待读的书:
- 【修改代码的艺术】如何把代码重构到”可测试”的状态