吴骏龙 前阿里巴巴本地生活高级专家
你好,我是吴骏龙。
长久以来,我们评判一个测试用例写得好不好,主要是关注它能达到多高的覆盖率。但随着软件行业的不断发展,我们对软件测试工作的精细化要求越来越高。如今覆盖率已经不足以说明问题了,这是因为测试用例的高覆盖率并不能保证它一定能发现问题。比如我们有下面这个方法:
public int calculate(int a, int b, int c) {
return (int)(a / (b - c));
}
对这个方法编写如下测试用例:
public void testCalculate() {
assertEqual(calculate(1,2,3), -1);
assertEqual(calculate(2,3,1), 1);
assertEqual(calculate(0,2,3), 0);
}
很显然,这个测试用例可以达到100%的代码覆盖率,但它却无法测出上述方法的一个显而易见的大问题,即没有对除数为0的情况做异常处理。这样的例子数不胜数,一不小心我们就会陷入“覆盖率很高,但问题依然很多”的怪圈。
我们希望能够确切地知道,我们所写的的测试用例究竟能够在多大程度上保障系统的质量。换言之,怎样评价这些测试用例是不是真的有效?更进一步地,到底有多有效?
今天我将与你共同探讨测试用例有效性度量的一种方案——变异测试,通过对变异算子、变异体等概念的介绍,结合工程化的案例展示,帮助你深入理解如何应用变异测试解决测试用例的有效性度量这一难题。
首先,我们先来看一下什么是变异测试。
变异测试是一种基于错误的测试方式,通过在程序中预先埋入一些“错误”,观察测试用例的表现来评估其有效性。变异测试的步骤一般分为三步:
public int calculate(int a, int b, int c) {
return (int)(a / (b - c));
}
我们将其中的“/”改为“*”,这就是所谓的“合乎语法的微小改动”。改动完毕的程序代码(变异体)为:
public int calculate(int a, int b, int c) {
return (int)(a * (b - c));
}
接下来,我们执行如下测试用例。
public void testCalculate() {
assertEqual(calculate(1,2,3), -1);
}
很显然,在源程序和变异体上,测试用例均能通过。这表明该测试用例无法甄别出这个变异体,变异体依然存活着。
我们换一个测试用例再执行一遍。
public void testCalculate() {
assertEqual(calculate(2,3,1), 1);
}
这时你会发现,测试用例在源程序上可以通过,而在变异体中无法通过,这说明变异体被杀死了。
到这里,你应该对变异测试的步骤有了比较感性的认识。下面我会介绍一些理论和术语,同时穿插讲解变异测试的一些重点和难点,帮助你融会贯通。
假设一:“胜任的程序员假设”(Competent Programmer Hypothesis,简称CPH)
通俗地说,CPH指的是程序员是有能力,而且是尽力去开发正确的程序的,而不是去搞破坏。变异测试的基础是这个“基本正确”的程序,而不是漏洞百出的程序。
假设二:“组合效应假设”(Coupling Effect Hypothesis,简称CEH)
CEH指的是,一个复杂的缺陷往往是由多个简单的缺陷累积而成的。换句话说,通过各种类型的变异体的叠加,我们可以模拟出复杂的程序问题。
了解了这两个基本假设之后,我们再来介绍变异测试的六个基本概念:变异算子、一阶变异体、高阶变异体、可杀除变异体、可存活变异体,以及等价变异体。接下来我们逐一看看。
(1)变异算子
上面我们已经提到过,对源程序进行合乎语法的微小改动,生成副本,相应的改动称之为变异算子。因编程语言的不同,这些改动也不尽相同。我们以流行的面向对象语言为例,展示一些典型的变异算子。具体见下表:
(2)一阶变异体
一阶变异体是在源程序上通过单个变异算子转换形成的目标变异体。
(3)高阶变异体
高阶变异体是在源程序上通过多次变异算子反复转换形成的目标变异体。
如下面的案例所示,每一项变异体,都是它上一项变异体的一阶变异体,而跨越多项的变异体,就属于高阶变异体。例如,B是A的一阶变异体,C也是B的一阶变异体,而D就是A的高阶变异体。
【A】z = x + y;
【B】z = x - y;
【C】z = x - y + 1;
【D】z = 3x - y + 1;
这个例子中对于每次变异相应的改变,我都用红色字体标记出来了,你可以看到。
(4)可杀除变异体
如果测试用例在源程序和变异体上的执行结果不一致,则该变异体针对该测试用例为可杀除变异体。
(5)可存活变异体
与“可杀除变异体”相反,如果所有的测试用例在这个变异体上的执行结果都一致,那么这个变异体就是可存活变异体。
这两者在上面我们已经展示过相关的案例。
(6)等价变异体
源程序和变异体之间,如果语法不同但语义完全相同,则它们是等价变异体。例如以下两段程序代码就是等价变异体。在实际应用中,最好能够去除掉这些冗余的等价变异体,降低变异测试的执行成本。
for(int i = 0; i < 5; i++) {
print(i);
}
for(int i = 0; i != 5; i++) {
print(i);
}
上面我所讲的内容对你来说可能有些“学院派”,接下来我们就从工程上来看一下变异测试的实践,这里我会基于PITest这个工具做详细讲解。
假设我们有一个Java程序,程序中有如下方法:
public class MyTester {
boolean isFixed(int input) {
return input > 0 && input <= 100;
}
}
针对这个方法,我们编写了如下的单元测试用例。
public class MyTester {
@InjectMocks
private MyTester myTester ;
@Before
public void init() {
MockitoAnnotations.initMocks(this);
}
@Test
public void fiftyReturnsTrue() {
myTester.isValid(50);
}
@Test
public void twoHundredReturnsFalse() {
myTester.isValid(200);
}
@Test
public void minus10ReturnsTrue() {
myTester.isValid(-10);
}
@Test
public void hundredReturnsTrue() {
myTester.isValid(100);
}
@Test
public void zeroReturnsFalse() {
myTester.isValid(0);
}
}
很明显,这些单元测试用例中都没有设置断言,尽管它们可以达到100%的代码覆盖率,但对检测程序的质量没有任何意义。
下面,我们开始使用PITest进行变异测试。它的用法很简单。假设我们的Java项目是通过Maven构建的,只需要在pom.xml文件中加入PITest插件依赖,然后执行mvn clean install命令,就能调用PITest完成变异测试的工作了。
测试完成后,PITest会自动生成HTML报告。结果很明显,尽管这些单元测试用例达到了100%的行覆盖率,但是变异覆盖率为0%,这说明这些用例都是无效的。
在PITest的测试报告中,给出了所使用的的变异算子的类型和变异体的存活情况,你可以看一下。
接下来,我们将测试用例补充完整,再通过PITest来看一下结果如何。
public class MyTester {
@InjectMocks
private MyTester myTester;
@Before
public void init() {
MockitoAnnotations.initMocks(this);
}
@Test
public void fiftyReturnsTrue() {
assertThat(myTester.isValid(50)).isTrue();
}
@Test
public void twoHundredReturnsFalse() {
assertThat(myTester.isValid(200)).isFalse();
}
@Test
public void minus10ReturnsTrue() {
assertThat(myTester.isValid(-10)).isFalse();
}
@Test
public void hundredReturnsTrue() {
assertThat(myTester.isValid(100)).isTrue();
}
@Test
public void zeroReturnsFalse() {
assertThat(myTester.isValid(0)).isFalse();
}
}
从下面的图中我们可以看到,这次变异覆盖率达到了100%。这说明这些测试用例都是有效的,即测试有效率为100%。
PITest在GitHub上有项目主页,建议你自己尝试着玩一下,这有助于你更好地理解变异测试的过程和精髓。PITest是基于Java语言的项目,如果你更擅长Python语言,可以尝试MutPy这个项目,它也是一个不错的变异测试工具。
我们可以将变异测试工具集成进CI/CD流水线中,在提交单元测试用例后自动触发执行并给出度量报告,这不仅能够可视化地展现测试用例的编写质量,还能作为衡量测试人员工作产出的科学指标。
今天,我们完整地介绍了变异测试的概念、理论、方法和应用。其中,两个基本假设和六个基本概念是变异测试的理论基础,需要你铭记在心。此外,我们基于PITest展示了变异测试在工程上的实践案例,希望你能亲自操刀。这能帮助你更好地理解变异测试,并落地应用到你的工程实践里。
为了帮助你记忆,这里我用图把这节课的内容总结了一下。
再多说几句,变异测试并不是一种新方法,早在1971年,变异测试的概念就已经被提出了 [ 参考文献1 ] ,并于1980年有了第一个变异测试工具。变异测试也不是一个冷门领域,在IEEE上用最严格的关键字搜索,也能得到超过3700多篇关于变异测试的论文。那么,为什么我们很少听到变异测试在工程上的实践呢?
这主要是由于变异测试的成本较高,比如等价变异体的识别,这是一个NP-hard问题(无法在多项式时间内被验证)。变异算子的选取也是个难题,如何选取恰当的变异算子以便最快速的度量测试用例的有效性,这些都是制约变异测试在工程上应用的重要因素。
不过可喜的是,学术界在这方面的研究一直比较活跃,在工程上我们也可以通过抽样的方式,降低变异体的数量,减少等价变异体出现的概率;通过大数据分析,得出相对合理的变异算子集合。随着变异测试的不断进步,我们有理由相信,变异测试将获得更大的发展前景。
希望今天的分享能够带给你不一样的思路和启发,也希望你能够举一反三,助推变异测试的发展。欢迎在视频下方的留言区与我讨论。
参考文献
[1] Richard Lipton, Fault Diagnosis of Computer Programs, 1971
[2] PITest Demo:https://github.com/mybreeze77/pitest-spring-boot2-demo