コンテンツにスキップ

ミューテーションテストの解説

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 が実行するミューテーションテストのループ:グリーンなテストを取り、ミュータントを生成し、対象をカバーするテストを再実行して、そのミュータントを、殺された(テストが捕捉した)、生き残った(カバレッジはあるがアサーションがないギャップで、修正が必要)、または等価であると証明された(どのテストも殺せない。台帳に記録され、スコアには算入されない)のいずれかに分類する。

2 つの設計上の選択が、この数値を信頼できるものにしています。第一に、スコアはゲートとして組み込まれています。継続的インテグレーションは、最小 MSI(および最小の covered-MSI)を強制し、変更された行に対して diff スコープのバリアントを実行します。その結果、コードは追加するものの実質的なアサーションを追加しない変更は、後から発見されるのではなく、レビューの時点で捕捉されます。第二に、NextPDF は都合の悪いミュータントをひそかに割り引くことをしません。本当に意味的に等価なミュータント、たとえば厳格な型付けが両オペランドが同じ型を共有することを保証する場合の !==!= は、明示的な等価性証明テストとともにミューテーション台帳に記録されます。その結果、生き残ったミュータント数は、帳簿上の都合ではなく、実際のギャップを反映します。PHPStan Level 10 に加えて strict_types、そして型付きプロパティがそろってこそ、それらの等価性証明は健全なものになります。

Evidence: Test-backed ミューテーションテストは、 エンジンにおいて、プロダクションのソースディレクトリ全体を対象に、振る舞いを露呈させるミューテーターファミリを有効にして構成されています。これは継続的インテグレーションのゲートとして、 最小 MSI と diff スコープのバリアントを用いて強制されます。これはビルドのチェックであり、 後付けではありません。

Evidence: Test-backed 等価ミュータントの問題は、 誠実に扱われます。意味的に等価なミュータントは分類され、 ミューテーション台帳内の専用の等価性証明テストによって裏付けられます。そして、 各証明の健全性は PHPStan Level 10 と厳格な型付けに支えられています。したがって、生き残ったミュータント数は、 実際の未検出の振る舞いを表すものであり、殺せないノイズで膨らませてスコアを実際より悪く見せたものではありません。

Evidence: Standard-backed ミューテーションは確立された技法であり、 NextPDF が考案したものではありません。 Spec: ISO/IEC/IEEE 29119-4, §B.2.4 は、仕様の要素に汎用的なミューテーションを適用して、テストのための具体的なミューテーションを導き出すことを記述しています。そもそもこの技法が必要なのは、 同じ規格が、カバレッジ基準の包含順序(subsumes ordering)は、それらを欠陥を露呈させる能力で順序づけるものではないと述べているからです (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 を使います。成果物はより良いアサーションであり、誇示することは明確に目的ではありません。

2 つ目の誤解は、生き残ったミュータントはすべてテストの欠陥だと考えることです。一部のミュータントは本当に等価であり、観測可能な振る舞いを変えないため、どのテストでも殺すことができません。それらを失敗として扱うと、不誠実で人為的に低いスコアが生まれ、人々がレポートを無視するように仕向けてしまいます。NextPDF の答えは、等価性を明示的に証明して台帳に記録することであり、それをひそかに握りつぶしたり、数値が実際より悪いかのように見せかけたりすることではありません。

ミューテーションテストは、テストが注入された変更を検出するかどうかを測定します。コードが正しいことを証明するものではありません。パフォーマンスや準拠性を測定するものでもありません。真に等価なミュータントを殺すことはできません。現在のミューテーションスコア、有効な最小 MSI のしきい値、台帳に記録された等価ミュータントの数、そして各カバレッジの数値は、継続的インテグレーションの成果物から生成され、ビルドとともに公開される生きた品質シグナルです。ここでは、それらをあえて記載していません。文章に貼り付けられた数値は古くなり、小さな嘘になってしまうからです。このページが述べる唯一の安定した事実は PHPStan Level 10 であり、それは等価性証明を支える構成上の性質であって、測定値ではありません。

ミューテーターの選択、しきい値、台帳ポリシーは、エンジンのミューテーション構成が管理するものであり、今後変化しうるものです。その構成がこのページと食い違う場合は、構成を正とします。ここでは、他のどのライブラリのテスト有効性についても主張していません。

  • ミュータント — ソースに加えられる、意図的な小さな単一の変更。テストスイートがその変更に気づくかどうかを試すために用いられる。
  • 殺されたミュータント — 少なくとも 1 つのテストを失敗させたミュータント。その振る舞いは実際にアサートされていた。
  • 生き残ったミュータント — すべてのテストが成功したままだったミュータント。その振る舞いは実行されたものの、一度もアサートされなかった。修正すべき弱点。
  • 等価ミュータント — 観測可能な振る舞いを変えないため、どのテストでも殺せないミュータント。NextPDF はこれらを証明し、台帳に記録する。
  • MSI(ミューテーションスコア指標) — おおよそ、殺されたミュータント数を非等価なミュータントの総数で割ったもの。実行ではなく検出の尺度。
  • ラインカバレッジ — テストスイートの実行中に実行可能な行が実行されたことだけを記録する指標。PHPUnit によって定義され、単独では不十分。