作者:ciuwaalu,腾讯安全平台部后台开发
研发效能提升是一个系统化的庞大工程,它涵盖了软件交付的整个生命周期,涉及到产品、架构、开发、测试、运维等各个环节。而单元测试作为软件中最小可测试单元的检查验证环节,可以说是这个庞大工程中最细致但又不可忽视的一个细节因素。本文内容梳理自安全平台部测试效能提升的经验实践,从零开始介绍探讨单测的方法论和优化思路,期望为大家带来参考,欢迎共同交流。什么是单元测试?
在最开始,我们先看看大家认为的单元测试是什么:
以上这些定义为了严谨起见,都是长长的一大段。在这里,我们结合工程实践经验,给出一个“太长不看”版的定义,这个定义不太严谨但更为简单:
开发同学在编码阶段以函数方法为粒度编写测试用例,检验代码逻辑的正确性。
在这个一句话定义里,有四个核心要素:
结合测试V型图,可以清晰看到单元测试在项目周期中所处的位置阶段。
我们不打算罗列《单元测试的N大优势》《写单元测试的N大好处》,只说一条最核心的:单元测试可以尽早发现编码中的低级错误。
越早发现问题,也越容易解决问题。很显然:
来自微软的数据,不同测试阶段发现BUG的平均耗时,供参考:
低级错误造成重大损失的例子实在太多了。有了单元测试,可以避免面向运气开发,面向回滚发布,打破“不知道有没有BUG~上线出事回滚~紧急修复~代码质量逐渐劣化~不知道有没有新BUG”的恶性循环。
黑盒与白盒
在软件测试理论中,常常将被测试对象视为一个盒子,这个神秘的盒子接受一些输入,并做某些处理工作,产生特定的输出结果。
在构造输入数据进行测试时:
白盒测试一般只在单元测试中使用,黑盒测试在单元测试、集成测试等各个阶段都可以使用。
我们以下方这个函数为例子,看看单元测试中如何应用黑盒与白盒测试。首先需要明确,设计单元测试,我们肯定是知道这个函数的具体用途、输入参数和返回结果的含义(即知道盒子的用途):
//从IPv4报文中提取源IP地址uint32_tGetSrcAddrFromIPv4Packet(constvoid*buffer,size_tsize);
如果我们手上只有编译好的二进制库文件,不知道函数的内部实现方式,通过想象这个函数在上线后会遇到什么类型的输入,设计了一些合法和非法的IP报文来做验证,此时是黑盒测试。
如果我们手上有函数源代码,一边看着函数实现,一边根据代码里的分支、逻辑构造各种输入,此时是白盒测试:
比如看到函数内部的if(buffer==nullptr)return-1;设计了一个空缓冲区的用例;
比如看到函数内部的if(size 在大部分情况下,我们是自己给自己写的函数做单元测试,当运用黑盒测试的思路时,要假装被测函数是别人写的。 覆盖 在单元测试中,覆盖率是一个常用的评估指标。 所谓覆盖,可以简单理解为“被执行过”。具体来说:在某个测试用例中,执行了某行代码,则可以说这行代码“被覆盖”;同样,当某个分支的真/假条件都被取到时,则可以说这个分支“被覆盖了”。 常见的覆盖可以分为这几种: 假设我们有一个这么一个待测函数: intfoo(inta,intb,intc,intd){intresult=0;if(a&&b)//分支1result+=a;if(c||d)//分支2result+=c;returnresult; 语句覆盖是指每条语句都被执行一次。当输入a=1,b=1,c=1,d=1一组用例时可以达到。 分支覆盖是指每个分支真/假条件都被执行一次。当输入a=1,b=1,c=1,d=1以及a=0,b=0,c=0,d=0两组用例时可以达到。 条件覆盖是指每个分支的条件组合方式都被执行一次。当输入a=1,b=1,c=1,d=1(真真)、a=1,b=0,c=1,d=0(真假)、a=0,b=1,c=0,d=1(假真)、a=0,b=0,c=0,d=0(假假)四组用例时可以达到。 语句覆盖是最容易达到、也是最弱的覆盖方式。在工程实践中,考虑到测试成本及测试效果,分支覆盖的覆盖率是最常使用的考察指标。 桩与驱动 假设我们还有这么一个待测函数: voidfoo(inta){if(a>0){A();}else{B(); foo()调用了外部函数A()B()。 假设A()是一个很重的函数(操作DB、文件或者网络通信……),进行单元测试时,我们不希望引入这些外部依赖,而是希望调用A()时立即返回一些提前准备好的“假数据”,这时需要“仿冒”一个A(),这个伪造过程就叫做插桩,假冒的A()就称为桩函数(stub)。 在做测试时,需要写一个函数来调用foo(),这个调用者就是驱动(driver)。 单元测试简单实践一个简单的单元测试 一个单元测试用例至少包含: 一个简单但完整的单元测试看起来会是这样的: //待测函数intadd(inta,intb){returna+b; //测试用例voidTestAdd(){//被测对象预期输出//||||assert(add(1,2)==3);//||||||||//断言输入数据} //执行测试intmain(){TestAdd();}Given-When-Then 单元测试中被测函数、断言、输入数据、预期输出几个要素,可以通过经典模板Given-When-Then(GWT)来做一些严谨的描述。 使用GWT来描述上一节的用例: assert(add(//When-测试过程发生的行为-调用被测函数add()1,2//Given-测试前置条件和初始状态-用例输入参数==3//Then-测试结束断言输出结果-断言预期输出 有些现代化的测试框架(例如catch2)对GWT描述做了表达上的优化。下方粘贴了一段单元测试代码示例,有对GWT更为具体的描述: SCENARIO("vectorscanbesizedandresized","[vector]"){GIVEN("Avectorwithsomeitems"){std::vectorv(5); REQUIRE(v.size()==5);//REQUIRE()即assert()REQUIRE(v.capacity()>=5); WHEN("thesizeisincreased"){v.resize(10); THEN("thesizeandcapacitychange"){REQUIRE(v.size()==10);REQUIRE(v.capacity()>=10);}}WHEN("thesizeisreduced"){v.resize(0); THEN("thesizechangesbutnotcapacity"){REQUIRE(v.size()==0);REQUIRE(v.capacity()>=5);}}}}组织结构 原则:单元测试尽可能以函数方法等较小粒度进行组织。 假设我们有下边一个类,设计单元测试时,最好以各个功能函数为测试目标,而不是将类本身为测试目标: //IPv4报文解析structIPv4Parser{IPv4Parser(constvoid*buffer,size_tsize); size_tGetHeaderSize();//获取头部大小uint32_tGetSrcAddr();//获取源IPuint32_tGetDstAddr();//获取目的IP}; 建议:为GetHeaderSize()GetSrcAddr()GetDstAddr()分别构造不同的测试输入数据。 不建议:为IPv4Parser类构造测试输入数据,然后对GetHeaderSize()GetSrcAddr()GetDstAddr()使用同样的数据进行单元测试。 常见的测试框架都支持通过测试套件(TestSuite)对测试用例(TestCase)在逻辑上进行组织,测试套件可以嵌套,整个单元测试可以组织为树状结构。 常见的测试框架还支持Fixture。Fixture是对测试环境进行组织,通过SetUp()TearDown()函数,以方便进行测试开始前的准备工作,以及测试完成后的清理工作。Fixture一般会与测试套件结合使用。 组织单元测试的几点准则: 用例设计 设计单元测试用例中有很多方法:等价类划分、边界值分析、路径测试…… 在实践中,我们可以设计覆盖正常流程&异常流程两大类用例: 一个函数的内部实现可能是异常处理-正常流程-异常处理-正常流程的重复,比如这样: size_tIPv4Parser::GetHeaderSize(){//异常处理if(buffer_size //正常流程autoip=(constiphdr*)buffer; //异常处理if(ip->version!=4)returnfalse; //...} 因此我们在设计测试用例时,可以: 在设计测试用例过程中,可能会遇到被测函数需要与外部DB、文件、网络交互的情况,这时候需要使用Fakes/Stubs/Mocks进行模拟: 在实践中通常并不纠结这几个词语的区别,常被统称为插桩,对应的工具也一般被称作Mock工具。 GoogleTest是老牌测试框架,功能完善,用户很多。 Catch2是现代化测试框架,提供了很多特色功能,依赖简单,可以一试。 Boost.Test是Boost自带的测试框架,依赖Boost的程序可以直接使用,功能强大。 一些Mock工具编译参数选项Python单元测试 点击阅读。 单元测试必须经常跑 从增量到存量,从主要到次要 测试用例需要逐步积累 实践经验 思路:以黑盒指导功能验证,以白盒提升覆盖率 黑盒测试为主: 白盒测试为辅: 可能踩到的坑 不要被高覆盖率骗了 Debug/Release目标结果不一致 代码合并导致单测失败 提高代码的可测性 在编码过程中,多多考虑代码的可测性,可以让单元测试事半功倍: