テストもCIも緑なのに、壊れた実装がそのまま通っていた。順序を入れ替えた実装、0件の確認、落ちたsetup、本物に届く偽物、skipしたjob。筆者が1週間で踏んだ穴と、わざと壊してredになるかを見る確かめ方を解説する。

本記事はプロモーションを含みます。
テストを足して、CI も緑になった。ところがレビューで実装をわざと壊してみると、テストはそれでも緑だった。筆者はこの 1 週間で、Go・Terraform・bash・GitHub Actions のそれぞれでこれを踏んだ。
結論から書くと、次の 3 つを習慣にすると多くを防げる。
go test -overlay で作業ツリーを書き換えずに壊せる以下、どの穴も実際に踏んだもので、どう気付いてどう塞いだかを順に書く。
テストが red になるのは、テストが想定した壊れ方をしたときだけだ。想定していない壊れ方に対して緑のままかどうかは、壊してみるまで分からない。
API の入口に、トークンを検査するミドルウェアを、利用者の認証より前に置いた。順序を守るテストも書いた。AI レビュー (Codex) はこのテストを「順序を入れ替えても通る」と指摘し、実際に順序を入れ替えた実装でテストが通ることを go test -overlay で示した。
-overlay は、ビルドに使うファイルを別のファイルに差し替える。go help build には次のようにある。
read a JSON config file that provides an overlay for build operations. The file is a JSON object with a single field, named ‘Replace’, that maps each disk file path (a string) to its backing file path
cp router.go /tmp/router_swapped.go # この中でミドルウェアの順序を入れ替える
printf '{"Replace":{"%s":"/tmp/router_swapped.go"}}' "$PWD/router.go" > /tmp/ov.json
go test -overlay=/tmp/ov.json ./...
壊したソースは /tmp にしか無いので、作業ツリーにも git の差分にも残らない。戻し忘れで壊れた実装をコミットする心配が無い。上の図は、使い捨てのモジュールで同じことをした結果だ (go1.26.4)。
入れ替えても通った理由は、テストの入力にあった。トークンの無い要求は、どちらのミドルウェアが先でも同じ 401 になる。順序を確かめるには、片方だけが弾く入力 (認証は通るがトークンが無い、など) で、どちらが先に答えたかが応答から分かる形にする。
テストに限らず、「OK」を出す道具は同じ穴を持つ。5 つの環境の Terraform を共通の module に寄せたとき、筆者は先に 2 つのツールを作った。5 環境を書き換える codemod と、変更前後の plan が「移動以外は同じ」かを判定するスクリプトだ。
5 環境とも判定は OK で、移動の件数は各 state の全件数と一致した。ただ、OK しか見ていない判定は、NG を出せるかがまだ分からない。そこで moved ブロックを 1 つ抜いて流し、DNS のリソースの削除と作成 4 件で NG が出ることを確かめてから、判定を信じた。
terraform test で、for_each で作るリソースが全部正しく作られたかを、作られたリソースの側を走査して確かめていた。
assert {
condition = alltrue([for k, v in google_x.default : v.app_id != ""])
}
レビューで「登録の欠落を検知しない」と指摘された。リソースの for_each を {} にすると、8 つの run がすべて通った。公式の alltrue にも「If the collection is empty, it returns true.」とある。空の集合に対する「全部」は真になる。
echo 'alltrue([])' | terraform console # true
作る物の側でなく、期待する入力の側と突き合わせる。キーの集合を比べれば、1 件でも欠ければ落ちる。
assert {
condition = keys(google_x.default) == keys(var.web_apps)
error_message = "web app ごとに 1 つ作る"
}
!anytrue(...) も同じ穴を持つ。書いた assert は、わざと作成物を 0 件にして落ちるかを 1 度見る。
LLM が返す JSON を受理するかを判定する bash の関数に、表駆動のテストを足した。壊れた JSON や空の値を渡すと、非ゼロで終わることを確かめる行を並べた。
足したテストは全部緑だったが、何も確かめていなかった。テストの setup に、値を書かない local v があったためだ。set -u の下で $v を読むと、setup が unbound variable で落ちる。
set -u
g() { local v; echo "[$v]"; }; g # v: unbound variable
g() { local v=""; echo "[$v]"; }; g # []
setup の失敗も非ゼロ終了なので、「非ゼロで終わること」を見る行は全部通る。レビューでも、「JSON が 1 つ」「空でない」の条件を実装から外しても緑のままだと指摘された。
直したことは 2 つ。
local v="" で空文字を入れるそのうえで、実装の条件を 1 つずつ外し、そのたびに red になることを確かめた。local の宣言と set -e の関係は「bashのset -eが効かない5つの場面と対処法」にも書いた。
DNS サービスの API を叩くシェルスクリプトのテストで、curl を関数で偽物に差し替えていた。ところがテストは対象を bash script.sh で子プロセスとして起動していた。関数は子プロセスに届かないが、export された環境変数は届く。
curl() { echo "fake $*"; }
bash -c 'type -t curl' # file (本物)
export -f curl; bash -c 'type -t curl' # function
その結果、子のスクリプトは本物の curl と、手元のシェルにあった本物の API トークンで API を叩いた。GET のほかに、変異テストの DELETE が 1 件飛んだ。消そうとした ID が存在しなかったので curl -f が exit 22 で終わり、実害は無かった。DNS のレコードに影響が無いことは dig で確かめた。
塞いだのはテストの入口だ。
main() { unset API_TOKEN; run_tests; } # 本物のトークンを外してから流す
偽物の側にも穴があった。偽の curl は URL と body だけを記録していたので、-X DELETE を送っても GET と同じに見えて検証が通る。偽物は、検証したい違い (ここではメソッド) まで記録する。
tmux の send-keys -t '=<セッション名>' で別のセッションにキーを送る処理を書いた。単体テストは通ったが、実物の tmux では送り先の pane を決められずに失敗した。'=<セッション名>:' と末尾にコロンを付ければ送れる。
単体テストの期待値も、同じ誤った形で書いていた。実装と期待値が同じ思い込みから書かれていると、テストは何も教えてくれない。外部のコマンドを組み立てる処理は、本物で 1 回動かす。tmux なら TMUX_TMPDIR を変えると、普段のサーバと分けて試せる。
favicon.svg から 16・32・48px の画像を詰めた ICO を作る生成器を書き、テストも付けた。レビューで、テストが ICO の目録 (ICONDIRENTRY) の寸法しか見ていないと指摘された。
ICO は、目録の寸法と、中に入れた PNG の寸法 (IHDR) を別々に持つ。食い違っていてもファイルとして読めるので、中の画像が全部 16px でも、目録だけ見るテストは通る。中の PNG の幅と高さも読んで照合する形に直した。
const n = buf.readUInt16LE(4);
for (let i = 0; i < n; i++) {
const e = 6 + i * 16, off = buf.readUInt32LE(e + 12);
const dir = [buf[e] || 256, buf[e + 1] || 256]; // 目録の寸法
const png = [buf.readUInt32BE(off + 16), buf.readUInt32BE(off + 20)]; // 中の PNG の寸法
}
SVG の favicon でも、テストが中身を見ていないと指摘された。Biome は xmlns の無い SVG も通すので、lint が緑でもブラウザが描けるとは限らない。Vite なら import svg from "./favicon.svg?raw" で文字列として読み、中身をテストで固定できる。
e2e テストの job を足した PR で、最初の CI は緑だった。中を見ると、e2e の job は skip されていた。前の job の output が末尾に改行付きの "true\n" で、== 'true' に一致しなかった。
if: needs.detect.outputs.e2e == 'true' # "true\n" だと false
if: fromJson(needs.detect.outputs.e2e) # true
fromJson() で JSON として読むと、改行は捨てられて true になる。job の skip は失敗ではないので、CI 全体は緑のままだ。足した job が実際に走ったかは、job の一覧で確かめる。
親の PR のブランチの上に、子の PR を 4 本積んだ。どれも赤が付いていなかったが、それは CI が走っていなかったからだった。workflow は on.pull_request.branches: [develop, main] で絞っていた。GitHub の Workflow syntax には次のようにある。
The patterns defined in branches are evaluated against the target branch’s name.
照合されるのは PR の base (マージ先) で、子の PR の base は親の feature ブランチなので workflow が起動しない。親をマージして子の base を develop に替えれば走る。それまでは、手元で流した lint とテストの結果を PR 本文に書いておく。
赤が無いことと、緑の check があることは別だ。PR を見るときは check の数を見る。
AI レビューのツールも同じ形で緑に見えることがある。筆者が使っている codex-cli は、混雑時に ERROR: Selected model is at capacity で exit 1 になっても出力ファイルを作り、中身が「指摘 0 件」に見えた。結果のファイルだけでなく、終了コードを見る。
| 穴 | 確かめ方・塞ぎ方 |
|---|---|
| 実装を壊してもテストが通る | go test -overlay で壊したソースに差し替えて流す。片方だけが弾く入力を使う |
| OK を出す判定ツール | わざと NG になる入力を渡し、NG を出すことを見る |
alltrue([]) が true | 作成物でなく入力の側と突き合わせる (keys(...) == keys(...)) |
| setup が落ちて「拒否」の行が通る | 受理される行を表に入れる。実装の条件を 1 つずつ外して red を見る |
| 偽物が子プロセスに届かない | テストの入口で本物のトークンを unset。偽物はメソッドまで記録する |
| 期待値も同じ誤り | 外部コマンドは本物で 1 回動かす |
| 生成物の中身を見ない | 使う側が読む中身 (PNG の寸法、SVG の文字列) まで照合する |
| job の skip、走らない CI | output は fromJson() で読む。PR は check の数を見る |
共通するのは、緑しか見ていないテストや CI は、red を出せるかがまだ分かっていないことだ。足したら 1 回壊して red を見る。テストのハーネス自体が失敗を数えていなかった件は「自動実行のbashが突然落ちる・失敗を見逃す4つの罠」に、GitHub Actions から GCP へ鍵なしで繋ぐときの落とし穴は「GitHub ActionsのOIDCでGCPが403になる原因」に書いた。
どのテストが壊れ方を検知でき、どのテストが緑のまま何も守らないのか。偽物 (モック) をどこまで使うかも含めて、テストの価値を測る考え方から学び直すなら次の本がよい。
-overlay)on.pull_request.branches)fromJSON)