自动化单元测试 Automatic Test Generation
自动化单元测试 Automatic Test Generation
Parasoft C/C++test / wing / VectorCast
https://patricegodefroid.github.io/public_psfiles/talk-issta2010.pdf
一、自动化测试技术
软件研发生命周期的各个阶段都有自动化测试技术的存在,并且对提升测试效率有着至关重要的作用。
Monkey Testing
漫无目的的生成随机测试数据,我们称之为 Monkey Testing,也可以称作是 Blackbox Fuzz Testing。
因为我们在生成测试数据的过程中,假设不知道程序内部的运行状态,而完全是依靠随机的方式。
Fuzz Testing(也称作 Fuzzing)
而更高效的方法是 Greybox Fuzz Testing。Greybox Fuzz Testing 的核心思想是,现实世界中,程序的输入数据大部分是存在某种特定语义的,基于一组有效语义的 seed 数据以及各种 Fuzzing Strategy 所生成的测试数据,更容易覆盖更多有效的代码路径。例如我们在为一个图片浏览器程序生成数据时,如果是纯粹随机的数据,那么大部分的测试数据都是无效的,会因文件不符合某种格式而走入相同的代码路径。这显然极其低效。而基于有效的图片文件进行比特翻转,或文件大小调整,则更容易避免这种情况,从而尽可能高效的生成有效测试数据。
LLVM 社区的 LibFuzzer 和 AFL 具有类似的思想。Google 的 ClusterFuzz 就是基于 LibFuzzer 的。和 Monkey Testing 相比,Greybox Fuzz Testing 生成有效测试数据的效果提升非常明显,并且在具体应用过程中也有着非常好的效果。然而尽管如此,从本质上讲,Greybox Fuzz Testing 还是一种基于暴力的方式,他不保证能够准确的覆盖到全部代码路径。
兔子的进化
那么是否存在不暴力的精确生成测试数据的方法呢?答案当然也是肯定的。
通过符号执行(Symbolic Execution)技术,分析所有分支路径的符号约束(Symbolic Constrains),来指导如何生成新的测试数据(Guided Test Generation),以保证每一个代码路径尽可能的只执行一次,这样就可以大大的提高分支覆盖的效率,避免生成的测试数据重复的走入同一个代码路径。这种方式也被称作为 Whitebox Fuzz Testing。符号执行 (Symbolic Execution)是一种程序分析技术。其可以通过分析程序来得到让特定代码区域执行的输入。
int foo(int x) {
int y = x + 3;
if (y == 3) abort(); // error
return 0;
}
首先,我们随机生成一个 x 的值,假设是 100,并调用 foo 函数,y 将被赋值为 103。接下来,将执行 if 分支判断。由于 y 是 103,不等于 3,则进入 else 分支。为了得到如何进入另一个分支的条件,我们将 x 替换成一个 Symbolic,例如 λ,那么进入另一个分支的条件是 λ + 3 == 3,也就是分支约束(Path Constrains)。根据这个条件,可以推导出 λ 为 0 时,可以进入这一分支,而 λ 为 0 就是符号约束(Symbolic Constrains)。而整个推导的过程,被称为 Constrains Solving。根据这个符号约束,我们可以在第二次执行时,直接将 x 赋值为 0,并调用 foo 函数。这样一来,只需要执行两次,就会完成代码路径覆盖并发现 bug。
Whitebox Fuzz Testing 虽然非常高效,但实际上 Symbolic Execution 技术非常复杂,也存在一定的限制,同时 Constrains Solving 也非常耗时。所以在实际应用场景中,Whitebox Fuzz Testing 通常与 Greybox Fuzz Testing 结合使用。Greybox Fuzz Testing 执行速度非常快,通过先运行 Greybox Fuzz Testing,完成初次的路径覆盖。对于无法达到的路径,通过 Symbolic Execution 的方式找到 Symbolic Constains,然后指导生成特定的测试数据,用于进入更深层次的代码路径。然后,再继续使用 Greybox Fuzz Testing 进行覆盖,如此循环迭代。
那么单元测试,手工测试,等等不行么?这主要是由于单元测试通常无法达到足够的代码路径覆盖,并且编写起来也非常耗时。同时,安全相关的 bug 又非常难于被发现。例如 2014 年被发现的著名的 ShellShock,相关的漏洞代码从 1989 年就已经被引入,经过了 25 年后才被发现。而背后的功臣,正是 Fuzz Testing。此外,像 NASA 这种对于程序正确性有极高要求的地方,也会使用 Fuzz Testing 的方式对他们的程序进行严格验证,以保证在任何极端情况下,程序都可以正常工作。
Fuzz Testing 代表了一种自动生成测试的技术。它并不能完全替代单元测试,以及手工测试,Fuzz Testing 也有很多不适用的场景。但 Fuzz Testing 是引导我们对程序的正确性进行验证的一种很好的方式,Fuzz Testing 与 Model Checking 相结合,也是对单元测试以及手工测试非常强大的补充。
二、单元测试单元测试本身就是自动化的,因为它根据软件详细设计采用等价类划分和边界值分析方法设计测试用例,在测试代码实现后再以自动化的方式统一执行。从广义上讲,单元测试阶段的「自动化」内涵不仅仅指测试用例执行的自动化,还应该包含以下五个方面:
1.用例框架代码生成的自动化
2.部分测试输入数据的自动化生成
3.自动桩代码的生成
4.被测代码的自动化静态分析
5.测试覆盖率的自动统计与分析
A. 用例框架代码生成的自动化
框架代码(测试数据载入+参数实例创建+被测函数调用+期望输出校验)应该由自动化工具生成,而不是由开发者手工完成。这样单元测试开发者可以把更多的精力放在测试逻辑的覆盖和测试数据的选择上,从而大幅提高单元测试用例的质量和开发效率。
对于基于代码的API测试用例,通常包含三大步骤:
1. 准备API调用时需要的测试数据
2. 准备API的调用参数并发起API的调用
3. 验证API调用的返回结果
B、部分测试输入数据生成的自动化
自动化工具能够根据不同变量类型自动生成测试输入数据。自动化工具本身不可能明白代码逻辑,所以只能根据类型生成测试数据,并且去判断预计的测试结果的。
比如,某个被测函数的原型是void fun(int* p, short b);,那么测试数据自动生成技术就会为输入参数int* p自动生成「空」和「非空」的两个指针p,然后分别执行函数void fun(int* p, short b);,并观察函数的执行情况。
如果函数内部没有对空指针进行特殊处理,那么函数fun的调用必定会抛出异常,从而发现函数的设计缺陷。同样的,对于输入参数short b会自动生成超出short范围的b,测试函数fun的行为。
C、自动桩代码的生成
简单的说,桩代码(stub code)是用来代替真实代码的临时代码。比如,某个函数A的内部实现中调用了一个尚未实现的函数B,为了对函数A的逻辑进行测试,那么就需要模拟一个函数B,这个模拟的函数B实现就是所谓的桩代码。
自动桩代码的生成是指自动化工具可以对被测试代码进行扫描分析,自动为被测函数内部调用的其他函数生成可编程的桩代码,并提供基于测试用例的桩代码管理机制。此时,单元测试开发者只需重点关注桩代码内的具体逻辑实现,以及桩代码的返回值。
必要的时候,自动化工具还需要实现「抽桩」,以适应后续的代码级集成测试的需求。
TIPS:「抽桩」,在单元测试阶段,假如函数A内部调用的函数B是桩代码,那么在代码级集成测试阶段,希望函数A不再调用假的函数B,而是调用真实的函数B,这个用真实函数B代替原本桩代码函数B的操作。
D、被测代码的自动化静态分析
静态分析主要指代码的静态扫描,目的是识别出违反编码规则或编码风格的代码行。
通常这部分工作是结合项目具体的编码规则和编码风格,由自动化工具通过内建规则和用户自定义规则自动化完成的。目前比较常用的代码静态分析工具有Sonar和Coverity等。
严格意义上讲,静态分析不属于单元测试的范畴,但这部分工作一般是在单元测试阶段通过自动化工具完成的,所以也可有把它归入到单元测试自动化的范畴。
**E、测试覆盖率的自动统计与分析
单元测试用例执行结束后,自动化工具可以自动统计各种测试覆盖率,包括代码行覆盖率、分支覆盖率、MC/DC覆盖率等。这些自动统计的指标,可以协助衡量单元测试用例集合的充分性和完备性,并可提供适当增补测试用例以提高测试覆盖率的依据。
1. 单元测试的一些原则:
1)测试用例能验证函数的正确性(这条都通不过就……);
2)测试用例尽可能涵盖边界条件(例如遍历一个链表,头指针是空,只有一个节点,链表有N个节点,N是问题描述下允许的最大节点数等等);
3)一些异常和错误处理(例如往一个函数里传入空指针,传入空串,这个函数能否打印一些log,返回错误码,实现加法的Add函数如何检测和处理溢出等等)
2. 打桩
2.1什么是桩
桩,或称桩代码,是指用来代替关联代码或者未实现代码的代码。如果函数func用func_stub来代替,那么,func称为原函数,func_stub称为桩函数。打桩就是编写或生成桩代码的过程。
2.2打桩的目的主要有:
- 隔离
隔离是指将测试任务从产品项目中分离出来,使之能够独立编译、链接,并独立运行。
隔离的基本方法就是打桩,将测试任务之外的,并且与测试任务相关的代码,用桩来代替,从而实现分离测试任务。
例如函数func_a调用了函数func_b,函数func_b又调用了函数func_c和func_d,如果函数func_b用桩来代替,函数func_a就可以完全割断与函数func_c和func_d的关系。
2.补齐
补齐是指用桩来代替未实现的代码。
例如,函数func_a调用了函数func_b,而函数func_b由其他程序员编写,且未实现,那么,可以用桩来代替函数func_b,使函数func_a能够运行并测试。补齐在并行开发中很常用。
3.控制
控制是指在测试时,人为设定相关代码的行为,使之符合测试需求。
例如:
extern int func_b();
int func_a()
{
int ret = func_b();
if(ret == 0)
;//do something
else if(ret == 1)
;//do something
else
;//do something
return ret;
}
如果函数func_b返回随机数,或者返回网络状态,或者返回环境温度,等等,则当调用其实际代码时,函数func_a很难测试,这时可以用桩函数func_b_stub来代替func_b,使其返回测试所需要的数据。
一个桩函数,可能既具有控制功能,又具有隔离或补齐功能。
3. 编写桩
一般来说,桩函数原型要和原函数保持一致,仅仅是实现不同,这样测试代码才能正确链接到桩函数。用于实现隔离和补齐的桩函数一般比较简单,只需把原函数的声明拷过来,加一个空的实现,就能通过编译链接了。比较复杂的是实现控制功能的桩函数,要根据测试的需要,输出合适的数据,下面是一个示例:
//获取环境温度。温度由出参pTemperature输出,返回值表示获取温度是否成功,如果成功,则返回1,否则返回0。
int GetTemperature(int* pTemperature)
{
if(caseNameIs("failed"))
return 0;
if(caseNameIs("ok-23"))
{
*pTemperature = 23;
return 1;
}
if(caseNameIs("ok-25"))
{
*pTemperature = 25;
return 1;
}
if(caseNameIs("ok-28"))
{
*pTemperature = 28;
return 1;
}
return 0;
}
其中,caseNameIs()是由测试工具提供的API,用于判断用例的名称。代码根据用例名称来决定输出数据。
4. 动态打桩
实际工作中,在我们编写单元测试代码时,需要在一个测试集合中,多次把原函数替换成桩函数进行测试,测试结束之后,又需要进行恢复。这就牵涉到动态打桩的概念了,我们先通过一个例子看一下动态打桩的效果。
// stub_test.c : 定义控制台应用程序的入口点。
#include "stub.h"
#include <stdio.h>
void add(int i)
{
printf("add(%d)\n",i);
}
void add_stub(int i)
{
printf("add_stub(%d)\n",i);
}
int main()
{
INSTALL_STUB(add,add_stub);
add(12);
REMOVE_STUB(add_stub);
add(11);
return 0;
}
上述代码编译运行结果为:
add_stub(12)
add(11)
通过结果可以明显看出,在执行INSTALL_STUB(add,add_stub)之后,原函数add已经被动态替换成了add_stub桩函数,在执行REMOVE_STUB(add_stub)之后,原函数add又恢复正常调用了。
5. 桩(stub)和模拟(mock)的区别
mock框架可以非常容易的开发stub和mock,但是stub框架想要提高mock机制就要靠自己扩展了。
两者主要的区别如下:
-
stub存在的意图是为了让测试对象可以正常的执行,其实现一般会硬编码一些输入和输出。
-
mock除了保证stub的功能之外,还可深入的模拟对象之间的交互方式,如:调用了几次、在某种情况下是否会抛出异常。
6. 代码覆盖率 gcov 工具
参考
参考2
TCov (Tiny coverage) is a small library to access GCov coverage information.
Java Code Coverage Tools
- gcov是什么
gcov是一个测试代码覆盖率的工具。与GCC一起使用来分析程序,以帮助创建更高效、更快的运行代码,并发现程序的未测试部分
是一个命令行方式的控制台程序。需要结合lcov,gcovr等前端图形工具才能实现统计数据图形化
伴随GCC发布,不需要单独下载gcov工具。配合GCC共同实现对c/c++文件的语句覆盖和分支覆盖测试
与程序概要分析工具(profiling tool,例如gprof)一起工作,可以估计程序中哪段代码最耗时
- gcov能做什么
使用象gcov或gprof这样的分析器,您可以找到一些基本的性能统计数据:
每一行代码执行的频率是多少
实际执行了哪些行代码,配合测试用例达到满意的覆盖率和预期工作
每段代码使用了多少计算时间,从而找到热点优化代码
gcov创建一个sourcefile.gcov的日志文件,此文件标识源文件sourcefile.c每一行执行的次数,您可以与gprof一起使用这些日志文件来帮助优化程序的性能。gprof提供了您可以使用的时间信息以及从gcov获得的信息。
- gcov过程概况
编译前,在编译器中加入编译器参数-fprofile-arcs -ftest-coverage;
源码经过编译预处理,然后编译成汇编文件,在生成汇编文件的同时完成插桩。插桩是在生成汇编文件的阶段完成的,因此插桩是汇编时候的插桩,每个桩点插入3~4条汇编语句,直接插入生成的*.s文件中,最后汇编文件汇编生成目标文件,生成可执行文件;并且生成关联BB(基本块)和ARC(跳转)的.gcno文件;
执行可执行文件,在运行过程中之前插入桩点负责收集程序的执行信息。所谓桩点,其实就是一个变量,内存中的一个格子,对应的代码执行一次,则其值增加一次;
生成.gcda文件,其中有BB和ARC的执行统计次数等,由此经过加工可得到覆盖率。
- 使用gcov的3个阶段
编译阶段
要开启gcov功能,需要在源码编译参数中加入-fprofile-arcs -ftest-coverage
-ftest-coverage:在编译的时候产生.gcno文件,它包含了重建基本块图和相应的块的源码的行号的信息。
-fprofile-arcs:在运行编译过的程序的时候,会产生.gcda文件,它包含了弧跳变的次数等信息。
如下以helloworld_gcov.c为例子,源码如下:
#include <stdio.h>
#include <string.h>
int main(int argc, char *argv[])
{
if (argc >=2) {
printf("=====argc>=2\n");
return;
}
printf("helloworld begin\n");
if (argc <2){
printf("=====argc<2\n");
return;
}
return;
}
helloworld_gcov.c的Makefile的书写如下,在编译选项CFLAGS中加入-fprofile-arcs -ftest-coverage选项:
#加入gcov编译选项,通过宏PRJRELEASE=gcov控制
ifeq ("$(PRJRELEASE)","gcov")
CFLAGS+= -fprofile-arcs -ftest-coverage
endif
CC=gcc
.PHONE: all
all: helloworld
helloworld: *.c
# 编译出汇编和gcno文件
@echo ${CFLAGS}
@${CC} ${CFLAGS} -S -o helloworld_gcov.s helloworld_gcov.c
@${CC} ${CFLAGS} -o helloworld_gcov helloworld_gcov.c
.PHONE: clean
clean:
@-rm helloworld_gcov helloworld_gcov.gcno helloworld_gcov.gcda helloworld_gcov.c.gcov helloworld_gcov
在helloworld目录下执行make命令后,产生helloworld_gcov.s,helloworld_gcov helloworld_gcov.gcno. helloworld_gcov.gcno只要源码不变,编译出来永远不改变.
运行gcov helloworld_gcov.c命令产生原始的代码覆盖率数据文件helloworld_gcov.c.gcov, 由于此时没有运行./helloworld_gcov,没有helloworld_gcov.gcda统计数据,覆盖率为0
gcov收集代码运行信息
运行./helloworld_gcov产生helloworld_gcov.gcda文件,其中包含了代码基本块和狐跳变次数统计信息.
生成gcov代码覆盖率报告
再次运行gcov helloworld_gcov.c产生的helloworld_gcov.c.gcov中包含了代码覆盖率数据,其数据的来源为helloworld_gcov.gcda
为了对比运行./helloworld_gcov前后的覆盖率数据文件helloworld_gcov.c.gcov信息,直接执行如下脚本,产生前后数据对比
$ make #编译
$ gcov helloworld_gcov.c #生成原始的helloworld_gcov.c.gcov文件
$ cp helloworld_gcov.c.gcov helloworld_gcov.c.gcov-old #备份好原始的helloworld_gcov.c.gcov文件,方便后续对比
$ cp helloworld_gcov.gcno helloworld_gcov.gcno-old #备份好原始的helloworld_gcov.gcno文件,方便后续对比
$ ./helloworld_gcov #产生helloworld_gcov.gcda文件,记录的代码运行的统计数据
$ gcov helloworld_gcov.c #根据gcda文件,再次生成helloworld_gcov.c.gcov文件
#最后显示如下,可以对比先后的gcov文件,前后汇编文件.
yangfogen@ubuntu:~/work/helloworld_gcov$ ls
helloworld_gcov helloworld_gcov.c.gcov helloworld_gcov.gcda helloworld_gcov.gcno-old helloworld_gcov.s
helloworld_gcov.c helloworld_gcov.c.gcov-old helloworld_gcov.gcno helloworld_gcov-gcov.s
其中#####表示未运行的行
每行前面的数字表示行运行的次数
上述生成的.c.gcov文件可视化成都较低,需要借助lcov,genhtml工具直接生成html报告。
根据.gcno .gcda文件生成图形化报告
$ lcov -c -d . -o helloworld_gcov.info
$ genhtml -o 111 helloworld_gcov.info
- gcov检测代码覆盖率的原理
原理概述
Gcc中指定-ftest-coverage 等覆盖率测试选项后,gcc 会:
在输出目标文件中留出一段存储区保存统计数据
在源代码中每行可执行语句生成的代码之后附加一段更新覆盖率统计结果的代码,也就是前文说的插桩
在最终可执行文件中进入用户代码 main 函数之前调用 gcov_init 内部函数初始化统计数据区,并将gcov_exit 内部函数注册为 exit handlers用户代码调用 exit 正常结束时,gcov_exit 函数得到调用,其继续调用 __gcov_flush 函数输出统计数据到 *.gcda 文件中.
gcov数据统计原理(即:gcov怎么计算统计数据的)
gcov是使用 基本块BB 和 跳转ARC 计数,结合程序流图来实现代码覆盖率统计的:
- 基本块BB
如果一段程序的第一条语句被执行过一次,这段程序中的每一个都要执行一次,称为基本块。
一个BB中的所有语句的执行次数一定是相同的。一般由多个顺序执行语句后边跟一个跳转语句组成。
所以一般情况下BB的最后一条语句一定是一个跳转语句,跳转的目的地是另外一个BB的第一条语句,如果跳转时有条件的,就产生了分支,该BB就有两个BB作为目的地。
- 跳转ARC
从一个BB到另外一个BB的跳转叫做一个arc,要想知道程序中的每个语句和分支的执行次数,就必须知道每个BB和ARC的执行次数.
- 程序流图
如果把BB作为一个节点,这样一个函数中的所有BB就构成了一个有向图。
要想知道程序中的每个语句和分支的执行次数,就必须知道每个BB和ARC的执行次数。
根据图论可以知道有向图中BB的入度和出度是相同的,所以只要知道了部分的BB或者arc大小,就可以推断所有的大小。
这里选择由arc的执行次数来推断BB的执行次数。所以对部分 ARC插桩,只要满足可以统计出来所有的BB和ARC的执行次数即可。