---
title: "確かめない技術 第4回：偽の失敗を読まない技術"
author: garplab
publisher: TypingTube
license: CC BY 4.0
license_url: https://creativecommons.org/licenses/by/4.0/
license_scope: 「CC BY 4.0」の印から始まる節（仕組み・検証手順・コード）。印の無い本文は著作権を留保
canonical: https://typing-tube.net/articles/tashikamenai-04-false-failures
series: "確かめない技術"
language: ja
---


今回は、偽の失敗の話です。偽の失敗というのは、コードは壊れていないのに、走らせ方のせいで落ちるテストのことです。私はある日、何も壊していないはずのコードで、見覚えのないエラーがずらりと並ぶ画面を受け取りました。

1 作目「読まない技術」の[第 1 回](https://typing-tube.net/articles/cd93e0c5cac832)で、テストの実行の入口をラッパー 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 本目を走らせない。**

## 仕組み: 1 本しか通さないロック

私は区別する努力をしません。エラーの山を賢く読み分けるツールも作りません。**山が生まれる条件のほうを消します**。テストの入口（1 作目 第 1 回のラッパー）でロックを一つ取り、取れなければ走らせない。

```bash
# 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 回でやったとおりです）。それから、偽の失敗の源は並列実行だけではありません——日付をまたぐ深夜の実行など、環境が作る偽物もあります。原理は同じで、読んで区別するより、条件を消すか検知して止める側に置きます。

## 生まれなくなったもの

エラーの山です。「この 53 個のうちどれが本物か」という切り分け作業は、山が生まれなくなったので消えました。偽の失敗を読んで存在しないバグを直しにいく AI を、途中で止める仕事も消えました。

## 注意: ロックが見ているのは、この入口を通った実行だけです

止まるのは、ラッパーを通って走り出した 2 本目だけです。素のコマンドを直に叩けば、ロックのある場所は通りません。だからこの回は、1 作目 第 1 回で置いた「素のコマンドを止める hook」とセットで初めて閉じます。入口が 1 本になっていない環境では、ロックは「行儀のいい実行だけが並ばない」以上のことをしません。

別の作業ディレクトリ（同じリポジトリを別に clone したもの）や CI から走ったものも、このロックの外側です。ロックはファイル 1 つで、置いた場所の中にしかいません。**同じテスト用のデータベースを共有する経路が他にもあるなら、そこは別に数えてください。**

## 検証手順: 2 本目が止まることを確かめる

**前提**

- テストの入口（1 作目 第 1 回のラッパー）に、ロックを組み込み終えていること
- **端末を 2 つ開けること**
- **1 本目が走り切るまでに数十秒はかかる**こと——一瞬で終わるテストだと、2 本目を起動する前に 1 本目が終わってしまいます

**所要時間**: 10 分

**手順**

1. **1 つ目の端末で、AI に**テストを 1 本走らせます
2. **走っている間に、2 つ目の端末から、あなたの手で** 2 本目を起動します。⚠️ AI に頼むのではなく、あなたが叩いてください。確かめたいのはロックの働きで、AI の判断ではありません
3. **1 本目が終わるのを待ってから、あなたが** 2 本目をもう一度起動します

**合格条件**（すべて満たすこと）

- 手順 2 で、2 本目が**テストを走らせる前に**止まる
- 手順 2 で、止まった場所に**実行中の相手がいること**が書かれている
- 手順 2 で、**出口が案内されている**——待つか、`--last` で前回の結果を読むか
- 手順 3 では、止まらずに普通に走り出す

**合格しなかったとき**

- **手順 2 でロックの消し方まで案内された** —— ⚠️ 案内の出し分けが壊れています。生きているロックの前に解除の方法を出すと、**先を急ぐ AI がそれを使います。** 消し方が出てよいのは、**ロックを取ったプロセスがもう居ないとき**だけです
- **手順 2 で 2 本並んで走った** —— ロックが入口の外側にあります。テストを起動する前に取る位置へ移します

**後始末**

- ロックのファイルが残っていないことを確かめます。残っていたら、以後のテストが全部止まります

止まるべきときに止まるのと、通るべきときに通るのと、両方を一度ずつ。

---

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