テストもCIも緑なのに壊れた実装を通す穴

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

テストもCIも緑なのに壊れた実装を通す穴

本記事はプロモーションを含みます。

はじめに: 緑は「守れている」の証拠にならない

実装の順序の入れ替え、0 件の確認、本物と違う偽物や期待値、走っていない CI の job の 4 つの場面で、テストは緑のまま壊れた実装を通した

テストを足して、CI も緑になった。ところがレビューで実装をわざと壊してみると、テストはそれでも緑だった。筆者はこの 1 週間で、Go・Terraform・bash・GitHub Actions のそれぞれでこれを踏んだ。

結論から書くと、次の 3 つを習慣にすると多くを防げる。

  • テストを書いたら、実装か入力をわざと 1 か所壊して、red になるかを 1 回見る。Go なら go test -overlay で作業ツリーを書き換えずに壊せる
  • 「全部 OK」を確かめるテストは、確かめた件数が 0 でも通る。作った物の側でなく、期待する入力の側と突き合わせる
  • CI の緑は「走って通った」とは限らない。job が skip されていないか、check が付いているかを見る

以下、どの穴も実際に踏んだもので、どう気付いてどう塞いだかを順に書く。

実装をわざと壊して、テストが落ちるかを見る

テストが red になるのは、テストが想定した壊れ方をしたときだけだ。想定していない壊れ方に対して緑のままかどうかは、壊してみるまで分からない。

go test -overlay なら作業ツリーを書き換えずに壊せる

go test は ok、-overlay で順序を入れ替えたソースに差し替えると FAIL、git status には何も出ない

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 になる。順序を確かめるには、片方だけが弾く入力 (認証は通るがトークンが無い、など) で、どちらが先に答えたかが応答から分かる形にする。

比べるツールを作ったら、ツールにも NG を出させる

変更後の plan は移動以外の差なしで OK、moved を 1 つ抜くと削除と作成 4 件で NG を出した

テストに限らず、「OK」を出す道具は同じ穴を持つ。5 つの環境の Terraform を共通の module に寄せたとき、筆者は先に 2 つのツールを作った。5 環境を書き換える codemod と、変更前後の plan が「移動以外は同じ」かを判定するスクリプトだ。

5 環境とも判定は OK で、移動の件数は各 state の全件数と一致した。ただ、OK しか見ていない判定は、NG を出せるかがまだ分からない。そこで moved ブロックを 1 つ抜いて流し、DNS のリソースの削除と作成 4 件で NG が出ることを確かめてから、判定を信じた。

確かめる対象が 0 件でも通る

Terraform の alltrue([]) は true

alltrue([]) は true になり、作成物を走査する assert は for_each が空でも 8 run すべて通る。入力のキーと作成物のキーを比べれば落ちる

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 度見る。

setup が落ちても「拒否されること」のテストは通る

setup が unbound variable で落ちると、非ゼロ終了を期待する行は通ってしまう。受理される行を 1 つ入れると FAIL になり気付ける

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="" で空文字を入れる
  • 受理されることを見る行 (正しい JSON) も表に入れる。setup が落ちればこの行が red になり、気付ける

そのうえで、実装の条件を 1 つずつ外し、そのたびに red になることを確かめた。local の宣言と set -e の関係は「bashのset -eが効かない5つの場面と対処法」にも書いた。

偽物と期待値が本物とずれる

関数で差し替えた curl は子プロセスに届かない

関数で定義した curl は bash -c の子からは本物 (file) に見え、export -f すると function になる。子のスクリプトは本物の curl と手元のトークンで API を叩く

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 を変えると、普段のサーバと分けて試せる。

生成物は、使う側が読む中身まで見る

ICO の目録は 16・32・48 でも、中の PNG が全部 16x16 なら目録だけ見るテストは PASS する。PNG の IHDR まで照合すれば気付ける

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" で文字列として読み、中身をテストで固定できる。

CI の緑は「走って通った」とは限らない

output の末尾の改行で job が skip される

output が true と改行の文字列だと == 'true' は偽で job は skip、fromJson で読むと run になる。skip は失敗でないので CI は緑のまま

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 には check が 1 つも付かない

pull_request の branches が develop と main のとき、base が develop の PR には check が付くが、base が feature ブランチの PR には 1 つも付かない

親の 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、走らない CIoutput は fromJson() で読む。PR は check の数を見る

共通するのは、緑しか見ていないテストや CI は、red を出せるかがまだ分かっていないことだ。足したら 1 回壊して red を見る。テストのハーネス自体が失敗を数えていなかった件は「自動実行のbashが突然落ちる・失敗を見逃す4つの罠」に、GitHub Actions から GCP へ鍵なしで繋ぐときの落とし穴は「GitHub ActionsのOIDCでGCPが403になる原因」に書いた。

どのテストが壊れ方を検知でき、どのテストが緑のまま何も守らないのか。偽物 (モック) をどこまで使うかも含めて、テストの価値を測る考え方から学び直すなら次の本がよい。

単体テストの考え方/使い方 (Vladimir Khorikov 著、須田智之 訳、マイナビ出版) 画像: 楽天市場

単体テストの考え方/使い方 (Vladimir Khorikov 著、須田智之 訳、マイナビ出版)

参考