🔍

93件のE2Eテストが1行で直った話(Vite --mode の罠)

に公開

この記事が役立つ方

  • AI(Claude Code 等)で開発中、E2Eテストのエラーが解決できず困っている方
  • net::ERR_CONNECTION_REFUSED で検索してたどり着いた方
  • Playwright + Vite の環境変数設定でハマっている方
  • Claude Code の Sonnet / Opus でデバッグ結果が違う理由を知りたい方

E2Eテスト、全部グリーン。「よし、完璧」。

そう思ってコミットしようとしたら、CIで93件が真っ赤に染まっていた。

「は?ローカルで通ったのに?」

エラーは全部同じ。net::ERR_CONNECTION_REFUSED

...あれ、これサーバー落ちてる?いや、さっき動いてたよな?

Claude Sonnet に聞いてみた。
「CORSの設定を確認してください」。確認した。問題ない。
「認証周りを見直してみては?」。見直した。正常。

「いや、だからそうじゃないんだって...」

1時間格闘して、疲れ果てて Opus に切り替えた。
結果、デバッグログ1行で原因を特定。

修正は、たった1行だった。

あの1時間は何だったんだ...。

先に結論

// Before
command: 'npm run dev',

// After
command: 'vite --mode test',

Viteはデフォルトで .env を読む。.env.test を読ませるには --mode test が必要。

たったこれだけ。

これを知らなかったがために、1時間を溶かした。
同じ轍を踏む人が一人でも減りますように...。

環境

項目 バージョン
Vite 6.0.1
Playwright 1.49.0
Node.js 22.x

症状

Console error: Failed to load resource: net::ERR_CONNECTION_REFUSED

93件のE2Eテストが「ネットワークエラーが発生しました」で失敗。
認証が必要なページ(Goals, Summary, Profile, Meals, Foods)すべてで発生。

スクリーンショットを見ると、どのページも同じエラー画面。
壮観というか、なんというか...。

Claude Sonnet vs Opus: 名探偵と新人刑事の違い

ここからが面白い(当時は全然面白くなかったけど)。

Sonnet のアプローチ(新人刑事タイプ)

試みた修正 結果
AuthContext の認証情報復元を追加 問題解決せず
ページコンポーネントで authLoading を待つ 問題解決せず
curl で API Gateway に直接アクセス 正常動作

curl が成功した時点で、Sonnet は「API は問題ない」と結論づけた。

「curl で動くならサーバーは生きてる。じゃあフロントの問題だな」

...うん、気持ちは分かる。私もそう思った。

でもこれ、刑事ドラマで言うと「凶器が見つからないから犯人じゃない」と決めつけてるのと同じ。
「そもそもリクエストが送られているか」を確認してない。

Opus のアプローチ(ベテラン刑事タイプ)

Opus は最初にこう言った。

「まず、本当にリクエストが送られてるか確認しましょう」

// まず、リクエストが本当に送信されているか確認
page.on('request', request => {
  if (request.url().includes('execute-api')) {
    console.log(`>> Request: ${request.method()} ${request.url()}`);
  }
});

テストを実行。

結果: >> Request: が1回も出力されない。

「あれ...リクエスト、送ってないじゃん」

この瞬間、視界が開けた。
問題は API Gateway でも認証でもない。そもそもリクエストが送信されていない。

犯人は現場にすらいなかったのだ。

原因:環境変数の「よくある勘違い」

playwright.config.ts の webServer 設定を見てほしい:

webServer: {
  command: 'npm run dev',  // ← こいつが犯人
  url: 'http://localhost:5173',
  timeout: 120 * 1000,
},

一見、普通の設定。何も怪しくない。

でも、Vite は npm run dev を実行すると、デフォルトで .env を読み込む

.env:      VITE_API_BASE_URL=http://localhost:3000
.env.test: VITE_API_BASE_URL=https://xxx.execute-api.ap-northeast-1.amazonaws.com/dev

つまり、E2Eテスト中のフロントエンドは localhost:3000 にリクエストを送っていた。

...そこには何も起動していない。

「接続拒否」当たり前だ。誰もいない家のドアをノックしてたんだから。

なぜ気づきにくいか

これ、本当に気づきにくい。理由は3つ。

1. ローカル開発では問題が起きない

ローカルだと .envlocalhost:3000 にバックエンドが起動してることが多い。
だから「ローカルで通る」んだけど、CI/テスト環境では違う。

「俺のマシンでは動く」の典型例。

2. エラーメッセージが誤解を招く

ERR_CONNECTION_REFUSED って言われたら、普通は「サーバーが落ちてる?」って思う。

まさか「そもそも違う場所にリクエスト送ってる」とは思わない。

カーナビが「目的地に到着しました」って言うけど、実は隣町に着いてた、みたいな。

3. サブプロセスの罠

Playwright が npm run dev を実行すると、それは別プロセスとして起動する。

親プロセス(Playwright)で process.env.VITE_API_BASE_URL を設定しても、子プロセス(Vite)には伝わらない。
Vite は自分で .env を読み直す。

親の心、子知らず。

解決策

webServer: {
  command: 'vite --mode test',  // .env.test を読み込む
  url: 'http://localhost:5173',
  timeout: 120 * 1000,
},

Vite の --mode フラグで .env.[mode] を読み込める。

--mode test を指定すると、Vite は以下の順序で環境変数を読み込む:

  1. .env (ベース)
  2. .env.test (モードに応じて上書き)

これで .env.testVITE_API_BASE_URL が正しく適用される。

結果

修正前 修正後
93件失敗 10件失敗

83件が1行で復活。

残り10件は別の問題(UIの期待値不一致)だったけど、それはまた別の話。

教訓

1. 「推測」より「観測」

Sonnet(と私)は「CORS かな?」「認証かな?」と推測を重ねた。
Opus は「まずリクエストが送られてるか見ましょう」と観測から始めた。

デバッグの基本中の基本なのに、焦ってると忘れる。

医者が「たぶん風邪でしょう」って言わないのと同じ。
まず検査する。話はそれからだ。

2. Vite の環境変数読み込み順序

.env              # 常に読み込まれる
.env.local        # 常に読み込まれる(gitignore推奨)
.env.[mode]       # 指定されたモードでのみ
.env.[mode].local # 指定されたモードでのみ

--mode test.env.test.env を上書きする。
これ、公式ドキュメントに書いてあるんだけど、ハマるまで読まないよね...。

3. Claude Sonnet vs Opus の使い分け

Sonnet: 症状 → 仮説 → 修正 → 失敗 → 同じ仮説で再試行(ループ)
Opus:   症状 → ログ追加 → 事実確認 → 仮説修正 → 根本原因特定

Sonnet は「よくあるパターン」に当てはめようとする。
Opus は「まず事実を確認」してから考える。

デバッグは Opus、実装は Sonnet。使い分け大事。

...とはいえ、これも結果論。
Sonnet でも「まずログ入れて」と頼めば同じことができたかもしれない。

結局、AI に頼るにしても、「まずログを入れて事実を確認する」というステップは省略してはいけない。

おまけ:同じ罠にハマらないためのチェックリスト

E2Eテストで ERR_CONNECTION_REFUSED が出たら:

  • リクエストが本当に送信されているか確認(ログを入れる)
  • 環境変数が正しく読み込まれているか確認
  • --mode フラグを指定しているか確認
  • サブプロセスの環境変数が親から分離されていないか確認

このチェックリストを最初にやっていれば、1時間も溶かさなかったのに...。

参考

Discussion