跳转到内容

变异测试详解

Spec: ISO/IEC/IEEE 29119-4 Spec: PHPUnit Evidence: Test-backed

行覆盖率只告诉你某一行代码在测试套件运行期间被执行过。它并不会告诉你:如果那一行写错了,是否会有测试失败。变异测试会刻意破坏代码,再检查测试能否发现,从而补上这个缺口。本页说明变异分数的含义,以及 NextPDF 如何把它用作诊断工具,而不是奖杯。

覆盖率是测试领域中最受信赖的指标,也是最容易误导人的指标之一。一个只调用方法却不做任何断言的测试,会执行其中每一行——覆盖率看起来完美,发现问题的能力却为零。标准文献明确指出,覆盖率准则之间的包含关系,并不能说明它们暴露错误的能力。那种能力正是它所称的测试有效性ISO/IEC/IEEE 29119-4,§C.2.4)。覆盖率百分比和找错保证,是两种不同的主张。

对 PDF 引擎而言,这并非学术空谈。一个签名的字节范围检查、一个交叉引用偏移量、一个编码分支——测试可以完全「覆盖」这一切,却从未断言真正重要的那个值。在薄弱测试之上亮绿灯的套件,比一个诚实暴露的缺口更糟,因为它会让所有人不再深究。

  • 变异测试会对源代码进行数以千计的、刻意设计的微小修改(变异体)——把 < 改成 <=、把 + 改成 -、改动一个 return 值——并针对每一个变异体重新运行测试。
  • 如果某个测试在某个变异体上失败,该变异体就被杀死(killed):这表示确实有测试断言了那项行为。如果所有测试仍然通过,该变异体就逃脱(escaped):那项行为被执行了,却从未受到检查。
  • **变异分数指标(MSI)**大致上是「被杀死的变异体」除以「非等价变异体总数」。它衡量的是你的测试是否检测到变更,而不是它们是否执行了代码。
  • 有些变异体是等价的(equivalent)——它们无法改变可观察的行为,因此没有任何测试能杀死它们。把这些计为失败并不诚实。NextPDF 会加以证明并写入账本,而不是含糊地把它们略过。
  • NextPDF 使用 MSI 来找出并强化薄弱测试。它是持续集成中的一道诊断闸门,而不是营销数字。

变异测试通过 Infection 变异器在引擎上运行。它配置在生产源代码树之上,并启用算术、布尔、条件边界、相等性、返回值与移除等变异器家族——正是那些会暴露「已执行但未断言」逻辑的操作符。整个流程是机械化的:

  1. Start green The suite must pass before mutation begins.
  2. Mutate Apply one small, deliberate change to the source.
  3. Re-run Run the tests that cover the mutated line.
  4. Killed A test failed — the behaviour is genuinely asserted.
  5. Escaped All tests still pass — a weak spot to strengthen.
  6. Equivalent No test can kill it because behaviour is unchanged — proven and ledgered, not scored as a miss.
NextPDF 运行的变异测试循环:取得绿灯的测试、产生一个变异体、重新运行涵盖该处的测试,并把该变异体分类为被杀死(有测试拦截它)、逃脱(一个有覆盖却无断言的缺口,需修补),或已证明等价(没有任何测试能杀死它;记入帐本,不计入分数)。

有两项设计选择让这个数字值得信赖。第一,这个分数会作为一道闸门接入流程。持续集成会强制要求一个最低 MSI(以及一个最低的已覆盖 MSI),并针对变更的代码行运行差异范围版本。因此,一项只添加代码、却未加入真正断言的变更,会在审查阶段就被拦下,而不是事后才被发现。第二,NextPDF 不会悄悄地把难处理的变异体打折扣。那些真正语义等价的变异体——例如当严格类型保证两个操作数同属一种类型时,!== 相对于 !=——会连同一个明确的等价性证明测试,记录到变异账本中。因此,逃脱计数反映的是真实缺口,而不是记账操作。PHPStan Level 10 加上 strict_types 再加上类型化属性,正是让那些等价性证明站得住脚的关键。

Evidence: Test-backed 变异测试的配置位于引擎之中,涵盖生产源代码目录,并启用能暴露行为差异的变异器家族。它作为一道持续集成闸门强制运行, 搭配一个最低 MSI 与一个差异范围版本。它是一项构建检查,而不是事后补充。

Evidence: Test-backed 等价变异体问题的处理方式很诚实。语义等价的变异体会被分类,并由变异账本中专属的等价性证明测试支撑,而每项证明的可靠性都建立在 PHPStan Level 10 加上严格类型之上。因此逃脱计数代表的是真实、未被检测到的行为,而不是被算进去后把分数拉低的不可杀死噪声。

