- ブラウザURL入力
- DNS名前→IP
- ルータ経路選択
- ロードバランサ振り分け
- Nginx受け口
- アプリ処理今ここ
- DB保存
前回は、期待する出力がどこから来たのかを見ました。今回は、その比較が本当に効いているのか、テストという検査そのものを検査します。
まず結論
「壊れていない」は何と比べたかでしか言えません。テストの緑が意味を持つのは、わざと壊した版を用意し、その版では赤になると確かめたときだけです。この回の比較相手はその壊した版です。
TDD で先に赤を見るのも、ミューテーションテストでコードを1箇所書き換えるのも同じ比較です。カバレッジ100%はこの比較の代わりになりません。
6歳未満を無料にする `return age < 6;` に、3歳と30歳を確かめるテストが2本あります。カバレッジは行・分岐・関数とも100%です。この `<` を `<=` に書き換えて同じテストを走らせると、どうなるでしょうか。
当てることが目的ではありません。外した予測ほど記憶に残るので、直感で選んでください。
緑は、赤になりえたときだけ情報になる
このコースの芯は「『壊れていない』は、何と比べたかでしか言えない」です。この回が比べる相手は、わざと壊した版です。
assert を1つも書いていないテストを考えてください。実装が何をしても緑です。いつも緑を返すものが緑を返しても、何も分かりません。緑が「壊れていない」を意味するのは、壊れた版では赤になると確かめてあるときだけです。
TDD はこの確認を順序で済ませます。3つの手順はよく Red - Green - Refactor と要約される、と Fowler は書いています。実装前のコードは「まだ機能が無い」という壊れた版なので、そこで赤を見ておけば、後の緑は実装の有無を見分けた結果になります。Kent Beck の「Canon TDD」は、手順3を Now that you have a failing test で始め、失敗しているテストがある状態を前提にしています。同じ文書は Mistake: write tests without assertions just to get code coverage. とも書いています。
- ・壊した版は、実装前のコード(TDD)でも、1箇所書き換えた版でもよい
- ・見分けられるのは試した壊れ方だけで、すべての誤りではない
実装が何も返さなくても通るテスト
延滞注文(30日超)を取り出す関数を、何も返さない版に差し替えてテストしました。
import { test } from "node:test";import assert from "node:assert/strict";const findOverdue = (orders) => orders.filter((o) => false); // 壊した版: 何も返さないtest("延滞注文はすべて30日超", () => {const result = findOverdue([{ days: 3 }, { days: 45 }]);result.forEach((o) => assert.ok(o.days > 30)); // 0回しか回らない});test("延滞注文はすべて30日超(件数も確かめる)", () => {const result = findOverdue([{ days: 3 }, { days: 45 }]);assert.equal(result.length, 1);result.forEach((o) => assert.ok(o.days > 30));});
✔ 延滞注文はすべて30日超 (0.274209ms)✖ 延滞注文はすべて30日超(件数も確かめる) (0.375291ms)ℹ pass 1ℹ fail 1AssertionError [ERR_ASSERTION]: Expected values to be strictly equal:0 !== 1
1本目は、forEach の中の検査が0回しか実行されないので緑です。この関数は何も返しませんが、1本目のテストはそれを見分けられません。件数を確かめる assert.equal(result.length, 1) を足した2本目は、同じ壊した版で 0 !== 1 の赤になりました。
`result.forEach((o) => assert.ok(o.days > 30))` だけで検査するテストが緑でした。ここから言えることはどれですか。
壊した版を機械に作らせる
実装前の赤は、テストを書いた時点の1回しか見られません。そこで、壊した版を機械に作らせて何度も比べるのがミューテーションテストです。PIT はこう説明します。
If your tests fail then the mutation is killed, if your tests pass then the mutation lived.
Stryker は同じ状態を survived と呼びます。生き残った改変は、テストが見分けられなかった壊れ方の一覧です。
カバレッジではこれが分かりません。『SWE at Google』11章の説明です。
code coverage only measures that a line was invoked, not what happened as a result.
注意したいのは、殺した改変が増えても誤りが無いことの証明にはならない点です。NATO 1969年会議の報告書(再組版 PDF の p.66、原本 p.85。§7.4、Dijkstra の文書)に Program testing can be used to show the presence of bugs, but never to show their absence! とあります。序章『生成されたコードの読み方 — 「動く」と「正しい」の差』の確認問題で、テストが示せるのは「踏んだ入力の範囲で、観測結果が期待と一致したところまで」でした。向きは同じです。ミューテーションテストが教えてくれるのは、試した壊れ方をテストが見分けたかどうかまでです。
カバレッジ100%のまま生き残る改変
冒頭の予測の実験です。return age < 6; を sed で1箇所ずつ書き換え、node --test の終了コードで判定する手製のスクリプトを使いました(Stryker や PIT は使っていません)。
$ ./mutate.sh weak.test.mjs # 3歳・30歳の2本original : exit=0mutant 'age <= 6': exit=0 -> SURVIVEDmutant 'age > 6': exit=1 -> KILLEDmutant 'age >= 6': exit=1 -> KILLEDmutant 'age == 6': exit=1 -> KILLED$ ./mutate.sh strong.test.mjs # 5歳・6歳を足した4本original : exit=0mutant 'age <= 6': exit=1 -> KILLEDmutant 'age > 6': exit=1 -> KILLEDmutant 'age >= 6': exit=1 -> KILLEDmutant 'age == 6': exit=1 -> KILLED
2本のテストでは <= だけが生き残りました。書き換えた版に --experimental-test-coverage を付けて走らせても、2本とも ✔、fare.mjs | 100.00 | 100.00 | 100.00 です。元のコードでも同じ値でした。カバレッジの数字は、元の版と壊した版を区別していません。
4本の版で <= を殺したのは「6歳は有料」の1本だけです。「5歳は無料」は < でも <= でも true なので見分けられません。なお Stryker の文書の例では、>= を > に変える改変は殺され、return true に置き換えた改変が生き残ります。文書はこの生き残りを there is probably a test missing と読んでいます。
4本のテスト(3歳は無料/30歳は有料/5歳は無料/6歳は有料)を、`age <= 6` に書き換えた版で走らせました。赤になったのはどれですか。
TDD の先に赤
壊れた版はまだ実装していないコード。テストを書いた時点で1回だけ、実装の有無を見分けるかを確かめる。
ミューテーションテスト
壊れた版は1箇所を書き換えたコード。書き換えのたびに、テストがその壊れ方を見分けるかを確かめる。
書き換え 3歳 30歳 判定age <= 6 緑 緑 生き残りage > 6 赤 赤 殺したage >= 6 赤 赤 殺したage == 6 赤 緑 殺した
実装前の赤と、書き換えた版の赤。どちらも何と何を比べていますか。
「このテストが緑でなかったら、何が起きていたはずか」を両方で考えてください。
この章のまとめ
- 緑が意味を持つのは、わざと壊した版で赤になると確かめたときだけ。
- TDD は実装前のコードを壊れた版として使う。後から書いたテストでも、実装を一時的に壊せば同じ比較になる。
- 検査が0回しか実行されないテストは、何も返さない実装でも緑になる。件数を確かめる1行で赤になった。
<を<=に書き換えても、行・分岐・関数のカバレッジは 100%のまま緑。カバレッジは行が実行されたことしか測らない。- ミューテーションテストは壊した版を機械で作る。PIT は lived、Stryker は survived と呼ぶ。
- 見分けられるのは試した壊れ方だけ。テストは誤りの存在は示せても、不在は示せない。
自分の現場で確かめる
読んだだけでは身につきません。手元の環境で実際に見てみてください。
理解度チェック
読んだ直後は「分かった」と感じやすいものです。実際に解いて確かめてください。
ミューテーションテストの道具 PIT が「mutation lived」と呼ぶのはどんな状態ですか。
用語集(4語)
- 緑/赤
- テストが通った/落ちた状態。TDD の3手順はよく Red - Green - Refactor と要約される、と Fowler は書いている。
- ミューテーションテスト
- コードに小さな改変を自動で入れ、テストが落ちるかを見る手法。PIT は改変を mutation、Stryker は mutant と呼ぶ。
- 生き残り(lived / survived)
- 改変した版でもテストがすべて通った状態。PIT は lived、Stryker は survived と書く。テストが落ちれば killed。
- コードカバレッジ
- テストでどの行・分岐が実行されたかの割合。判定が効いているかは測らない。
出典(一次ソース)
- PIT — Real world mutation testing ↗
- Stryker Mutator — What is mutation testing? ↗
- Software Engineering at Google, Chapter 11 Testing Overview (A Note on Code Coverage) ↗
- Software Engineering Techniques, NATO Science Committee, 1969 (再組版 PDF p.66 = 原本 p.85, §7.4) ↗
- Kent Beck, Canon TDD (2023-12-11) ↗
- Martin Fowler, TestDrivenDevelopment (bliki) ↗
- Node.js v24 — Test runner (Collecting code coverage) ↗