行カバレッジ 94% が見逃したもの
debuff のトゥイーン、イージング、パーティクルをまとめた @debuffjp/juice は、9 件のテストで行カバレッジが 94% あります。このパッケージにミューテーションテストをかけました。Henka がソースに小さな変更を 1 つずつ加え、そのたびにテストを実行します。132 個の変更が、すべてのテストを通りました。その 1 つ 1 つを読みました。69 個は、テストが実行しているコードの本当の穴です。たとえばパーティクルが逆向きに動く変更がそうです。残りは、どのテストも実行しないコードにあるもの(35 個)、テストから違いが見えないもの(18 個)、テストで固定すべきでない調整用の既定値(10 個)です。スクリプト、両方の生の結果、仕分けの結果は bench/mutation にあります。
| ファイル | 実行された行 | 変更 | 見逃し | 実行されたコードの穴 |
|---|---|---|---|---|
particles.ts | 96% | 78 | 59 | 46 |
ease.ts | 81% | 105 | 46 | 11 |
tween.ts | 93% | 51 | 13 | 5 |
trail.ts | 100% | 24 | 9 | 4 |
recipes.ts | 100% | 8 | 5 | 3 |
| 合計 | 94% | 266 | 132 | 69 |
Windows 11、Node 24.15.0、Vitest 5.0.5、juice 0.30.0(ソース 360 行)。カバレッジの測定には Vitest の V8 プロバイダー(分岐カバレッジ 81%)、変更の生成には Henka 0.1.0 を使いました。
1. ミューテーションテストの変更とは
ここでいう変更は、コードに 1 か所だけ加えた小さな違いです。人やエージェントがやりそうな書き間違い、たとえば - のはずの +、<= のはずの <、0 にされた数値などです。どのテストも失敗しなければ、そのコードの動きを確かめているテストはありません。次の変更は 9 件のテストをすべて通りました。
// particles.ts, Particles.update()
- p.x += p.vx * dt;
+ p.x -= p.vx * dt;これで全部のパーティクルが、速度と反対の向きに飛びます。この行はテスト中に実行されるので、カバレッジには数えられます。テストが確かめているのは、パーティクルが出て、消えて、プールに戻ることです。パーティクルがどこに行き着くかを見ているテストはありません。
2. 実行されたコードの穴
69 個の穴は、テストが実行はしても、何をしているかは確かめていない行です。多くはパーティクル処理にあり、46 個の変更が通りました。
- どちらかの軸で位置を逆にする変更、重力の符号を反転する変更、速度に
dtを掛ける代わりに割る変更。 p.drag === 1を!==にして、すべてのパーティクルで減衰を無視する変更。dragを設定するテストはありません。- パーティクルの寿命に沿ったサイズと透明度、描く位置。サイズの曲線を逆にする変更、透明度を掛ける代わりに割る変更、円を半径 4 倍で描く変更が、すべて通りました。
- 色の選び方で、リストの長さを掛ける代わりに割る変更。これで全部のパーティクルが最初の色になります。
残りはほかのファイルに散らばっています。squash() はスプライトを横へ広げて縦を縮め、それから元に戻します。縦の値を 1 + amount へ変えてスプライトを縦長にしても通りました。テストが確かめているのは横幅だけだからです。tweens.kill(target) で || を && にすると、ほかのすべてのオブジェクトの実行中のトゥイーンまで止まりますが、これも通りました。イージングの曲線では、outQuad の t * (2 - t) が t / (2 - t) になりました。イージングのテストは各曲線が 0 を 0 に、1 を 1 に写すことだけを確かめていて、この変更後もそれは成り立ちます。
どれも今の juice のバグではありません。どれも、テストが赤くならないまま出荷されてしまう変更です。しかも、カバレッジ上はテスト済みと数えられる行にあります。
3. カバレッジの数字が隠していたもの
35 個の見逃しは、どのテストも実行しないコードにあります。20 個は、V8 が一度も実行されないと報告する行にあります。outBounce の 2 番目と 3 番目の区間、トゥイーンの repeat、Particles.clear() です。残りの 15 個は outElastic の中央の分岐にあります。テストが 0 と 1 で呼ぶのでその行は実行され、行カバレッジには数えられます。しかし両端の間を計算する式は一度も実行されません。
分岐カバレッジ(81%)なら、この 35 個の大半を指摘できたはずです。ただし 69 個のほうは指摘できません。69 個はどれも、テストが実行する行と分岐にあるからです。
4. 見逃しでも穴ではないもの
18 個の変更は、テストから観察できる違いを生みません。5 個は、イージング曲線の 2 つの区間がちょうど同じ値でつながる位置での < から <= への変更で、どちら側でも同じ答えになります。4 個は、インデックスが常に範囲内にあるところで ?? 0 を ?? 1 にする変更で、フォールバックは使われません。残りの 9 個には、配列の終わりより先から始まって余分なインデックスを飛ばすループ、向きの分布が変わらない左右反転した広がり、時間を節約するだけの近道などがあります。そうした行に // henka-skip を付けると、以後は表示されません。
10 個は調整用の既定値です。パーティクルを一度に出す数、速さ、寿命、サイズと、squash() の量と長さです。既定の速さの範囲が [40, 120] から変わると失敗するテストは、その数値を固定してしまいます。するとゲームの手触りを調整する人は、そのたびにテストを書き換えることになります。こうした値は、値が担う振る舞いをテストしましょう。たとえば、速いバーストほど遠くまで広がることを確かめます。Henka はすべての数値リテラルを変えるので、これらもほかの見逃しと並んで一覧へ出ます。印を付けるかどうかは、テストを書く人が決めます。
5. Henka を Stryker と照らし合わせる
Henka は、変更をまたいで開いたままの Vitest プロセスの中で、Vite がファイルを読み込むときに変更を差し替えます。ここで起こりうる失敗は 2 つです。古いモジュールが残って、ある変更が次の実行に漏れること。もう 1 つは、差し替えが無関係な部分を壊し、変更と関係のないテストを失敗させることです。この 2 つを確かめるために、同じパッケージで StrykerJS 10.1.1 を実行し、判定を比べました。
同じ変更を同じ決定的なテストで実行すれば、判定は同じになるはずです。食い違いがあれば、それは Henka の仕組みの問題を示します。ファイル、行、置き換え後のテキストで突き合わせると、両方のツールが作る変更は 152 個でした。そのうち 8 個は Stryker の変更の複数と一致します(1 行にある 2 つの同じ 2 * t の変更は、テキストでは区別できません)。その場合は、どれか 1 つと一致すれば同じ判定とみなしました。152 個すべてで判定は一致しました。
2 つのツールは変えるものが違うので、スコアをそのまま比べることはできません。Henka は 266 個中 134 個(50%)、Stryker は 362 個中 232 個(64%、実行されたコードの 326 個に限れば 71%)を捕まえます。Henka の変更のうち 106 個は Stryker が変えない数値リテラルで、Henka の見逃し 132 個のうち 58 個を占めます。逆に Stryker には、Henka にない変更の種類があります。実行されたコードで生き残った Stryker の変更のうち、Henka に対応する変更がないものは 43 個です。条件を true や false に固定するものが 20 個、論理演算子(いくつかは ??)が 8 個、残りは文字列、配列、メソッド呼び出しです。これらは、Henka がまだ報告できない穴です。
方法
このリポジトリで Stryker を動かすには、3 つの変更が必要でした。Stryker の tsconfig プリプロセッサーは TypeScript 7 がもう提供していない関数を呼ぶので、tsconfigFile を存在しないファイルに向けます。test.projects を読まないので、専用の Vitest 設定を用意します。サンドボックスには、ルートが参照するすべての tsconfig を残す必要があります。設定ファイルは bench/mutation にあります。
Henka は 2 ジョブで 10 分 38 秒、変更 1 個あたりの中央値は 3.4 秒で、その間ほかの作業で CPU 使用率は 97% でした。Stryker は、もっと空いているマシンで concurrency 2 にして 5 分 17 秒でした。この 2 つの時間は比べられず、同じ負荷での測定はまだしていません。変更 1 個あたりの時間の大半は、Vitest がテストファイルごとにワーカーを起動する時間だと考えています。ただし、切り分けての測定はしていません。
実行を小さく保つために、npx henka --since main はブランチで変えた行だけを変更します。Google のミューテーションテストはさらに進んでいます。変更されたコードだけを対象とするのに加え、報告は 1 行あたり 1 個までで、開発者が役に立たないと判断した種類の変更も除きます(Petrović ほか、2021)。Henka にあるのは、この 3 つのうち最初の 1 つだけです。
この結果からわからないこと
- 対象は小さなパッケージ 1 つです。テストが遅いパッケージは試していません。
- 2 つのツールの速度は比べていません。
- 仕分けは、ソースと照らして見逃しを 1 つずつ読んだ私たちの判断です。別の人が読めば、等価、調整用、穴の間でいくつか入れ替わるかもしれません。各判断は理由とともに
triage.jsonにあります。 - 穴がプレイヤーにとって問題になるかどうか。逆向きのパーティクルなら数秒で気づかれますが、間違ったイージング曲線は何か月も出荷されたままかもしれません。
試してみる
Vitest 5 でテストしているプロジェクトで実行します。
npx henka --since main