この記事は、連載「確かめない技術」の第 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. 1 つ目の端末で、AI にテストを 1 本走らせます
  2. 1 本目が走っている間に、2 つ目の端末から、あなたが自分の手で 2 本目を走らせます。ここで確かめたいのはロックの働きなので、AI の判断を間に挟みません
  3. 1 本目が終わるのを待ってから、あなたが 2 本目をもう一度走らせます

合格条件(すべて満たすこと)

合格の条件は 4 つです。手順 2 では、2 本目が走り出す前に止まり、止まったときの案内に、1 本目が実行中であることと、出口(待つか、--last で前回の結果を読むか)が書かれていることを確かめます。手順 3 では、2 本目が普通に走り出すことを確かめます。

合格しなかったとき

  • 手順 2 でロックの消し方まで案内された —— 案内の出し分けが壊れています。ロックを取ったプロセスがまだ走っているときに消し方を出すと、その消し方でロックが消され、2 本が同時に走ります。消し方を出してよいのは、ロックを取ったプロセスが終了しているときだけです
  • 手順 2 で 2 本が同時に走った —— ロックを取る処理が、テストを走らせる処理より後ろか、入口の外側にあります。ロックを取る処理を、ラッパーの中の、テストを走らせる前の位置へ移します

後始末

tmp/test_running.lock が消えていることを確かめます。残っていると、そのあとのテストが全部止まります。

この検証手順では、止まるべきときに止まることと、通るべきときに通ることを、一度ずつ確かめました。


CC BY 4.0 はここまで

次回は「全部走らせない技術」。私は、テストを全部は走らせていません。 それでも、走らせていないところは毎回、目の前に出てきます。