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