5日前に「全テスト緑なのに exit 1」を切り分けたとき、原因の特定は別タスクに送って「直したら書く」と残していました。その続きです。
前回どこまで分かっていたか
前回の到達点はこうだった。
- テストは全部合格しているのに、コマンドの終了コードが 1(=失敗扱い)になります
- 正体は
ReferenceError: window is not defined。テストファイルが終わってブラウザの偽物(jsdom)が撤収された後に、React のスケジューラが予約していた仕事を実行して、もう無いwindowに触っていた - 単体では出ません。フルラン(並列実行)のときだけ、しかも出たり出なかったりします
- 新実装が1行も入っていないコミットでも再現した=新入りは無実
分かっていなかったのは「じゃあ誰のせいなのか」。今回はそこを潰した。
ランナーが指す「発生元」は犯人の証明にならない
vitest はエラーにこう添えてきます。
ReferenceError: window is not defined
This error originated in "src/app/lab/pages/create/createPool.test.tsx" test file.
It doesn't mean the error was thrown inside the file itself, but while it was running.
後半の1文を読み飛ばしていた(実際の出力では2行目に続けて1行で出ます。ここでは読みやすさのため折り返した)。**「そのファイルの中で投げられたという意味ではなく、そのファイルが走っている間に起きたという意味だ」**とランナー自身が書いています。
名前が出るのは「そのとき走っていた人」であって、「予約を残した人」ではありません。
前回「発生元とされたファイルを単体で3回回したら3回とも緑だった」のも、そのファイルが無実だったからではなかった。犯人であっても単体では出ません。あの3回の緑は、無実の証明ではなく何の情報でもなかった。
犯人は「書き方の差分」から出てきた
発生元が当てにならないなら、どこから探すか。今回効いたのは、同じ環境を使うファイルを全部並べて、規律に従っていないものを数える、という手だった。探す方向は自分で決めて、14本を洗い出して仕分ける検索そのものは AI に回した。
前回は「createPool.test.tsx だけが jsdom を被せている」と書いた。数えたら14本あった。発生元に名前が出たファイルしか見ていなかったから、そう見えていただけだった。この14本を、それぞれが「画面を畳む後片付け」をどうしているかで仕分けた。
仕分けたらこうなった(render =テストの中で画面を1枚組み立てること、afterEach =テストが1つ終わるたびに必ず走る後片付けの枠)。
| 後片付け | render の回数 | ファイル数 |
|---|---|---|
afterEach で明示的に畳む |
4〜7回 | 4本 |
| 畳まない代わりに render を1回に揃える | 1回 | 7本 |
| どちらでもない | 2〜3回 | 3本 |
まず3本目のグループから潰した。画面を2回・3回と組み立てておいて、1回も畳まずにファイルが終わる=置き去りの数がいちばん多いです。(シロに見えた1回組のほうは、あとで戻ってくる。)
この3本のうち1本は、前回「発生元」と名指しされた createPool そのものだった。ただしそこへは発生元の表示から辿り着いたわけではなく、規律の差分を数えた結果そうなっただけだ。残り2本は名前すら出ていない。名指しを信じて1本直して終わりにしていたら、3分の1しか塞げていなかった。
「直った」は統計で言うしかない
ここが今回いちばん考えたところ。出たり出なかったりするバグは、直したあと1回走らせて緑でも何の証拠にもなりません。もともと5回に1回しか出ないのだから、直っていなくても8割は緑になります。
そこで、直す前に基準線を測った。やったのは、同じフルランを20回まわして「失敗扱い(終了コードが 0 以外)」が何回出るかを数えるだけ。ループを回したのは AI で、何回まわすか・何をもって「直った」と言うかは自分で決めた。
結果は20回中4回(20%)。ここを起点にして、直したあと同じことをする。
- 3本に後片付けを足した直後 → 80回中1回
- 後述の設定変更まで入れたあと → 180回まわして
window is not definedはゼロ
減ってはいるが、途中の「80回中1回」で完全には消えていなかった。ここで手を止めなくてよかったと思う。1回走らせて緑を見て終わりにしていたら、そのまま残っていた。残ったその1回が何だったのかは、次に書くとおり出力を捨てていて追えなかった。だから「3本で全部だった」とは言えないまま、次を考えることになった。
失敗したこと
測るループを回すとき、落ちた回の出力を捨てていた。終了コードだけ数えて中身を残さなかったので、80回中1回残ったその1回が何だったのか追えなくなった。
しかも次に回すときに同じ穴を踏んだ。今度は保存するようにしたのに、エラー行を拾う検索条件を狭く書きすぎて、window is not defined 以外の落ち方をした回が空っぽで記録された。2回目の取り逃し。
保存は「絞ってから」ではなく丸ごと、と覚えた。絞る条件を決めるということは、その時点で「どういうエラーか」を決め打ちしているのと同じで、想定外のものを見落とします。
直し方を、置き場所ごと変えた
80回に1回が残っている以上、3本で全部だったとは言えない。そこで残り7本を見直した。あれは「畳まない代わりに render を1回に揃える」流儀で、テスト同士がぶつかることはありません。でもファイルが終わるときに置き去りにする点は同じだった。
7本に同じ1行を足して回ることもできたけれど、そうすると次に画面のテストを1本書いたとき、また書き忘れます。実際、14本のうち7本が守れていなかった規律だ。
なので、置き場所を変えることにした。各テストファイルではなく、全テスト共通の出口に1つ置く。
// vitest.setup.ts
import { afterEach } from "vitest";
// 全テスト共通の出口。どのファイルのどのテストも、終わるたびにここを通る
afterEach(async () => {
// Node 環境のテストには画面自体が無いので素通り(ライブラリも読み込まない)
if (typeof document === "undefined") return;
// 画面があるときだけ後片付けの道具を取り出して、組み立てた画面を畳む
const { cleanup } = await import("@testing-library/react");
cleanup();
});テストランナーの設定ファイルに「毎回このファイルを先に読む」という1行(setupFiles)を足せば、全テストに適用されます。これで書き忘れようがなくなった。
書き手が覚えておく前提の規律だったが、14本中7本という時点でもう破られていた。
学んだこと
- エラーメッセージの但し書きを読みます。「発生元」を犯人だと思い込んでいたが、ランナーは「そのファイルの中で投げられたという意味ではない」と最初から書いていた。実際そのファイルは犯人の1本ではあったが、残り2本は最後まで名前が出てこなかった。当たっている名指しほど、そこで探索を止めさせます
- 発生元が当てにならないとき、同じ条件のファイルを全部並べて規律から外れたものを数える方が速かった。今回は検索2回で14本から3本に絞れた
- 出たり出なかったりするバグは、直す前に基準線を測ります。1回緑でも証拠になりませんし、逆に1回赤でも直っていないとは言えません。20% が 180回ゼロになった、という形でしか言えない
- 測るときは落ちた回の出力を丸ごと保存します。2回取り逃してから覚えた。絞る条件を書いた時点で、想定外の落ち方が見えなくなります
- 各ファイルで守る決まりが1回でも破られていたら、置き場所を疑います。今回は半分が破っていた。それは書き手の不注意ではなく、置き場所の問題だった
前回の記事で「根本原因の特定は別タスクに送った」と書いたときは、どのテストとどのテストがぶつかっているのかも、スケジューラの残りをどう畳むのかも分かっていなかった。5日かかったが、切り分けと修正を別の手番に割る判断は今回もそのまま通した。受入検証のついでにこれを追っていたら、20回・180回を測るところまでは手が回らなかった。
この記事は2026年7月時点の自分の理解で書いています。仕様や認識が変わってる可能性があります。