この記事は、連載「確かめない技術」の第 4 回です。うまくいかない症状と、その対処を一つずつ並べています。各回はファイルやスクリプトを一つ置けば完結します。その仕組みがなぜ要るのかは、置いたあとに解説を読めば分かります。連載の全体像と各回の一覧は序論にあります。
今回は、偽の失敗の話です。偽の失敗というのは、コードは正しく動くのに、テストの走らせ方のせいで出る失敗のことです。はある日、何も壊していないはずのコードのテストで、見覚えのないエラーがずらりと並ぶのを見ました。
は、1 作目「読まない技術」の第 1 回で、テストの入口を、ラッパー(テストを走らせるスクリプト scripts/test.sh)1 本にまとめ、AI がラッパーを通さずに素のコマンドでテストを走らせようとしたときに、hook でその実行を止める形にもしました。今回は、その入口にロックを 1 つ足します。
fixtures やテスト用DBを共有する構成の人は、手元で次のことを試してください。
端末を 2 つ開き、同じテストスイートを同時に走らせます(済んだら、テスト用DBを作り直して元の状態に戻します——Rails なら bin/rails db:test:prepare)。
画面に出てくるのは、見たことのないエラーの山です。fixtures のロードがデッドロックします。共有しているキャッシュ(Redis など)では、2 本の実行が互いのデータを上書きし合います。の環境で実際に起きた日の結果は、7 failures / 53 errors でした。
テストの出力に出てくるのは、その 1 本の結果だけです。別の日に 2 本が同時に走ったときは、「ルームがプレイ中ではありません」という、アプリの不具合と同じ形の失敗も出ました。
あなたのプロジェクトは、テスト用DBを 1 つ持ち、どのテストの実行も、fixtures を全部そこへロードする構成(Rails の既定)だとします。AI が全量(すべてのテスト)を走らせている途中で、部分実行がもう 1 本走り出しました。返ってきた結果には、何も変えていないのに、失敗とエラーが合わせて 60 個ありました。このとき、あなたなら次のどちらを選びますか。
- 失敗を 1 件ずつ読んで、本物の失敗を切り分ける
- テストの入口で、2 本目のテストを走り出す前に止める
答えは、走り出す前に止めるほうです。この 60 個は、2 本の実行が同じテスト用DBを同時に使ったことから出ました。出力にはもう 1 本の実行が出てこず、失敗はアプリの不具合と同じ形をしているので、1 件ずつ読んでも、どれが 2 本目のせいかは分かりません。は 7 failures / 53 errors が出た日、切り分けのために全量を 3 回走らせ直しました。2 本目を走り出す前に止めれば、山そのものが生まれません。
理解したことは一文にできます。
テストが 2 本同時に走ると、本物の失敗が偽の失敗の山に埋もれる。だから、山を読んで本物を探す代わりに、2 本目を走り出す前に止める。
CC BY 4.0
仕組み: 2 本目を走り出す前に止めるロック
がするのは、エラーの山を読み分けて本物の失敗を探すことの代わりに、山が生まれる条件を消すことです。
# 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 "Another test run is in progress (started $(cat "$lock/started" 2>/dev/null) / PID ${owner})." >&2
echo " Wait for it to finish. To see the result only: scripts/test.sh --last" >&2
else
echo "Stale lock (its owner PID ${owner:-unknown} is gone). Remove it with rm -r $lock and run again" >&2
fi
exit 1
fi
date '+%m/%d %H:%M' > "$lock/started"
echo $$ > "$lock/pid"
trap 'rm -rf "$lock"' EXIT
2 本目のテストは、走り出す前に止まります。1 本目が実行中のとき、2 本目を走らせた側に案内されるのは、ロックを残したまま使う出口だけで、待つか、--last で前回の結果を読むかです。--last は、この連載の第 3 回でラッパーに足したオプションで、テストを走らせ直さずに、前回の結果を表示するものです。ロックを取ったプロセスがすでに終了しているときだけ、ロックの消し方が案内されます。
ロックの消し方を出口として案内するのは、ロックを取ったプロセスが異常終了すると、ロックが消されずに残るからです。そのときの出口を用意しておかないと、それ以後のテストが、残ったロックに止められて 1 本も走らなくなります。
ただし、ラッパーは、ロックに書いたPIDで、ロックを取ったプロセスがまだ走っているかを確かめ、終了しているときだけ消し方を出します。ロックを取ったプロセスがまだ走っているときに消し方を出すと、その消し方でロックが消され、2 本目が 1 本目と同時に走り出すからです。消し方が出る条件をこう絞っておくので、もAIも、消し方なしで止まったときは、1 本目が本当に走っていると信用することができます。
テストを走らせるのは、メインのセッション(サブエージェントを起動した側)だけにしてください。
ロックが通すのは 1 本だけなので、サブエージェントとメインのセッションが同時にテストを走らせると、あとから走らせた側が止まり、1 本目が終わるまで待つことになります。
それから、偽の失敗は、テストが 2 本同時に走るとき以外にも生まれます。日付をまたぐ深夜の実行のように、走らせる環境が作る偽の失敗です。こちらも、は失敗を読んで本物を探す代わりに、日付が変わる直前には、全量の実行を走り出す前に止めています。
CC BY 4.0 はここまで
生まれなくなったもの
生まれなくなったのは、エラーの山です。「この 60 個のうちどれが本物か」という切り分けの作業は、山が生まれなくなったので、の仕事から消えました。
CC BY 4.0
注意: ロックが止めるのは、この入口を通った実行だけです
ロックで止まるのは、ラッパーを通して走らせた 2 本目だけです。素のコマンドで直にテストを走らせると、テストはラッパーの外で走り出します。だから、このロックが 2 本目を止め切るのは、1 作目 第 1 回で置いた hook(素のコマンドを止める仕組み)と組み合わせたときです。
別の作業ディレクトリ(同じリポジトリを別にクローンしたものや、git worktree)やCIから走ったテストも、このロックの外側で走ります。ロックは、作業ディレクトリの中に作る tmp/test_running.lock 1 つで、作業ディレクトリごとに別々です。worktree はDBの設定ファイルも同じなので、DBサーバーを共有していれば、同じテスト用DBを使います。
同じテスト用DBを使う経路がほかにもあるなら、その経路も、このロックの外側にあるものとして数えてください。
検証手順: 2 本目が止まることを確かめる
前提
テストの入口(1 作目 第 1 回のラッパー)に、ロックを組み込み終えておきます。端末は 2 つ開いておきます。1 本目には、走り切るまでに数十秒はかかるテストを選んでください。一瞬で終わるテストだと、2 本目を走らせる前に 1 本目が終わってしまいます。
所要時間: 10 分
手順
- 1 つ目の端末で、AI にテストを 1 本走らせます
- 1 本目が走っている間に、2 つ目の端末から、あなたが自分の手で 2 本目を走らせます。ここで確かめたいのはロックの働きなので、AI の判断を間に挟みません
- 1 本目が終わるのを待ってから、あなたが 2 本目をもう一度走らせます
合格条件(すべて満たすこと)
合格の条件は 4 つです。手順 2 では、2 本目が走り出す前に止まり、止まったときの案内に、1 本目が実行中であることと、出口(待つか、--last で前回の結果を読むか)が書かれていることを確かめます。手順 3 では、2 本目が普通に走り出すことを確かめます。
合格しなかったとき
- 手順 2 でロックの消し方まで案内された —— 案内の出し分けが壊れています。ロックを取ったプロセスがまだ走っているときに消し方を出すと、その消し方でロックが消され、2 本が同時に走ります。消し方を出してよいのは、ロックを取ったプロセスが終了しているときだけです
- 手順 2 で 2 本が同時に走った —— ロックを取る処理が、テストを走らせる処理より後ろか、入口の外側にあります。ロックを取る処理を、ラッパーの中の、テストを走らせる前の位置へ移します
後始末
tmp/test_running.lock が消えていることを確かめます。残っていると、そのあとのテストが全部止まります。
この検証手順では、止まるべきときに止まることと、通るべきときに通ることを、一度ずつ確かめました。
CC BY 4.0 はここまで
次回は「全部走らせない技術」。は、テストを全部は走らせていません。 それでも、走らせていないところは毎回、目の前に出てきます。