突变检测:两个套间的故事

2020-08-20 19:28:02

2020年8月17日,2020年1月,Etsy工程公司启动了一项备受期待的计划。多年来,我们的前端工程师一直在使用内部开发的JavaScript测试框架。它将Jasmine用于断言和语法,将PhantomJS用于测试环境,并使用PHP编写的自定义、从头构建的测试运行器。此设置不再满足我们的需求,原因有很多:

现在是时候开发一个具有强大社区和JavaScript开发人员期望的特性列表的行业标准工具了。我们之所以选择Jest,是因为它符合所有这些标准,并且自然地补充了使用Reaction构建的站点区域。在几个月内,我们就做好了所有必要的基础工作,开始大规模迁移我们的遗留测试。这提出了一个重要的问题-如果我们的测试套件由Jest运行,而不是由我们的遗留测试运行器运行,那么它在捕获回归方面是否同样有效?

所以他们不是在做同样的事情吗?也许吧。如果一个检查浅等式,另一个检查深等式,会怎么样呢?那么,在Jest中没有推论的断言又如何呢?我们的遗留套件依赖于jasmine.ajax和jasmine-jQuery,在迁移测试时,我们需要为这两个模块提出替代方案。所有这些都为细微变化的潜移默化打开了大门,并决定着捕捉到还是错过了一个错误。我们本可以花时间仔细研究Jasmine和Jest的源代码,以确定这些差异是否真的存在,但是我们决定使用突变测试来为我们找到它们。

突变测试允许开发人员根据他们的测试套件可以捕获多少潜在的bug来给他们的测试套件打分。因为我们正在测试我们的JavaScript套件,所以我们选择了Stryker,它的工作方式与任何其他突变测试框架大致相同。Stryker分析我们的源代码,制作任意数量的副本,然后通过编程向其中插入错误来改变这些副本。然后,它针对每个“变种”运行我们的单元测试,并查看套件是否失败或通过。如果所有测试都通过了,那么变种人就活下来了。如果一个或多个测试失败,那么突变体就被杀死了。被杀死的突变体越多,我们就越有信心套件会捕捉到我们代码中的回归。在测试了所有这些潜在的突变后,Stryker通过将杀死的突变体数量除以生成的总数量来生成一个分数:

Stryker的默认报告甚至显示了它是如何产生幸存下来的突变体的,因此很容易识别套件中的空白。在这种情况下,两个条件表达式突变体和一个逻辑运算符突变体幸存下来。总而言之,Stryker支持大约30种可能的突变类型,但为了更快的测试运行,该列表可以缩减。

由于我们的假设是,Jasmine和Jest之间的实现差异可能会影响遗留测试套件和新测试套件的突变分数,因此我们首先对遗留套件中茉莉花特定的语法进行了分类。然后,我们编译了大约40个测试文件的列表,我们将针对这些文件进行突变测试,以便覆盖完整的语法目录。对于每个文件,我们为其遗留状态生成一个变异分数,将其转换为在我们的新Jest设置中运行,然后再次生成一个变异分数。我们希望新的Jest框架的突变分数能与我们的旧框架一样好,甚至更好。

通过将我们的测试范围限制在几十个文件内,我们能够在合理的时间范围内运行Stryker提供的所有突变。然而,我们的代码库的绝对大小和任意给定功能中杂乱无章的依赖树给这项工作带来了其他挑战。正如我前面提到的,Stryker将要变异的源代码复制到单独的沙箱目录中。默认情况下,它会将整个项目复制到每个沙箱中,但是Node.js在我们的存储库中无法处理:

Stryker允许用户配置要复制的文件数组,而不是整个代码库,但这样做需要我们提前知道希望测试的每个文件的完整依赖树。我们没有手动解决这个问题,而是专门为我们的Stryker测试环境编写了一个自定义Jest文件解析器。它将尝试从本地目录结构中解析源文件,但如果找不到它们,它不会立即失败。相反,我们的新解析器将到达Stryker沙箱之外,查找原始目录结构中的文件,将其复制到沙箱中,然后重新启动解析过程。此方法为具有非常扩展的依赖关系树的文件节省了时间。掌握了这一点,我们就加紧进行我们的实验。