Evidence: Standard-backed 变异是一项公认的技术, 并非 NextPDF 的发明。 Spec: ISO/IEC/IEEE 29119-4, §B.2.4 说明的是:对规格的各个元素应用通用变异,借以派生出用于测试的特定变异。之所以需要这项技术,正是因为同一份标准指出:覆盖率准则的包含(subsumes)排序,并不会按其暴露错误的能力来排序它们 (ISO/IEC/IEEE 29119-4,§C.2.4)。

Evidence: Standard-backed 覆盖率本身定义明确, 但有其局限性。 Spec: PHPUnit 区分了行、分支与路径覆盖率。行覆盖率只记录一条可执行代码行被执行过。理解它的定义,恰恰会让它的不足变得显而易见。

重点不在于那个指令——而在于一个逃脱的变异体告诉了你什么:

<?php
declare(strict_types=1);
final class ByteRange
{
// Suppose the production guard is:
// if ($offset < 0) { throw new InvalidByteRange(); }
public function assertNonNegative(int $offset): void
{
if ($offset < 0) {
throw new InvalidByteRange('offset must be >= 0');
}
}
}
// A test that EXECUTES this line but does not assert the boundary:
// $byteRange->assertNonNegative(5); // no exception expected, none asserted
// gives 100% line coverage of assertNonNegative().
//
// Mutation flips `< 0` to `<= 0`. Behaviour now differs ONLY at $offset === 0.
// If no test passes 0 and asserts what happens, every test still passes:
// the mutant ESCAPED. Coverage said "tested"; mutation said "the boundary
// is unasserted". The fix is a test that pins offset === 0, not a higher
// target.
//
// composer mutation:diff → mutate only changed lines, enforce min MSI
// composer mutation:full → full-tree mutation gate

那个逃脱的变异体正是全部价值所在。它定位到一个真实、具体、缺失的断言,而覆盖率报告却把它评为已经完整测试。

最常见的误解,是把变异分数当成一个需要最大化的成绩。为了杀死变异体而写测试获得的极高 MSI,跟调用方法却不做断言获得的高覆盖率一样空洞。这个指标已经被操纵,不再衡量检测能力。NextPDF 使用 MSI 来找出薄弱测试。交付成果是一个更好的断言;它明确不是为了炫耀。

第二个误解是:每一个存活下来的变异体都代表测试中的一项缺陷。有些变异体确实是等价的,无法被任何测试杀死,因为它们并不会改变可观察的行为。把它们算作失败,会产生一个不诚实、被人为压低的分数,并让人们养成忽略报告的习惯。NextPDF 的做法是明确证明等价性并写入账本,而不是悄悄压下它,或假装这个数字比实际更差。

变异测试测量的是测试是否检测到被注入的变更。它并不能证明代码是正确的。它不测量性能或合规性。它无法杀死一个真正等价的变异体。当前的变异分数、现行的最低 MSI 门槛、已写入账本的等价体数量,以及任何覆盖率数字,都是由持续集成产物生成、并随构建发布的实时质量信号。这里刻意不写出这些数字,因为一个贴进文本叙述里的数字会过时,并变成一个小小的谎言。本页唯一陈述的稳定事实是 PHPStan Level 10,而这是一项支撑等价性证明的配置属性,并非一项测量值。

变异器的选择、各项门槛与账本政策,都由引擎的变异配置管理,且可能会演进。如果该配置与本页有任何不一致,以该配置为准。本页不对任何其他库的测试有效性作出任何主张。

  • NextPDF 测试金字塔——这五个层级的测试,正是变异测试所审查、用来验证其真实错误检测能力的对象。
  • 严格类型,无处不在——PHPStan Level 10 与严格类型如何让等价变异体的证明站得住脚。
  • 黄金文件测试——另一个测试层级;变异测试有助于验证它的检测能力。
  • 变异体(Mutant)——对源代码进行的单一、刻意的微小变更,用以测试测试套件是否会发现该变更。
  • 被杀死的变异体(Killed mutant)——一个使至少一项测试失败的变异体;该行为确实得到了断言。
  • 逃脱的变异体(Escaped mutant)——一个让每一项测试都仍然通过的变异体。该行为被执行了,却从未被断言——这是一个待修补的弱点。
  • 等价变异体(Equivalent mutant)——一个无法改变可观察行为的变异体,因此没有任何测试能杀死它。NextPDF 会证明这些变异体并写入账本。
  • MSI(变异分数指标,Mutation Score Indicator)——大致上是被杀死的变异体除以非等价变异体总数;它是检测能力的衡量,而不是执行量的衡量。
  • 行覆盖率(Line coverage)——一项只记录某条可执行代码行在测试套件运行期间被执行过的指标;由 PHPUnit 定义,单靠它并不足够。