不懂变异测试,你好意思说自己是测试工程师,今天让我(一个即将秃头的工程师)带你深入浅出理解变异测试的方方面面。
从测试覆盖率的局限性谈起
很多时候我们会用单元测试执行后的代码覆盖率来衡量测试的充分性和完整性,问题是有了很多测试用例,同时又有很高的白盒覆盖率,是否代码质量真的就高枕无忧了吗?
答案显然不是。来看个《软件研发效能提升之美》书里的例子就懂了。
比如下面的代码,测试执行后的代码覆盖率可以达到100%,但是代码中的问题并没有能暴露出来,这样的测试用例缺乏对于缺陷的发现能力,会导致测试用例数量不少,同时测试覆盖率也很高,但是缺陷依旧不能被发现的尴尬处境。
图1:覆盖率不等于有效性的例子
这里暴露出的本质问题是测试覆盖率不等于测试的有效性,那么测试的有效性又应该如何来衡量呢?这就是变异测试(Mutation Testing)需要解决的问题和存在的价值。
变异测试的基本概念
首先解释一下变异测试的概念。变异测试是一种基于错误注入的测试方式,具体来讲就是人为在代码中注入错误,然后来观察现有的测试用例是否能够发现这些错误,如果能够发现说明测试用例是有效的,如果不能发现说明测试用例需要进一步完善和补充。
你是不是有一种亲切感,这个不就是混沌工程(Chaos Engineering)的逻辑吗?没错,变异测试本质上就是代码级的混沌工程。
变异测试是新技术吗?
变异测试并不是什么新技术,变异测试的概念早在1971年由Richard Lipton提出,之后在1980年就已经出现了第一个变异测试的工具。这个历史远比混沌工程要早得多。
在学术界变异测试的研究已经持续了很长时间,研究的焦点主要集中在变异算法优化以及等价变异体分析上。
但是在工业界对变异测试的关注度一直很低,甚至很多测试技术人员压根不知道什么是变异测试,这背后的原因主要是因为变异测试需要在单元测试已经做得比较完备的基础上才有其价值,但是国内的单元测试现状不用我说,你也懂的,这也就制约了变异测试在工业界的实践。
实施变异测试的步骤
实施变异测试的简化步骤如下图所示。
图2:简化后的变异测试步骤
首选我们有被测源代码,以及对应的测试用例代码,随后在被测源代码P上,用变异算子S生成变异体源代码P’,这个过程称为变异体生成。用人话来讲其实就是对被测源代码进行“合乎语法的微小改动”,这种微小改动就是所谓的变异算子,比如原本是“加法”运算现在改成“乘法”运算,或者原本是“逻辑与”运算现在改成“逻辑或”运算,之后分别使用相同的测试用例T对被测源代码P和变异体源代码P’执行测试,最后比较测试执行结果。
如果两次测试结果都是通过的,说明变异注入的错误并不能被测试用例T感知,这种情况称为变异体能够“存活”,说明测试用例T的有效性存在问题,需要对测试用例进行补充和修正。
如果两次测试结果不同,暨在被测源代码P上执行测试通过,在变异体源代码P’上执行测试不通过,说明变异注入的错误能够被测试用例T感知到,测试用例T能够“杀死”此次变异,测试用例的有效性在此类变异上没有问题。
如果这么解释还是比较抽象的话,我这里使用变异测试工具MutPy基于Python语言来举个例子。
被测源代码是一个简单的乘法函数(图3),测试用例代码对该乘法函数的正确性进行了验证(图4),可见,测试代码执行后的代码覆盖率是100%。
图3:被测源代码
图4:测试代码
接下来使用MutPy发起变异测试,具体做法是在calculator.py和test_calculator.py的目录下执行以下命令行。
变异测试执行后的结果输出如图5所示,其中运用了4个变异算子,其中3个变异被杀死了,1个变异存活了下来。
4个变异算子分别实现了把乘法换成除法、取余、幂和直接返回。可以看到其中幂变异存活了下来,而其他3个变异都被杀死了,因此最后的变异得分是75%。这说明这个测试用例对于幂变异是无法发现的,所以我们需要对测试用例进行修正,这里最简单的方式就是把测试用例中的22=4换成23=6即可,这样再执行一遍变异测试就能杀死所有的变异体。
图5:MutPy变异测试执行结果
有了主观感受之后,我们再来看一下变异算子S的定义。
变异算子是在符合语法规则的前提下,将原有代码转变成极小差别代码(变异体)的转换规则,这些转换规则有一整套的定义,图6列出了这些常用变异算子的定义和缩写,上面的例子中就用到算数运算符替换AOR和语句删除SDL。
图6:常用变异算子的定义
主流变异测试工具使用简介
了解了变异测试的基本概念后,这里再给大家介绍一些目前比较主流的变异测试工具。
变异测试工具其实不少,但是很多都是学术界的产物,很难在工业界实际落地与应用。我这里挑选三款在工业界有实际应用价值的工具给大家做个简单的介绍。
Pitest
Pitest是针对Java目前主流的变异测试工具,其功能比较强大,属于可以应用于“真实世界”的变异测试工具。
Pitest不仅使用简单,执行性能优越,而且还提出了变异覆盖率的指标,变异覆盖率指标可以和代码覆盖率指标同时使用。
比如下面图7中的Pitest测试报告,其中:
浅绿色的代码行表示已经被测试用例覆盖(比如125行和126行);
深绿色的代码行表示已经被变异测试覆盖(比如123行和123行,最左边的3代表这一行已经覆盖了3种变异);
浅粉红色的代码行表示没有被测试用例覆盖(比如130行);
深粉红色的代码行表示还没有被变异测试覆盖(比如127行和128行,这两行最左边的1代表这一行需要覆盖1种变异);
图7:Pitest测试报告中的覆盖率
由于Pitest比较实用,我这里给大家举一个完整的实际案例来帮助大家更好地理解。
首先,图8是被测代码,图9是测试用例代码。
图8:被测代码
图9:测试用例代码
可以发现图9中的单元测试有很明显的问题,作为测试居然没有任何断言Assert,同时最后一个单元测试的语义也是错的,这样的测试用例有效性是完全不符合要求的。但是这种明显不符合要求的单元测试覆盖率却可以达到100%,如图10所示。这里再次印证了覆盖率不等于有效性。
图10:单元测试覆盖率结果100%
此时如果用Pitest去执行变异测试,得到的结果报告就能反映出问题了,如图11所示。
图11:Pitest的覆盖率报告能反映问题
从Pitest的报告中可以看出来,虽然代码覆盖率是100%,但是变异覆盖率却是0%,为此我们需要对测试用例进行改进,改进后的测试用例如图12所示,我们增加了测试用例的断言Assert,同时修正了最后一个测试用例的语义错误。
图12:改进后的测试用例
之后再次用Pitest去执行变异测试,得到的结果报告如图13所示。可见此时变异覆盖率达到了60%,结合具体的覆盖率详情(图14),我们发现第9行代码的5种变异中,有2个边界值条件存活了,这说明当前的测试用例并没有考虑边界值的场景。
图13:改进测试用例后的Pitest覆盖率报告
图14:Pitest覆盖率详情
为此,我们再次改进测试用例,把0和100的两个边界值场景纳入测试范围,增补以下两个测试用例(图15),然后再次执行Pitest后的结果如图16所示,此时变异覆盖率也达到了100%,至此测试用例的有效性问题就被彻底修复了。
图15:增补2个边界值的测试用例
图16:Pitest最终的覆盖率报告
Stryker Mutator
Stryker Mutator(图17)是针对JavaScript,C#,和Scala的变异测试工具,使用方式和原理大同小异,具体可以参见https://stryker-mutator.io/, 这里就不再展开了。
图17:Stryker Mutator
MuDroid
MuDroid对Android应用程序的变异测试提供全过程支持,包括变异体自动生成、变异测试自动执行、变异结果分析和测试结果报告生成。这个工具最大的特点是提供变异测试服务化的能力,并且提供非常详实的报告界面(图18)。
图18:MuDroid变异测试报告
变异测试的工程化实践(纯干货)
理解了变异测试概念和工具之后,我们来看一下在软件企业中开展变异测试的工程化实践(图19),建议你先看一下图中的主要流程,然后再往下阅读。
图19:变异测试的工程化实践
工程化实践图中展示了以下6个关键实践,我们依次说明。
通过CI流水线的集成完成变异测试的全流程,实现单元测试的有效性评估与持续改进。
变异测试的执行可以用流水调度实现并发运行,降低变异测试执行时间,这个对于大型的软件项目尤为关键。
变异测试衡量的是单元测试的有效性,那么对于单元测试尚未覆盖的代码就没有用武之地,所以变异测试的范围可以和单元测试的覆盖率相结合,只在有覆盖的代码上实现变异,提升变异测试的投入产出比,避免变异体数量过大。
变异测试可以和精准测试相结合,每次变异可以根据代码变更的Code Diff来生成,实现变异测试的精准化。
在工程实践中,不能以完全杀死变异体为目标,因为这样做的成本很高,所以需要建立变异分门禁的计算,通过变异分来判断测试有效性达标的情况。变异分门禁的计算会依赖于代码的使用热度、频繁变更度、变异覆盖率和Code Diff进行综合计算。
测试用例的增补和修改可以实现智能化,采用自动修正测试用例加上人工确认的方式,进一步提升效率。比如上面Pitest例子中的边界值验证用例就能实现自动修复。
变异测试在接口测试中的应用与探索
前文中我们说过,变异测试依赖单元测试,单元测试如果做的不好,变异测试无从谈起,而国内的很多软件企业在单元测试这里一直是软肋,那么在其他的测试类型中是否也可以使用变异测试来对测试本身的有效性进行评估呢?
这里最有价值的探索就是在接口测试中引入变异测试的理念。随着微服务架构的不断普及,接口测试用例的数量一直在持续增长,如何评判和优化接口测试用例的有效性成为了关键问题,而变异测试恰好可以解决这些问题,但是在落地的过程中会遇到很对新问题,其中最关键的问题是单元测试不需要部署环境可以直接运行的,而接口测试却需要部署服务,而且每个变异体都需要单独部署,这就需要一系列配套的技术和工程实践来解决这些问题,比如用AOP的JVM-sandbox动态构造变异体,使用JVM Hotswap机制实现热部署,使用基于容器化的特性环境实现基础部署等,如果大家对这部分内容感兴趣可以持续关注我的公众号,我会在后续的文章给出解读。