突变测试——Mutation Testing

853 阅读3分钟

突变测试是我在公司里给某个SDK添加一个功能的时候遇到的一个很有意思的用于检测单元测试质量的测试方法。

首先我们来看一下测试框架Stryker对于Mutation Testing的介绍:

Bugs, or mutants, are automatically inserted into your production code. Your tests are run for each mutant. If your tests fail then the mutant is killed. If your tests passed, the mutant survived. The higher the percentage of mutants killed, the more effective your tests are.

这里提到Bug或者说突变(Mutants)会被自动注入代码中,然后利用单元测试来检测是否能杀死这些突变。如果你的单元测试其中任意一个失败了,就说明这个突变被杀死了,反之则认为这个突变存活了下来。杀死的突变越多,我们的得分就会越高,从而说明我们的测试更有效,所以我们的目标就可以简化成需要在测试中让尽可能多地杀死突变。

在讨论具体有哪些突变之前,我们需要先弄清楚另一个问题,那就是单元测试中的代码覆盖率为什么不能够很好地达到检测单元测试质量的目的。

先来看个例子:

我们可以看到图中的code coverage的测试结果是100%,但是在Mutation Testing的结果可以说是惨不忍睹:

所以代码覆盖率并不一定等同于测试代码的质量:因为代码覆盖率只是测试用例的一个指标,而测试用例的设计和执行也受到多种因素的影响,甚至可能出现为了提升覆盖率而不写断言的测试。

接下来来看一个Mutation Testing的例子:

function isUserOldEnough(user){  
      return user.age >= 18;
}
​
/* 1 */ return user.age > 18;
/* 2 */ return user.age < 18;
/* 3 */ return false;
/* 4 */ return true; 

可以看到这里针对这个大于等于的判断做出了4种不同的变化

所以整个突变测试的流程是,首先确定有哪些地方可以进行突变,在插入突变体后,将这些含有突变的代码分别编译,如果项目复杂,突变的个数可能会有成百上千个,在编译这些突变的时候就会花费大量的时间;接下来针对每个不同的突变执行所有的单元测试,如果其中有一项测试失败,就标记成突变被杀死了。如果没有测试失败,则当前突变存活。你的单元测试越好,存活下来的突变体就越少,相应的得分也就越高。最后通过公式计算得分。

那么这个得分是怎么来的呢?

Total detected = # timedOut + # killed

Total undetected = # survived + # no coverage

Total mutants = # Total detected + # Total undetected

Mutation score = # Total detected / # Total mutants * 100

这里计算主要涉及到4类Mutants。

Killed 和Survived比较容易理解,这里解释一下其他的几个。

TimedOut 通常是指在突变之后,某些测试会花费太长的时间,大多数情况是因为产生了无限的循环,我自己遇到最常见的就是for循环中i++被修改成了i--;还有一种就是While循环的条件被修改成了true,这种其实可以认为突变被杀死,因为死循环说明程序突变结果是有问题的。

No coverage就比较容易理解了,从一开始就没有被测试覆盖到,所以这里即使突变了,也不会被覆盖。

剩下有两种没有列在上方的计算中,第一种是Error,即在突变时会导致程序异常,比如字符串拼接使用加号,但是突变成了减号,Error中包含了Runtime Error以及Compile Error;还有一种Ignored,可能是出于某种原因被标记忽略的,这种也不会算到最终的得分中。

接下来我们来看一下有哪些不同的Mutation,这里列举了一些,可以看到有对运算符号,大小相等等:

Arithmetic Operators (arithmetic)

OriginalMutated
+-
-+
*/
/*
%*

Equality Operators (equality)

OriginalMutated
<
>=
>=<
>=>
<>
<<=
<=>
<=<
==!=
!===
isis not
is notis

Logical Operators (logical)

OriginalMutated
&&||
||&&
==
andor
orand

由于种类很多,我这里就不都一一列出来了,Stryker针对.net的总共有这些:

有哪些Mutation.png

Mutation Testing有个非常明显的缺点,就是非常非常慢,之所以比较慢,主要是因为以下几个方面:

首先框架需要对代码进行大量的修改,生成各种变异体,并对每个变异体运行测试用例。这个过程相当耗时,特别是当代码规模较大时,时间会更长。如果测试用例的数量较多,那么测试时间也会相应增加。所以说Mutation Testing可能不适用于所有项目。比如我手头单元测试最多的服务,有四千多个单测,单跑一次所有的单元测试就要30-50分钟,如果突变有百来个,那就是几十万个测试了,没有个把小时是跑不完的。

那么如果最终的得分比较低,应该如何改进呢?

通常来说,之前写的SDK的要求突变测试的得分都在90分以上,我自己总结了以下三点:

首先我们要检查自己的测试是否充分,因为Mutation Testing仍然是一个用来“测试”测试的方法,检查自己是不是漏掉了什么边界case没有测试;

第二点,如果你觉得自己测试已经很OK了,那么检查原来的代码是不是有问题,因为你的代码通过注入bug或者突变都还能“按照预期运行”,显然你原来代码可能就存在bug或者可能可以简化

最后还可以通过config来设置忽略或者用注释来忽略某些可以突变的地方,因为确实有些case不太好用测试用例覆盖。


Reference:

stryker-mutator.io/docs/