二、单元测试浅谈
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
的优点是可以Mock
C函数
和
静态成员函数
,下文有涉及到。
4、gtest & gmock基础
(1) gtest
单元测试框架通常会对一些变量或函数设置期望,若变量值或返回值符合预期,就认为单元测试用例通过。gtest也提供了下面一些断言:
ASSERT_*
系列的断言,当检查点失败时,立即退出单元测试;
EXPECT_*
系列的断言,当检查点失败时,单元测试还是会继续执行,但结束后会标记所有
ECPECT_*
失败的用例;
EXPECT_CALL
设置函数调用之后期望的实现,比如直接返回某一个值。该断言后面没有
.Times()
时,无论函数有没有调用都不会导致失败,如果有
.Times()
时,不满足
.Times()
设置的次数时就会导致期望失败;
(2) gmock
有时候对于一些接口,比如向服务器发送请求。但单元测试中有没有可用于测试的服务器,这个时候就需要mock这个请求接口。
mock工具的作用是指定函数的行为(模拟函数的行为)。可以对入参进行校验,对出参进行设定,还可以指定函数的返回值。
Mock的基本使用方法是:
继承
某一个类;
实现
或
重写
类中的某个或某些
虚方法
;
创建Mock对象,设置重写方法的实现(大部分是直接返回,对于返回值是内置类型,即使不设置调用后的期望幸会,gmock也会设置默认返回值);
调用被测接口,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
public:
virtual void SetUp()
virtual void TearDown()
protected:
void authenticate_success()
复制代码
登录接口测试源文件:
AuthenticateTest.cpp
TEST_F(AuthenticateTest authen_success)
authenticate_success();
TEST_F(AuthenticateTest, authen_failed)
复制代码
添加商品到购物车测试头文件:
AddTest.h
class AddTest : public AuthenticateTest
public:
virtual void SetUp()
AuthenticateTest::SetUp();
authenticate_success();
virtual void TearDown()
AuthenticateTest::TearDown();
protected:
void add_success()
复制代码
添加商品到购物车测试源文件:
AddTest.cpp
TEST_F(AddTest add_success)
add_success();
TEST_F(AddTest, add_failed)
复制代码
结账测试头文件:
PayTest.h
class PayTest : public 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()
_mt = new MT;
virtual void toBeTestFunc()
_mt->func();
private:
MT* _mt;
复制代码
Mock:
class MockMT : public MT
public:
MOCK_METHOD0(connectDB, void());
复制代码
很关键的依赖注入部分,对待测类也进行Mock:
class MockWrapper : public Wrapper
public:
MOCK_METHOD0(init, void());
void realToBeTestFunc()
Wrapper::toBeTestFunc();
复制代码
单元测试case:
TEST_F(XXX, success)
MT* mockMT = new MockMT;
Wrapper* mockWrapper = new MockWrapper;
EXPECT_CALL(mockWrapper, init()).Times(1).WillOnce(Invoke([this](){
_mt = mockMT;
EXPECT_CALL(mockMT, connectDB()).Times(1).WillOnce(Return());
EXPECT_CALL(mockWrapper, toBeTestFunc()).Times(1).WillRepeatedly(Invoke(mockWrapper, &MockWrapper::realToBeTestFunc));
mockWrapper->init();
mockWrapper->toBeTestFunc();
Mock::VerifyAndClearExpectations(mockMT);
Mock::VerifyAndClearExpectations(mockWrapper);
复制代码
有时候Mock一个类,并不是实际意义的Mock,而是为了实现依赖注入,比如
Wrapper
类。这样的类实例化之后更多的是要执行正常逻辑(此时应该忽略掉它是Mock的)
。对于这样的类,需要做一些特殊处理:
作为依赖注入时,在调用mock对象之前,使用Invoke修改函数,传入
已经创建好的Mock对象
,函数
其余逻辑保持原样
;
作为正常测试类时,在调用mock对象之前,使用Invoke
修改函数调用父类的实现
,比如
MockWrapper::realToBeTestFunc
;
一些待读的书:
【修改代码的艺术】如何把代码重构到"可测试"的状态