如何评估测试质量? 许多人依靠每个人都知道的最流行的度量标准-代码覆盖率。 但这是定量的,而不是定性的指标。 它显示了测试涵盖了多少代码,但没有很好地编写这些测试。
解决这个问题的一种方法是通过突变测试。 该工具对源代码进行了微小的更改,然后重新运行测试,使您可以识别无用的测试和低质量的测试范围。
在
三月份的
Badoo PHP聚会上,我谈到了如何组织PHP代码的突变测试以及您可能遇到的问题。 该视频可
在此处获得 ,对于文本版本,欢迎关注。

什么是突变测试
为了解释我的意思,我将向您展示一些示例。 它们很简单,在某些地方被夸大了,而且看起来似乎很明显(尽管真实的例子通常很复杂,而且用肉眼无法看到)。
考虑一下情况:我们有一个声称是成年人的基本功能,并且有一项测试可以对其进行测试。 该测试具有一个dataProvider,即它测试两种情况:年龄17岁和年龄19岁。 我认为对于很多人来说,成人很明显具有100%的覆盖率。 唯一的行。 它是通过测试执行的。 一切都很棒。

但是仔细检查后发现,我们的提供者的文字写得不好,没有测试边界条件:未测试18岁的边界条件。 您可以将>符号替换为> =,并且测试不会捕获此类更改。
另一个例子,稍微复杂一些。 有一个函数可以构建一些包含setter和getter的简单对象。 我们设置了三个字段,并且进行了一项测试,以检查buildPromoBlock函数是否确实收集了我们期望的对象。

如果仔细观察,我们还有setSomething,它将某些属性设置为true。 但是在测试中,我们没有这样的主张。 也就是说,我们可以从buildPromoBlock中删除此行-我们的测试将不会捕获此更改。 同时,由于在测试期间执行了全部三行,因此buildPromoBlock函数具有100%的覆盖率。
这两个例子将我们带到什么是突变测试。
在拆卸算法之前,我将给出一个简短的定义。 变异测试是一种机制,它使我们可以对代码进行较小的更改,以模仿邪恶的Pinocchio或Junior Vasya的行为,后者是有目的的并且开始故意破坏它,用<,= by!=,等替换>字符。 对于出于良好目的而进行的每个此类更改,我们都会运行测试,以覆盖更改后的行。
如果测试没有向我们显示任何内容,或者没有通过测试,则可能效果不佳。 它们不测试边界情况,不包含断言:也许它们需要改进。 如果测试失败,那么它们很酷。 他们确实可以防止这种变化。 因此,我们的代码很难破解。
现在让我们分析一下算法。 这很简单。 我们执行变异测试的第一件事是获取源代码。 接下来,我们获得代码覆盖率,以了解针对哪个字符串运行哪些测试。 之后,我们遍历源代码并生成所谓的变体。
突变体是单个代码更改。 也就是说,我们采用某个函数进行比较,其中有一个>符号,如果我们将这个符号更改为> =-,则会得到一个突变体。 之后,我们运行测试。 这是一个突变的示例(我们用> =替换了>):

在这种情况下,突变不是随机进行的,而是根据某些规则进行的。 突变测试响应是幂等的。 无论我们在同一代码上运行突变测试多少次,它都会产生相同的结果。
我们要做的最后一件事是运行覆盖突变线的测试。 使其脱离覆盖范围。 有驱动所有测试的非最佳工具。 但是,一个好的工具只会驱走那些需要的工具。
之后,我们评估结果。 测试失败了-那么一切都很好。 如果它们没有跌倒,那么它们就不是很有效。
指标
变异测试能为我们提供哪些指标? 它在代码覆盖率方面增加了三个,我们现在将讨论。
但是首先,让我们分析一下术语。

这里有被杀死的突变体的概念:这些突变体是我们的测试“钉住”的(即,他们抓住了它们)。

有逃生突变体(幸存突变体)的概念。 这些是设法避免惩罚的突变体(也就是说,测试没有抓住他们)。

