今回は、偽の失敗の話です。偽の失敗というのは、コードは壊れていないのに、走らせ方のせいで落ちるテストのことです。私はある日、何も壊していないはずのコードで、見覚えのないエラーがずらりと並ぶ画面を受け取りました。
1 作目「読まない技術」の第 1 回で、テストの実行の入口をラッパー 1 本にまとめ、素のコマンドは hook で止める形にしました。今回は、その入口に鍵を 1 つ足します。
再現できる環境の人だけ、一つ試してください。端末を二つ開き、同じテストスイートを同時に走らせます(テスト用 DB や fixtures を共有する構成なら、ローカルで再現できます。済んだらテスト DB を作り直して戻します——Rails なら bin/rails db:test:prepare)。
出てくるのは、見たことのないエラーの山です。fixtures のロードがデッドロックし、共有しているキャッシュや Redis が混線して、互いのデータを踏み合う。私の環境で実際に起きた日は、7 failures / 53 errors でした。この 60 個の中に本物の失敗が混ざっていたのか——それを切り分けるために、私は 277 秒の全量実行を 3 回再実行するはめになりました。
昔なら「同時に 2 本走らせるなんて誰がやるんだ」で済んだ話です。いまは違います。並列実行は AI の時代の標準的な事故です。人間が複数のセッションを並列実行する。サブエージェントが気を利かせてテストを回す。前回見たとおり、AI は「確認のため」にもう 1 本流す。そして走らせた AI は、自分以外の実行を知りません。エラーの山を見た AI が次に何をするかというと、偽陽性(偽の失敗)を 1 件ずつ読んで、直しにいきます。存在しないバグの修正で、動いていたコードが壊れていきます。
理解したことは一文にできます。
テストは 2 本同時に走ると、本物の失敗が偽の失敗の山に埋もれる。読んで区別するのではなく、2 本目を走らせない。
CC BY 4.0
仕組み: 1 本しか通さないロック
私は区別する努力をしません。エラーの山を賢く読み分けるツールも作りません。山が生まれる条件のほうを消します。テストの入口(1 作目 第 1 回のラッパー)でロックを一つ取り、取れなければ走らせない。
# scripts/test.sh の実行直前(mkdir -p tmp より後)に足す
lock=tmp/test_running.lock
if ! mkdir "$lock" 2>/dev/null; then
owner=$(cat "$lock/pid" 2>/dev/null)
if [ -n "$owner" ] && kill -0 "$owner" 2>/dev/null; then
echo "別のテストが実行中です($(cat "$lock/started" 2>/dev/null) 開始 / PID ${owner})。" >&2
echo " 終わるのを待ってから実行してください。結果を見るだけなら: scripts/test.sh --last" >&2
else
echo "残骸のロックです(主の PID ${owner:-不明} はもう居ません)。rm -r $lock で消して再実行してください" >&2
fi
exit 1
fi
date '+%m/%d %H:%M' > "$lock/started"
echo $$ > "$lock/pid"
trap 'rm -rf "$lock"' EXIT
2 本目は走り出す前に止まります。実行中に案内されるのは、ロックを消さない道だけです——待つか、--last で前回の結果を読むか。ロックの主のプロセスがもう居ないときだけ、消し方そのものが案内されます。AI はこの案内を読んだときにだけ先へ進めます——ここでも、読んでいなければ動けない形です。
消し方の道は飾りではありません。プロセスが異常終了すればロックは残ります。そのときの出口を用意しておかないと、ガードレールが邪魔になった誰かが、ガードレールごと壊します。ただし出口は選んで見せます——ロックに書いた PID の生存を確かめ、主が居ないときだけ消し方を出す。生きているロックの前に解除コマンドを置くと、先を急ぐ AI がそれを使ってしまうからです。正規の出口が正しい条件でだけ現れるから、ロックは信用され続けます。
隣の話を二つ、短く。サブエージェントにはテストを走らせないでください。ロックは 1 本しか通さないので、メインと取り合ってどちらかが必ず止まり、止まった側の作業はそこまでの積み上げごと無駄になります(サブエージェントの扱いは 1 作目 第 5 回でやったとおりです)。それから、偽の失敗の源は並列実行だけではありません——日付をまたぐ深夜の実行など、環境が作る偽物もあります。原理は同じで、読んで区別するより、条件を消すか検知して止める側に置きます。
CC BY 4.0 はここまで
生まれなくなったもの
エラーの山です。「この 53 個のうちどれが本物か」という切り分け作業は、山が生まれなくなったので消えました。偽の失敗を読んで存在しないバグを直しにいく AI を、途中で止める仕事も消えました。
CC BY 4.0
注意: ロックが見ているのは、この入口を通った実行だけです
止まるのは、ラッパーを通って走り出した 2 本目だけです。素のコマンドを直に叩けば、ロックのある場所は通りません。だからこの回は、1 作目 第 1 回で置いた「素のコマンドを止める hook」とセットで初めて閉じます。入口が 1 本になっていない環境では、ロックは「行儀のいい実行だけが並ばない」以上のことをしません。
別の作業ディレクトリ(同じリポジトリを別に clone したもの)や CI から走ったものも、このロックの外側です。ロックはファイル 1 つで、置いた場所の中にしかいません。同じテスト用のデータベースを共有する経路が他にもあるなら、そこは別に数えてください。
検証手順: 2 本目が止まることを確かめる
前提
- テストの入口(1 作目 第 1 回のラッパー)に、ロックを組み込み終えていること
- 端末を 2 つ開けること
- 1 本目が走り切るまでに数十秒はかかること——一瞬で終わるテストだと、2 本目を起動する前に 1 本目が終わってしまいます
所要時間: 10 分
手順
- 1 つ目の端末で、AI にテストを 1 本走らせます
- 走っている間に、2 つ目の端末から、あなたの手で 2 本目を起動します。⚠️ AI に頼むのではなく、あなたが叩いてください。確かめたいのはロックの働きで、AI の判断ではありません
- 1 本目が終わるのを待ってから、あなたが 2 本目をもう一度起動します
合格条件(すべて満たすこと)
- 手順 2 で、2 本目がテストを走らせる前に止まる
- 手順 2 で、止まった場所に実行中の相手がいることが書かれている
- 手順 2 で、出口が案内されている——待つか、
--lastで前回の結果を読むか - 手順 3 では、止まらずに普通に走り出す
合格しなかったとき
- 手順 2 でロックの消し方まで案内された —— ⚠️ 案内の出し分けが壊れています。生きているロックの前に解除の方法を出すと、先を急ぐ AI がそれを使います。 消し方が出てよいのは、ロックを取ったプロセスがもう居ないときだけです
- 手順 2 で 2 本並んで走った —— ロックが入口の外側にあります。テストを起動する前に取る位置へ移します
後始末
- ロックのファイルが残っていないことを確かめます。残っていたら、以後のテストが全部止まります
止まるべきときに止まるのと、通るべきときに通るのと、両方を一度ずつ。
CC BY 4.0 はここまで
次回は「全部走らせない技術」。私は、テストを全部は走らせていません。それでも、走らせていないところは毎回、目の前に出てきます。