最终,我们发现我们的新Jest框架比我们的旧框架有更差的突变得分。

这是真的。平均而言,我们的传统框架运行的测试获得了55.28%的突变分数,而我们的新Jest框架运行的测试获得了54.35%的突变分数。在我们最糟糕的一个案例中,遗留测试获得了35%的分数,而Jest测试只获得了区区16%的分数。

一旦我们开始在大量文件上看到较低的突变得分,我们就暂停迁移,以调查哪些类型的突变正在滑过我们的新套件。事实证明,我们的新Jest套件未能捕捉到的大部分内容都是异步模块定义中的字符串文字突变:

我们进一步挖掘这些失败,发现真正的罪魁祸首是不同的测试运行人员如何编译我们的代码。我们的遗留测试运行器是为处理Etsy独特的代码库而定制的,并且与我们基础设施的其余部分紧密耦合。当我们启动测试时,它将定位所有相关的源代码和测试文件,在实际的webpack构建过程中运行它们,然后将结果代码加载到PhantomJS中执行。当webpack在依赖项定义中遇到空字符串时,它将抛出错误并停止测试,从而有效地捕获错误,即使没有实际依赖于该依赖项的测试也是如此。

另一方面,Jest能够使用其文件解析器和少量自定义映射和转换器绕过我们的构建系统。这首先是迁移的最大吸引力之一;将测试与构建过程分离意味着它们可以在很短的时间内执行。然而,我们在Jest中用来管理依赖关系的模块比我们实际的构建系统宽松得多,空字符串被简单地忽略了。这意味着,除非测试实际上依赖于依赖项,否则如果意外遗漏了依赖项,我们的Jest设置无法向测试人员发出警报。最终,我们决定让这种bug溜之大吉是可以接受的。虽然它不再在测试阶段被捕获,但是代码仍然会被CI管道的构建阶段拒绝,从而阻止bug进入生产。

在我们进行迁移的过程中,我们遇到了少数几个突变得分明显不同的案例,其中一个特别值得注意。我们碰巧遇到一个异步测试,它使用Done()回调来表示测试应该何时退出。测试的格式不正确,因为有两个Done()回调,它们之间有断言。说真的,这没什么大不了的;在测试结束之前,它很高兴地执行了额外的断言。不过,茉莉要严格得多。遇到第一个回调时,它会立即停止测试。结果,我们看到突变分数有了显著的跃升,因为突变体突然被悬而未决的断言抓住了。这证实了我们的猜想,即Jasmine和Jest之间的实现差异可能会影响哪些bug被捕获以及哪些bug被漏掉。

在这个实验过程中,我们学到了大量关于我们的测试框架和突变测试的一般知识。Stryker为被测试的40个左右的文件生成了超过3800个突变,这相当于每个文件大约95次测试运行。在所有的透明度中,这个数字可能会被人为地降低,因为我们排除了一些我们最初识别用于测试的文件,当时我们意识到它们会产生数百个突变。如果我们假设我们计算的平均值表示所有文件,并考虑运行整个Jest套件所需的时间,那么我们可以估计完成整个JavaScript代码库的单线程完整突变测试将需要大约五年半的时间。诚然,Stryker并行了开箱即用的测试运行,并且我们可以使用Jest的findRelatedTests特性来缩小根据哪个文件被变异来运行哪些测试的范围,从而潜在地看到更多的性能提升。即便如此,很难想象在任何规律的节奏下运行一次完整的突变测试。

虽然Etsy测试代码库中所有可能的突变可能不可行,但我们仍然可以通过在更细粒度的级别上应用突变测试来深入了解我们的测试实践。一种易于管理的方法是在任何时候打开拉取请求时自动生成突变分数,并且只将测试重点放在更改的文件上。在拉请求上发布该信息可以帮助我们理解哪些条件会导致我们的单元测试失败。编写一个无论如何都能通过的过于宽松的测试是很容易的,在某些方面,这比根本没有测试更危险。如果我们只看代码覆盖率,这样的测试会增加我们的数量,给我们一种错误的安全感,认为错误会被捕获。突变得分迫使我们直面套件的局限性,并鼓励我们尽可能有效地进行测试。