- ブラウザURL入力
- DNS名前→IP
- ルータ経路選択
- ロードバランサ振り分け
- Nginx受け口
- アプリ処理今ここ
- DB保存
このコースの初回です。テスト・履歴・レビュー・CI を、どれも「何かと何かの比較」として読みます。今回は最も身近な比較、テストの期待する出力の出どころを見ます。
まず結論
テストは、実際の出力を「期待する出力」と比べる作業です。テストランナーが判定するのは一致したかどうかだけで、どちらが正しいかは判定しません。
期待する出力を実装と同じ計算で作ったり、実装の出力から写したりすると、実装の誤りまで写り、テストは緑のままになります。緑が何を意味するかは、期待する出力の出どころで決まります。
テストが期待する出力を `baseUrl + "/albums"` で作っています(`baseUrl` は `"http://photos.google.com/"`)。実装も同じ連結で URL を作っていたら、このテストはどうなるでしょうか。
当てることが目的ではありません。外した予測ほど記憶に残るので、直感で選んでください。
テストは比較で、比べる相手は誰かが用意する
テストは、ある入力に対する実際の出力を、あらかじめ用意した値と比べます。この値を本コースでは期待する出力、略して期待値と呼びます。確率の期待値(平均)とは別物です。
テストランナーがするのは一致の判定だけです。一致すれば緑、しなければ赤。どちらの値が正しいかは判定しません。 緑が意味を持つかは、期待する出力がどこから来たかで決まります。
- ・期待する出力の出どころ: (1) 仕様から人が書き下す (2) 実装と同じ計算で作る (3) 実装の出力を写す
- ・(2)(3) は実装から独立していない。実装の誤りが期待する出力にも写る
SWE at Google 12章は、この点を見出し Don’t Put Logic in Tests で扱い、良いテストの条件をこう書きます。
Clear tests are trivially correct upon inspection
一目で正しいと分かること。期待する出力が正しいかを確かめているのは、読んだ人の目です。
連結1回で、誤りが期待する出力に入る
同じ章の Example 12-15 は、題が Logic concealing a bug(バグを隠すロジック)です。原文から2行を抜粋します。
String baseUrl = "http://photos.google.com/";
...
assertThat(nav.getCurrentUrl()).isEqualTo(baseUrl + "/albums");
baseUrl は / で終わっているので、期待する出力は http://photos.google.com//albums です。
続く Example 12-16 は、同じ期待する出力を "http://photos.google.com//albums" と誤った値のまま書き下した版です(末尾に // Oops!)。原文はその後でこう書きます。
When the whole string is written out, we can see right away that we’re expecting two slashes in the URL instead of just one. If the production code made a similar mistake, this test would fail to detect a bug.
書き下しても値が誤っていれば見逃します。変わるのは、誤りが読む人の目に見える点です。
原文に実装コードはありません。そこで本教材で、同じ連結をする実装を用意しました。2本目は、12-16 の期待する出力を読んだ人が仕様どおり /albums に直した版です。
// 実装: baseUrl が / で終わると // になるgoToAlbumPage() { this.current = this.baseUrl + "/albums"; }// テスト1: 期待する出力を連結で作る(12-15 相当)assert.equal(nav.getCurrentUrl(), baseUrl + "/albums");// テスト2: 12-16 の // を / に直して書き下すassert.equal(nav.getCurrentUrl(), "http://photos.google.com/albums");
$ node --test logic.test.mjs✔ 連結で作る(12-15 相当) (0.287208ms)✖ 仕様から書き下す(12-16 の // を / に直した版) (0.385ms)ℹ pass 1ℹ fail 1+ actual - expected+ 'http://photos.google.com//albums'- 'http://photos.google.com/albums'
連結で作ったテストは緑です。実装と同じ計算をしているので、同じ誤りを含んだ値同士が一致しました。//albums を捕まえたのは、人が仕様から正しい値を書き下したテストだけです。12-16 の誤った値のままなら、これも緑でした(本教材で確認)。
SWE at Google 12章の Example 12-15 は、なぜ誤りを見逃しうると説明されていますか。
実装から写すのは、いつ誤りで、いつ正当か
Kent Beck は「Canon TDD」(2023)で、テストを通す段階の誤りの1つをこう挙げます。
Mistake: copying actual, computed values & pasting them into the expected values of the test. That defeats double checking, which creates much of the validation value of TDD.
テストの価値は二重確認にあります。実装とは別の経路で出した値と突き合わせるから、ずれが見つかります。期待する出力を実装から写せば、経路が1本になります。
ところが、実際の出力を写すこと自体を手順にしているテストがあります。Michael Feathers の特性テスト(2016)です。
The purpose of characterization testing is to document your system’s actual behavior, not check for the behavior you wish your system had.
実行して出てきた値を貼り付けて緑にする手順を示し、そうしたテストを descriptions of what we have rather than statements of correctness と見られると書きます。正しさの表明ではなく、今の振る舞いの記述です。
2人は逆のことを言っているように見えますが、目的が違います。Beck は新しい振る舞いを作るときの確認、Feathers は既存コードの今の振る舞いの記録です。写した値は実装から独立していない、という前提は同じです。
スナップショットは「前回と同じか」を見る
写す作業を道具にしたのがスナップショットテストです。node:test の文書はこう説明します。
Snapshot tests allow arbitrary values to be serialized into string values and compared against a set of known good values.
known good values(正しいと分かっている値)と呼んでいます。本教材で、金額を桁区切りで整形する関数を使って確かめました。
(b) --test-update-snapshots で生成 ✔ exit=0保存された値: "¥0", "¥1,234", "¥1,000,000"(d) 実装を String(yen) に変える ✖ exit=1+ '\n[\n "¥0",\n "¥1234",\n "¥1000000"\n]\n'- '\n[\n "¥0",\n "¥1,234",\n "¥1,000,000"\n]\n'(e) 壊れたまま --test-update-snapshots ✔ exit=0保存された値: "¥0", "¥1234", "¥1000000"(f) フラグなしで再実行 ✔ exit=0
(d) で赤になります。前回と違うことは検出できます。しかし (e) で壊れたまま更新すると、桁区切りの無い値が新しいスナップショットになり、(f) は緑です。
(b) で保存した "¥1,234" が正しいと判断したのは、ツールではなく中身を見た人です。スナップショットの緑が言えるのは「保存したときと同じ」まで。変更で既存の機能が壊れていないかを確かめる回帰の検出は、この「前回との比較」の上に成り立っています。基準にした前回が正しかったかは、別に確かめる必要があります。
node:test のスナップショットテストで、実装を壊した状態のまま `--test-update-snapshots` を付けて実行し、次にフラグなしで実行しました。結果はどうなりますか。
期待する出力を連結で作ったテスト
期待する出力を baseUrl + "/albums" で作ると、実装と同じ誤りが入る。二重スラッシュを含む値同士が一致して緑になる。
実際の出力 http://photos.google.com//albums期待する出力 http://photos.google.com//albums出どころ 実装と同じ連結結果 緑
壊れたまま更新したスナップショット
更新フラグを付けると、その時点の実装の出力が保存される。桁区切りが消えた値が基準になり、以後の実行は緑になる。
実際の出力 "¥1234"期待する出力 "¥1234"(更新で保存された値)出どころ 実装の出力をそのまま保存結果 緑
計算で作った期待する出力と、ツールが保存した期待する出力。仕組みは違いますが、緑が同じように空になる理由は何でしょうか。
それぞれの期待する出力を作るとき、実装を通らない経路が1本でもあったかを探してください。
この章のまとめ
- テストは実際の出力と期待する出力の比較。テストランナーは一致だけを見て、どちらが正しいかは判定しない。
- 期待する出力の出どころは3通り。仕様から書き下す / 実装と同じ計算で作る / 実装の出力を写す。後の2つは実装から独立していない。
- Beck は写すことを二重確認を失う
Mistakeと呼び、Feathers は既存の振る舞いの記録として写す。目的が違うだけで、写した値が独立していないという前提は同じ。 - スナップショットの緑は「保存したときと同じ」まで。壊れたまま更新すれば壊れた値が基準になる。
- 緑を見たら「何と一致したのか」を問う。基準を用意するのは人の仕事。
自分の現場で確かめる
読んだだけでは身につきません。手元の環境で実際に見てみてください。
理解度チェック
読んだ直後は「分かった」と感じやすいものです。実際に解いて確かめてください。
Feathers(2016)は、特性テスト(characterization testing)の目的をどう説明していますか。
用語集(4語)
- 期待する出力(期待値)
- テストが実際の出力と比べる相手の値。確率・統計の「期待値」(平均)とは別物。テストランナーは一致を判定するだけで、この値が正しいかは判定しない。
- 緑 / 赤
- テストの成功・失敗の俗称。本コースでは「比べた相手と一致した/しなかった」の意味でだけ使う。
- 特性テスト (characterization testing)
- 既存システムの実際の出力を記録して期待する出力にするテスト。望む振る舞いではなく、今の振る舞いを記録する。
- スナップショットテスト
- 出力を文字列にしてファイルに保存し、以後の実行で保存済みの値と比べるテスト。node:test では v22.3.0 で追加され、v23.4.0 で実験的扱いが外れた。