变异测试详解
Spec: ISO/IEC/IEEE 29119-4 ISO/IEC/IEEE 29119-4 Spec: PHPUnit PHPUnit Evidence: Test-backed
行覆盖率只告诉你某一行代码在测试套件运行期间被执行过。它并不会告诉你:如果那一行写错了,是否会有测试失败。变异测试会刻意破坏代码,再检查测试能否发现,从而补上这个缺口。本页说明变异分数的含义,以及 NextPDF 如何把它用作诊断工具,而不是奖杯。
为什么这很重要
标题为“为什么这很重要”的章节覆盖率是测试领域中最受信赖的指标,也是最容易误导人的指标之一。一个只调用方法却不做任何断言的测试,会执行其中每一行——覆盖率看起来完美,发现问题的能力却为零。标准文献明确指出,覆盖率准则之间的包含关系,并不能说明它们暴露错误的能力。那种能力正是它所称的测试有效性(ISO/IEC/IEEE 29119-4,§C.2.4)。覆盖率百分比和找错保证,是两种不同的主张。
对 PDF 引擎而言,这并非学术空谈。一个签名的字节范围检查、一个交叉引用偏移量、一个编码分支——测试可以完全「覆盖」这一切,却从未断言真正重要的那个值。在薄弱测试之上亮绿灯的套件,比一个诚实暴露的缺口更糟,因为它会让所有人不再深究。
简短版
标题为“简短版”的章节- 变异测试会对源代码进行数以千计的、刻意设计的微小修改(变异体)——把
<改成<=、把+改成-、改动一个return值——并针对每一个变异体重新运行测试。 - 如果某个测试在某个变异体上失败,该变异体就被杀死(killed):这表示确实有测试断言了那项行为。如果所有测试仍然通过,该变异体就逃脱(escaped):那项行为被执行了,却从未受到检查。
- **变异分数指标(MSI)**大致上是「被杀死的变异体」除以「非等价变异体总数」。它衡量的是你的测试是否检测到变更,而不是它们是否执行了代码。
- 有些变异体是等价的(equivalent)——它们无法改变可观察的行为,因此没有任何测试能杀死它们。把这些计为失败并不诚实。NextPDF 会加以证明并写入账本,而不是含糊地把它们略过。
- NextPDF 使用 MSI 来找出并强化薄弱测试。它是持续集成中的一道诊断闸门,而不是营销数字。
NextPDF 如何处理它
标题为“NextPDF 如何处理它”的章节变异测试通过 Infection 变异器在引擎上运行。它配置在生产源代码树之上,并启用算术、布尔、条件边界、相等性、返回值与移除等变异器家族——正是那些会暴露「已执行但未断言」逻辑的操作符。整个流程是机械化的:
- Start green The suite must pass before mutation begins.
- Mutate Apply one small, deliberate change to the source.
- Re-run Run the tests that cover the mutated line.
- Killed A test failed — the behaviour is genuinely asserted.
- Escaped All tests still pass — a weak spot to strengthen.
- Equivalent No test can kill it because behaviour is unchanged — proven and ledgered, not scored as a miss.
有两项设计选择让这个数字值得信赖。第一,这个分数会作为一道闸门接入流程。持续集成会强制要求一个最低 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 ISO/IEC/IEEE 29119-4 §B.2.4 说明的是:对规格的各个元素应用通用变异,借以派生出用于测试的特定变异。之所以需要这项技术,正是因为同一份标准指出:覆盖率准则的包含(subsumes)排序,并不会按其暴露错误的能力来排序它们 (ISO/IEC/IEEE 29119-4,§C.2.4)。
Evidence: Standard-backed 覆盖率本身定义明确, 但有其局限性。 Spec: PHPUnit 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 定义,单靠它并不足够。