Dev Study
← 開発の実務
レッスン 2 / 12·約 8 分·第16期 開発の実務

一度も落ちたことのないテストは、落ちられるか分からない

リクエストがアプリケーションに届くまで
  1. ブラウザURL入力
  2. DNS名前→IP
  3. ルータ経路選択
  4. ロードバランサ振り分け
  5. Nginx受け口
  6. アプリ処理今ここ
  7. 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日超)を取り出す関数を、何も返さない版に差し替えてテストしました。

empty2.test.mjs(壊した版の findOverdue を含む)設定ファイル
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));
});
$ node --test empty2.test.mjs(Node v24.21.0。抜粋: ℹ 行の一部とスタックトレースを省略)コマンド出力
✔ 延滞注文はすべて30日超 (0.274209ms)
✖ 延滞注文はすべて30日超(件数も確かめる) (0.375291ms)
ℹ pass 1
ℹ fail 1
AssertionError [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 は使っていません)。

手製スクリプトの出力(Node v24.21.0。2回実行して同じ結果)コマンド出力
$ ./mutate.sh weak.test.mjs # 3歳・30歳の2本
original : exit=0
mutant 'age <= 6': exit=0 -> SURVIVED
mutant 'age > 6': exit=1 -> KILLED
mutant 'age >= 6': exit=1 -> KILLED
mutant 'age == 6': exit=1 -> KILLED
$ ./mutate.sh strong.test.mjs # 5歳・6歳を足した4本
original : exit=0
mutant 'age <= 6': exit=1 -> KILLED
mutant 'age > 6': exit=1 -> KILLED
mutant 'age >= 6': exit=1 -> KILLED
mutant '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` に書き換えた版で走らせました。赤になったのはどれですか。

⇄ 2つを見比べる
具体例 A

TDD の先に赤

壊れた版はまだ実装していないコード。テストを書いた時点で1回だけ、実装の有無を見分けるかを確かめる。

具体例 B

ミューテーションテスト

壊れた版は1箇所を書き換えたコード。書き換えのたびに、テストがその壊れ方を見分けるかを確かめる。

age < 6 の書き換えと、2本のテストの結果表
書き換え 3歳 30歳 判定
age <= 6 緑 緑 生き残り
age > 6 赤 赤 殺した
age >= 6 赤 赤 殺した
age == 6 赤 緑 殺した

実装前の赤と、書き換えた版の赤。どちらも何と何を比べていますか。

「このテストが緑でなかったら、何が起きていたはずか」を両方で考えてください。

この章のまとめ

  • 緑が意味を持つのは、わざと壊した版で赤になると確かめたときだけ。
  • TDD は実装前のコードを壊れた版として使う。後から書いたテストでも、実装を一時的に壊せば同じ比較になる。
  • 検査が0回しか実行されないテストは、何も返さない実装でも緑になる。件数を確かめる1行で赤になった。
  • < を <= に書き換えても、行・分岐・関数のカバレッジは 100%のまま緑。カバレッジは行が実行されたことしか測らない。
  • ミューテーションテストは壊した版を機械で作る。PIT は lived、Stryker は survived と呼ぶ。
  • 見分けられるのは試した壊れ方だけ。テストは誤りの存在は示せても、不在は示せない。

自分の現場で確かめる

読んだだけでは身につきません。手元の環境で実際に見てみてください。

理解度チェック

読んだ直後は「分かった」と感じやすいものです。実際に解いて確かめてください。

問題 1 / 3正解: 0

ミューテーションテストの道具 PIT が「mutation lived」と呼ぶのはどんな状態ですか。

用語集(4語)
緑/赤
テストが通った/落ちた状態。TDD の3手順はよく Red - Green - Refactor と要約される、と Fowler は書いている。
ミューテーションテスト
コードに小さな改変を自動で入れ、テストが落ちるかを見る手法。PIT は改変を mutation、Stryker は mutant と呼ぶ。
生き残り(lived / survived)
改変した版でもテストがすべて通った状態。PIT は lived、Stryker は survived と書く。テストが落ちれば killed。
コードカバレッジ
テストでどの行・分岐が実行されたかの割合。判定が効いているかは測らない。