并且有涵盖突变的概念-测试涵盖了一个突变,而与之相反的一个未发现的突变则根本没有任何测试涵盖(即我们有代码,它具有业务逻辑,我们可以更改它,但不能进行单个测试不检查更改)。
突变测试为我们提供的主要指标是MSI(突变得分指标),即被杀死的突变体数量与其总数之比。
第二个指标是突变代码覆盖率。 它只是定性的,而不是定量的,因为它显示了您可以打破并定期执行的业务逻辑的数量,我们的测试已被捕获。
最后一个指标是MSI,即较软的MSI。 在这种情况下,我们仅针对测试涵盖的那些突变体计算MSI。
变异测试问题
为什么不到一半的程序员听说过此工具? 为什么没有到处使用它?
低速
第一个问题(主要问题之一)是突变测试的速度。 在代码中,如果我们有数十个变异运算符,即使对于最简单的类,我们也可以生成数百个变异。 对于每个突变,您都需要运行测试。 举例来说,如果我们有5,000个单元测试运行十分钟,那么变异测试可能要花费数小时。
可以做什么来达到此水平? 在多个线程中并行运行测试。 将溪流扔进几辆汽车。 可以用
第二种方法是增量运行。 无需每次都计算整个分支的突变指标-您可以进行分支差异分析。 如果使用功能早午餐,则将很容易执行此操作:仅对已更改的文件运行测试,并查看向导中发生的情况,进行比较和分析。
您可以做的下一件事是突变调整。 由于可以更改突变算子,因此您可以设置它们的工作规则,然后,如果它们故意导致问题,则可以停止某些突变。
重要一点:突变测试仅适用于单元测试。 尽管可以运行它来进行集成测试,但这显然是一个失败的想法,因为集成(如端到端)测试运行速度慢得多,并且影响的代码更多。 您将永远不会等待结果。 原则上,这种机制是专门为单元测试发明和开发的。
无尽的突变
突变测试可能引起的第二个问题是所谓的无穷突变。 例如,有一个简单的代码,一个简单的for循环:

如果将i ++替换为i--,则循环将变为无限。 您的代码将保留很长时间。 突变测试经常会产生此类突变。
您可以做的第一件事就是调整突变。 显然,在for循环中将i ++更改为i--是一个非常糟糕的主意:在99%的情况下,我们最终将陷入无限循环。 因此,我们禁止在我们的工具中执行此操作。
保护您免受此类问题影响的第二个也是最重要的事情是运行超时。 例如,相同的PHPUnit能够完成超时测试,而不管它停留在哪里。 PHPUnit通过PCNTL挂断回调并计算时间本身。 如果测试在某个时期内失败,则只需将其钉上钉子,并将这种情况视为已杀死的突变体,因为生成突变的代码实际上已由测试检查,这确实抓住了问题,表明该代码已失效。
相同的突变体
突变测试理论中存在此问题。 实际上,他们并不经常遇到这种情况,但是您需要了解它。
考虑一个说明它的经典示例。 我们将变量A乘以-1,将A除以-1。 在一般情况下,这些操作会导致相同的结果。 我们更改A的符号。因此,我们有一个突变,允许两个符号相互改变。 这样的变异不会破坏程序的逻辑。 测试并且不应该抓住它,不应该掉下去。 由于这种相同的突变体,出现了一些困难。
没有通用的解决方案-每个人都以自己的方式解决此问题。 也许某种突变注册系统会有所帮助。 Badoo我们正在考虑类似的事情,我们将模仿它们。
这是一个理论。 那PHP呢?
有两种众所周知的用于突变测试的工具:Humbug和Infection。 当我准备这篇文章时,我想谈一谈哪个更好,然后得出结论,这就是感染。
但是,当我进入Humbug页面时,我看到了以下内容:Humbug宣布自己过时了,转而支持Infection。 因此,我的文章的一部分被证明是没有意义的。 因此,感染是一个非常好的工具。 我必须感谢明斯克的
borNfree创造了它。 他真的很酷。 您可以直接从盒子中取出它,将其放入作曲家并启动。
我们真的很喜欢感染。 我们想使用它。 但是它们不能有两个原因。 感染需要覆盖代码才能正确正确地测试突变体。 在这里,我们有两种方法。 我们可以在运行时直接计算它(但是我们有100,000个单元测试)。 或者我们可以为当前的主服务器进行计算(但是在我们的由十个功能非常强大的计算机组成的云中构建多个线程需要一个半小时)。 如果我们在每次突变运行中都执行此操作,则该工具可能无法正常工作。
有一个选项可以填充完成的文件,但是在PHPUnit格式中,这是一堆XML文件。 除了它们包含有价值的信息外,它们还拖延了一些结构,一些括号和其他内容。 我认为总体而言,我们的代码覆盖范围约为30 GB,我们需要将其拖动到所有云计算机上,并不断从磁盘读取。 总的来说,这个想法是马马虎虎。
第二个问题更加严重。 我们有一个很棒的
SoftMocks库。 它使我们能够处理难以测试的旧代码,并为它成功编写测试。 尽管我们正在以不需要我们使用SoftMocks的方式编写新代码,但我们正在积极使用它并且在不久的将来不会拒绝它。 因此,该库与Infection不兼容,因为它们使用几乎相同的方法来更改更改。
SoftMocks如何工作? 他们拦截文件包含并将其替换为修改后的包含,即,SoftMocks不在执行类A的情况下,在另一个位置创建类A并连接了另一个而不是原始的类。 感染的行为完全相同,只是它通过
stream_wrapper_register()起作用,它的作用相同,但在系统级别。 结果,SoftMocks或Infection都可以为我们工作。 由于SoftMocks是我们测试所必需的,因此很难使这两个工具成为朋友。 这可能是可能的,但是在这种情况下,我们陷入了感染的深渊,以至于这种变化的意义就完全消失了。
克服困难,我们编写了我们的小工具。 我们从Infection借用了变异运算符(它们写得很酷并且非常易于使用)。 我们不是通过stream_wrapper_register()启动突变,而是通过SoftMocks运行它们,也就是说,我们从框中使用我们的工具。 我们的toolza是我们内部代码覆盖服务的朋友。 也就是说,它可以在不运行所有测试的情况下按需接收文件或行的覆盖率,这很快就会发生。 但是,这很简单。 如果Infection具有各种各样的工具和功能(例如,在多个线程中启动),那么我们就没有。 但是我们使用我们的内部基础结构来弥补这一缺点。 例如,我们在整个云中的多个线程中运行相同的测试。
我们如何使用它?
首先是手动运行。 这是第一件事。 您编写的所有测试均通过变异测试手动验证。 看起来像这样:

