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. ローカル開発では問題が起きない
ローカルだと .env の localhost: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 は以下の順序で環境変数を読み込む:
-
.env(ベース) -
.env.test(モードに応じて上書き)
これで .env.test の VITE_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