什么是单元测试
本质上也是代码,但它的主要目的是用来验证业务代码的正确性、健壮性和稳定性,甚至是性能。它是代码级的测试。
传统意义上的单元测试一般指软件的最小粒度模块上的测试。对于C语言来说,就是函数的测试,对于C++、JAVA等来说,通常指一个类。但随着各种测试框架、测试手段的不断发展,单元测试已不仅仅局限于此,测试不再限于最小粒度模块。比如C,可以只对对外提供的接口进行测试,而不用测试接口内部使用的其他辅助类接口,对C++而言,可以仅对public接口进行测试而不用对内部的private进行测试。继续扩展,根据功能特性来划分单元,对软件的某个功能模块进行的测试,也可以称为单元测试。
根据软件的设计实现,耦合程度,以及每个人对"单元"的不同定义,单元测试的粒度也可以不同。
单元测试的最终目的是保证代码质量,于开发阶段提前发现问题,减少软件缺陷带来的损失。单元测试是否有效直接体现在代码覆盖率上。
单元测试的好处
- 提前发现代码缺陷,减少质量问题造成的成本损失;
- 优化代码设计。基于测试的考虑,研发人员会更深度考虑代码的可测试性,减少代码的耦合程度;
- 利于代码的重构和迭代。单元测试的保障,可以避免代码重构和新需求迭代开发引入新的问题,或者造成原有功能的异常;
- 方便复现生产问题。产品上线后,某些特殊场景下引发的问题,难以通过人工测试手段复现,可以借助单元测试;
- 利于快速上手项目。项目的新成员,往往是从项目文档和单测用例来快速入手项目和熟悉业务的;
- ...
编写时机
- 具体业务代码实现前,先开发单元测试。由测试驱动开发。需提前明确功能需求和业务接口;
- 与业务代码并行开发。边开发代码边进行单元测试,可以提前发现代码问题并修正,一般随着业务代码开发完成,单元测试也开发完成了;
- 业务代码开发完再进行单元测试。业务代码开发周期缩短,但后续的单元测试覆盖往往粒度不足,且也有可能出现单测过程中发现业务代码设计框架上的不合理,而面临重构代码的问题;
- 迭代过程中新增单测用例。新功能的增加也需要增加对应的单测用例;
- 缺陷修复中补充单测用例。系统测试阶段或产品上线后才暴露的问题,也表明了测试用例场景覆盖不足,此时一般需要对对应的缺陷场景进行用例设计补充;
编写原则
- 明确测试对象和测试的功能;
- 单测用例隔离明确。每个用例只测一个功能或一种场景。这样用例失败下才能明确哪种业务场景异常;
- 要注重利用单测断言。对关键的流程节点和接口返回,要做断言检验。如果用例都不做断言检验,那么用例可以说是无用的;
- 单测要求自动化。不能依赖人工去检验用例的执行是否符合预期;
- 稳定性。一个单测用例,如果业务代码不存在问题的话,那么它是必须通过的,它不能受限于外界不稳定的因素。如果业务代码需要跟某些不稳定的因素交互,那么需要mock那些不稳定的对象;
- 时效性。单测用例的执行时间尽量不能太长,太长的用例需要优化;
- 注重场景恢复。一个用例执行前和执行后,整个测试环境应该是一致的,不能因为你的测试而修改了某些环境,干扰到后续用例的执行;
- 覆盖度。不应该只设计正常场景下的用例,也需要考虑某些异常场景、边界输入的场景用例,测试业务代码的健壮性。理想的情况下要覆盖到业务代码所有的流程分支;
- ...
测试方法
在不同测试层面做好资源配置和回收
目前主流的测试框架,像gtest,unity等,都会把测试分为几个层面:
- 整个测试层面,即在测试工程开始前和结束后进行(gtest有,unity没有);
- 测试套件层面,即在某个测试套件开始前和结束后进行(gtest有,unity没有);
- 测试用例层面,即在某个测试用例开始前和结束后进行;
在整个测试层面,需要在开始前做好测试环境配置,如日志初始化,通用配置初始化等所有测试套件都需要的初始化动作。在结束后进行反初始化做好资源回收;
测试套件,即一组测试用例的集合。一般将所有测试对象相同,测试功能类似的测试用例组划归为同一个测试套件。通常指的就是类或者模块;
在测试套件层面,需要在测试开始前做好所有测试用例的环境配置,如模块的初始化等,在测试结束后做好测试环境的恢复,如模块的反初始化,资源回收等;
在测试用例层面,需要在测试开始前做好每个测试用例通用的初始化,在测试结束后做好反初始化。如内存块使用计数,开始前和开始后的一个计数校验和重置。如消息队列残留消息的清理等。
单测的常用方法——测试替身
实际单元测试场景中,我们可能面对比较复杂的状况:
- 真实的对象很难被创建;
- 真实的对象是通过文件系统、数据库或者网络异步获取的;
- 真实的对象运行效率低;
- 真实的对象难以模拟,比如网络错误等;
- 真实对象的行为有不确定性,无法通过真实对象覆盖全部场景;
- 真实对象的实现依赖于硬件环境;
- 真实对象实际上还不存在
当遇到上述的这些情况时,为了单测的开展,通常可以采用fake、stub或者mock的手段。
fake(伪造)
fake 即伪造对象,用来代替真实对象的行为。它是“假的”,是真实对象的简化实现版本。它有具体的实现,但通常采取一些捷径,去代替真实对象的实现。
class DBServer{
public:
virtual int AddUser(int id, std::string& user);
};
class SqliteDBServer : public DBServer{
public:
int AddUser(int id, std::string& user){
char insertSql[128];
snprintf(insertSql, sizeof(insertSql), "insert into (id,name) value(%d,\"%s\")", id, user.c_str());
retun sqlite3_exec(db, insertSQL, NULL, NULL, &zErrMsg); //插入数据
}
};
class FakeDBServer : public DBServer{
public:
int AddUser(int id, std::string& user){
user_map.insert(std::pair<int, std::string>(id, user));
}
}
stub(打桩)
stub 可以理解为测试桩,它能实现当特定的方法被调用时,返回一个指定的模拟值。stub没有具体的实现,只是返回提前准备好的数据,通常采用硬编码方式。
#ifndef UNIT_TEST
float getTemperature(){
...
float temp;
read_sensor_data(temp);
...
return temp;
}
#else
float getTemperature(){
return 36.5;
}
#endif
int main(){
...
printf("temperature: %f.\n", getTemperature());
}
mock(模拟)
mock和stub类似,只是在测试中需要调用时,针对某种输入指定期望的行为。mock和stub的区别是, mock除了返回数据还可以指定期望以验证行为, 比如接口调用了几次,在某种情况下是否会抛出异常,每一次执行的返回结果。
#include "gmock/gmock.h"
using ::testing::Return;
...
class Turtle {
...
virtual int GetNum() const = 0;
...
};
class MockTurtle : public Turtle {
public:
...
MOCK_METHOD(int, GetNum, (), (const, override));
};
TEST(TurtleTest, GetNum){
MockTurtle turtle;
EXPECT_CALL(turtle, GetNum())
.Times(4)
.WillOnce(Return(1))
.WillOnce(Return(0))
.WillRepeatedly(Return(2));
}
/*
* 这个EXPECT_CALL()指定的期望是:
* 在turtle这个Mock对象销毁之前,turtle的getNum()函数会被调用4次。
* 第一次返回1,第二次返回0,第三次及以后都返回2。
* 指定期望后,4次对getNum的调用会有这些行为。但如果最终调用次数不为4次,则测试失败。
*/
C/C++ 使用测试替身的手段
预编译时替换
通过预编译宏来做隔离。通常单元测试编译会在构建中启用特有的宏来区分业务代码与替身代码。形如__UNIT_TEST__。
#ifdef __UNIT_TEST__
#define malloc(a) test_malloc(a, __FILE__, __LINE__)
#define free(a) test_free(a, __FILE__, __LINE__)
#endif
链接时替换
链接时替换,需要通过编译构建工具,修改makefile,对想要替换的代码编译单元,做隔离和替换。同样也需要借助单元测试编译宏,区分编译单元(C文件)和链接的单元(目标文件、或者库文件)。
# 测试代码
file(GLOB_RECURSE MAIN_SOURCE_FILE ${CMAKE_CURRENT_SOURCE_DIR}/*.cpp ${CMAKE_SOURCE_DIR}/unit_test/Common/*.cpp)
# 业务代码
file(GLOB_RECURSE APP_SOURCE_FILE ${CMAKE_SOURCE_DIR}/src/server/NatServer/*.cpp)
# 去除业务代码中的main.c
list(REMOVE_ITEM APP_SOURCE_FILE ${CMAKE_SOURCE_DIR}/src/server/NatServer/main.cpp)
set(UNIT_TEST_SOURCE_FILES
${MAIN_SOURCE_FILE}
${APP_SOURCE_FILE}
)
运行时替换
C中通常借助函数指针,C++通常借助virtual机制
C实现
// common.h
extern int (*get_func)(void);
void get_config();
void get_a_config();
// common.c
get_func = get_a_config;
int get_config(){
...
get_func();
...
}
// test.c
void test_get_config();
TEST(common, get_config){
...
get_func = test_get_config;
...
get_config();
...
}
C++实现
// common.h
class Operator{
public:
virtual void get_config()=0;
};
class GetOperator : public Operator{
public:
virtual void get_config();
};
// common.cpp
void get_sql_config(Operator* op){
op->get_config();
}
// test.h
class TestGetOperator : public GetOperator{
public:
vitrual void get_config();
};
//test.cpp
TEST(common, get_config){
Operator* op = new TestGetOperator();
get_sql_config(op);
}
单元测试的覆盖方式
int function(int a, int b, int x){
if(a > 1 && b == 0){
x += a;
}
if(a == 2 || x > 1){
x += 1;
}
return x;
}
行覆盖
即覆盖代码的每一行,也称为语句覆盖,是最弱的一种覆盖方式。仅需使用如下一个测试例可以覆盖所有语句。
TEST(module, function){
ASSERT_EQ(6, function(2, 0, 3));
}
分支覆盖
即每一个分支都要覆盖到true和false的两种情况,使用如下测试例可以覆盖到
TEST(module, function){
ASSERT_EQ(4, function(2, 0, 1));
ASSERT_EQ(1, function(3, 1, 1));
}
条件覆盖
即每个判断条件都要覆盖到true和false两种场景。如(a > 1 && b == 0),要覆盖到a > 1为true和false,b==0 为true和false。 使用如下测试例可以覆盖到。
TEST(module, function){
ASSERT_EQ(6, function(2, 0, 3));
ASSERT_EQ(0, function(0, 1, 0));
}
路径覆盖
即覆盖所有可能执行的路径
使用如下测试用例可以覆盖到。
TEST(module, function){
ASSERT_EQ(0, function(0, 1, 0)); // 路径1->3->5
ASSERT_EQ(0, function(3, 0, -3)); // 路径1->2->5
ASSERT_EQ(4, function(2, 1, 3)); // 路径1->3->4
ASSERT_EQ(6, function(2, 0, 3)); // 路径1->2->4
}
单元测试应该尽量做到路径覆盖
参考资料
- https://www.cnblogs.com/csonezp/p/11757967.html
- https://zhangyuyu.github.io/cpp-unit-test/#1-fakemockstub