見返しページ(フォトブック)の実装を codex に振って、受入の検証を Claude に回していたときに踏んだ「全テスト緑なのに失敗扱い」の切り分け記録です。
起きたこと: 合格なのに失敗
受入のゲートとして、開発用のコンテナの中で npx vitest run(フルラン)を回したら、こういう結果が返ってきた。
Test Files 66 passed (66)
Tests 431 passed (431)
Errors 2 errors
431件すべて合格。なのに終了コードは 1=コマンドとしては失敗扱い。CI に載せていたらここで赤になります。
引っかかるのは3行目の Errors で、これはテストの成否とは別のカウントになっています。
エラーの中身はこうだった。
ReferenceError: window is not defined
❯ node_modules/react-dom/cjs/react-dom-client.development.js:17920:15
❯ Immediate.performWorkUntilDeadline node_modules/scheduler/cjs/scheduler.development.js:45:48
This error originated in "src/app/lab/pages/create/createPool.test.tsx" test file.
意味を読み下すと、テストファイル自体は終わってブラウザ環境(jsdom の window)が畳まれたのに、React のスケジューラが予約していた仕事の続きをあとから実行して、もう存在しない window に触った、ということ。テストの本体は全部通っているから、Tests 431 passed と矛盾しません。
このプロジェクトのテストは既定が Node 環境で、画面を触るテストだけがファイル先頭の // @vitest-environment jsdom でブラウザ環境を被せています(この時点で13本あった)。単体実行ならファイル終了でプロセスも終わりますが、フルランでは同じワーカーが次のファイルへ進みます。
単体では再現しない
発生元とされた createPool.test.tsx を単体で3回回したら、3回とも緑で Errors も出なかった。フルラン(並列実行)のときだけ、しかも出たり出なかったりする。回によって Errors が1件だったり2件だったりもする。
この「単体×・並列○・件数が揺れる」という再現条件そのものが、タイミング依存の競合=フレークのサインだった。
ただし、そもそも残務が積まれたまま終わる状態を作っているのはテスト側の書き方(非同期の後始末を待たずに終わっている箇所)のはずで、そこは今回追っていない。根本原因の特定は別タスクに送った。直したらその記事で書く。
新入りのせいか、前からか
ここで困るのが状況で、ちょうど codex が実装したフォトブックの新ブランチを受け入れるかどうかの検証中だった。フルランが赤くなれば、まず疑われるのは新入りのコードになります。
エラーの発生元は新実装と無関係の既存テストファイルだったけれど、「並列実行の順番が変わったせいで既存の競合が顕在化した」という可能性もあるので、発生元だけでは無実の証明になりません。
そこで出てきた切り分けが、新実装が入る直前のコミットに戻して同じフルランを回すという一手だった。
# 作業ツリーがクリーンなことを確認してから(未コミットの変更があると移動できない)
git checkout --detach <新実装の直前のコミット> # --detach = ブランチはそのまま、その時点のコードだけ見にいく
npx vitest run # → 同じ Unhandled Error が出た
git checkout <元のブランチ> # 見終わったら必ず元居た場所へ戻るつまり既存のフレークで、新入りは無実。ここまで確認して、受入は合格で通した。
直すのは別タスクでいい
もうひとつの判断は、このフレークをその場で直さなかったこと。受入検証の目的は「codex の実装が頼んだ仕様どおりか」であって、既存テストの競合退治はスコープの外にあります。切り分けの結果(発生元・再現条件・変更前でも再現)だけを記録して、修正は別のタスクに切り出した。
受入のついでに手を伸ばすと、新実装の差分と無関係の修正が同じブランチに混ざって、あとから「どこまでが codex の成果か」が濁ります。切り分けと修正は別の手番、と割ってすっきりした。別タスクに回した時点で、どのテストとどのテストが並列でぶつかっているのか、React のスケジューラの残りをどう畳めばいいのかは分かっていない。今回はっきりしたのは「新実装のせいではない」ところまでだ。
学んだこと
- 「テストが失敗した」と「テストランナーが失敗した」は別物だと今回で分かった。サマリは Tests 行しか見ていなかったが、全緑で exit 1 のときは Errors 行のほうに答えがあった
- 「単体では出ない・並列だと出る・件数が揺れる」は、タイミング競合のサインらしいです。発生元とされたテストファイルを単体で回しても、何回やっても再現しない以上そこに答えは無かった
- 因果の切り分けは「変更前の状態で再現するか」が効いた。エラーメッセージを読んで推理するより、コミットを戻して同じコマンドを1回回すほうが速く白黒がついた。効いたのは、原因が分からないままでも答えが出るから——知りたかったのは「なぜ消えた
windowに触るのか」ではなく「新実装のせいか」で、この2つは別の問いだった - 切り分けと修正を別の手番に割ったのは、あとから「どこまでが codex の成果か」を追えるようにするため。受入のスコープを守る意味が今回で腑に落ちた
別タスクに送った根本原因の特定と修正は、5日後の「全テスト緑なのに exit 1② — ランナーが指す「発生元」は犯人を教えてくれない」で書いた。そちらでは、この記事の「単体で3回緑だった」を無実の材料に使った見立てを取り消し、jsdom を被せているファイル数も13本から14本に数え直している。
この記事は2026年7月時点の自分の理解で書いています。仕様や認識が変わってる可能性があります。