我对某些文件进行了突变测试。 得到了结果:16个突变体。 其中有15人被测试杀死,其中一个因错误摔倒。 我没有说突变会导致死亡。 我们可以轻松地更改某些内容:使返回类型无效或其他。 这是有可能的,因为我们的测试将开始下降,它被认为是杀死的突变体。
但是,感染有时会将此类突变体区分为单独的类别,原因是有时值得特别注意错误。 碰巧发生了一些奇怪的事情-并没有正确地认为该突变体被杀死。
我们使用的第二件事是母版上的报告。 每天晚上,开发基础结构处于空闲状态时,我们会生成代码覆盖率报告。 之后,我们制作相同的突变测试报告。 看起来像这样:

如果您曾经看过有关PHPUnit代码覆盖率的报告,您可能会注意到该接口是相似的,因为我们是通过类比来制作工具的。 他只计算了目录中特定文件的所有关键指标。 我们还设定了某些目标(实际上,我们是从最高处提出来的,因此尚未遵守,因为我们尚未决定每个指标应指导哪些目标,但是这些目标存在,以便将来轻松生成报告)。
最后一点,最重要的是其他两个方面的结果。 程序员是懒惰的人。 我很懒:我喜欢一切正常的工作,而不必做额外的手势。 我们这样做的目的是,当开发人员推动自己的分支机构时,将自动递增地计算其分支机构和早午餐主控者的指标。

例如,我运行了两个文件并获得了此结果。 在另一个主文件中,我有548个突变体,有400个被杀死,根据另一个文件-147个对63个。 但是在第一个文件中,突变体被钉住了,在第二个文件中,他逃脱了。 MSI指标自然会下跌。 这样的事情甚至可以使那些不想浪费时间用手进行突变测试的人看到他们做得更糟的事情并予以关注(与审阅代码的过程完全一样)。
结果
仍然很难给出任何数字:我们没有任何指标,现在它已经出现了,但没有可比的指标。
我可以说突变测试从心理效果上讲是有效的。 如果您开始通过变异测试来运行测试,那么您会不由自主地开始编写更好的测试,而编写质量测试不可避免地导致编写代码的方式发生变化-您开始认为您需要涵盖所有可能破坏的情况,然后再开始更好的结构,使其更具可测试性。
这是排他性的主观意见。 但是我的一些同事给出了大致相同的反馈:当他们开始在工作中不断使用变异测试时,他们开始更好地编写测试,许多人说他们开始更好地编写代码。
结论
代码覆盖率是需要监控的重要指标。 但这指标不能保证任何事情:这并不意味着您安全。
变异测试可以帮助您更好地进行单元测试,并且跟踪代码覆盖范围很有意义。 已经有一个用于PHP的工具,因此,如果您有一个没有麻烦的小项目,请立即尝试。
至少通过手动运行突变测试开始。 采取这个简单的步骤,看看它能为您带来什么。 我确定你会喜欢的。