きっかけは、社内サイトのお問い合わせフォームを触るPRのレビューでした。
「E2Eテストあるので大丈夫です」と言われて、私も一瞬そう思ったんです。確かに tests/e2e/ にPlaywrightのスモークテストが置いてある。設定ファイルもある。npm run test:e2e も生えている。
ただ、レビュー中のPRのチェック欄には validate と deploy-preview の2つしか並んでいませんでした。E2Eという名前のチェックがない。
その場でワークフローを全部開いて確認しました。.github/workflows/ にあるのは deploy.yml と preview.yml の2本だけで、どちらにも test:e2e を呼ぶ行はありませんでした。テストファイルのコミット履歴を見たら追加は5月4日。この記事を書いている時点で106日前です。
つまり、私たちのE2Eテストは書かれた日から一度も自動実行されていませんでした。
「テストがある」は4段階ある
テストは目的ではなく手段です。手段が働いているかどうかは、存在の有無では判定できません。今回の件を整理して、私は自分の中でこう分解しました。
| 段階 | 確認方法 | 今回の自社の状態 |
|---|---|---|
| 1. 存在する | リポジトリにテストファイルがある | 満たしていた(5ケース) |
| 2. 実行される | CI設定に起動する行がある | 満たしていない(106日間0回) |
| 3. 正しい対象に向いている | 検証対象URLがその変更の成果物を指す | 満たしていない(既定が本番) |
| 4. 失敗を説明できる | 落ちた理由を後から追える証跡が残る | 満たしていない(トレース0件) |
1段階目しか満たしていないものを、レビューの場で「テストがあるから大丈夫」の根拠にしていた。これが今回の本題です。
「重いから外していた」のではなかった
CIに載っていない理由として、私は最初「実行時間が重いから誰かが外したんだろう」と考えていました。E2Eは遅い、という思い込みです。
なので、まず測りました。npx astro build でサイトを生成し(111ページ、8.95秒)、npx astro preview をバックグラウンドで立てて、E2E_BASE_URL にそのローカルURLを渡して流します。結果はこうでした。
Running 5 tests using 1 worker
ok 1 [chromium] › contact.spec.ts › C1: 必須バリデーションが各 Step で先送りを阻止する (2.3s)
- 2 [chromium] › contact.spec.ts › C2: 全入力 → 200 OK → サンクス表示 (smoke)
ok 3 [chromium] › lp-ai-web.spec.ts › L1: 必要な 7 フィールドが描画されている (phone を含む) (998ms)
ok 4 [chromium] › lp-ai-web.spec.ts › L2: 必須欠落で送信ブロック・エラーメッセージ表示 (980ms)
- 5 [chromium] › lp-ai-web.spec.ts › L3: 全入力 → 200 OK → サンクス UI 表示 (smoke)
2 skipped
3 passed (7.3s)
7.3秒でした。
スキップ2件はメール送信を伴うケースで、環境変数を立てたときだけ動く作りになっています。これは正しい設計です。問題は残り3件のほうで、たった7秒の検査を106日間さぼっていたことになる。
正直なところ、ここで一番こたえたのは「重かったから」という言い訳が使えなかったことでした。単に載せ忘れていただけです。
ちなみに、実行そのものは7秒ですが、CIで毎回かかるのはブラウザの取得です。ローカルの取得済みディレクトリを測ったら chromium が428MB、ヘッドレスシェルが272MBありました。設計上のコストはテストではなくブラウザのキャッシュで、ここを外すとジョブ全体が数分単位で伸びます。
想定外だったのは、載せても緑になることだった
ワークフローに1ジョブ足せば終わり、のつもりでした。
念のため、テストが「その変更を見ているか」を確認することにしました。やり方は単純で、ビルド成果物のほうを意図的に壊します。LPのフォームにある name="phone" を name="tel" に書き換えて、7フィールドの存在を見ているL1を落としにいく。
# ビルド成果物側の name="phone" を name="tel" に置換した状態で2通り流す
E2E_BASE_URL=http://localhost:4321 npx playwright test tests/e2e/lp-ai-web.spec.ts
# 1 failed L1: 必要な 7 フィールドが描画されている (phone を含む) (14.4s)
npx playwright test tests/e2e/lp-ai-web.spec.ts # E2E_BASE_URL 未指定
# ok 1 L1: 必要な 7 フィールドが描画されている (phone を含む) (1.0s)
# 2 passed (6.6s)
同じ壊れたビルドに対して、片方は落ちて、片方は通りました。
理由は設定の1行です。
// playwright.config.ts(発見時点)
const baseURL = process.env.E2E_BASE_URL ?? 'https://xecin.jp';
export default defineConfig({
testDir: './tests/e2e',
workers: 1,
fullyParallel: false,
retries: 0, // 再試行しない
use: {
baseURL,
trace: 'on-first-retry', // 再試行しないので永久に取れない
video: 'on', // スキップしたケースの分まで録る
screenshot: 'only-on-failure',
},
});
既定の向き先が本番です。環境変数を渡さなければ、CIは「そのPRのビルド」ではなく「今動いている本番サイト」を検査します。本番は当然壊れていないので、テストは緑になる。
これをそのままワークフローに足していたら、壊れたPRでもE2Eのチェックだけは緑で並ぶ状態を作っていました。しかもチェック欄に e2e という文字が増えるぶん、レビューの安心感だけは上がる。今思えば、実行されていない状態よりタチが悪い出来上がりでした。
動いた、緑になった、は品質の根拠になりません。何に対して緑なのかがセットでなければ意味がない、というのを久しぶりに手で確認した形です。
失敗の証跡が残っていなかった
もう一点、落ちたときの挙動も測っておきました。上のL1が失敗した実行で test-results/ に残ったのは動画とエラーコンテキストのファイルだけで、トレースは0件でした。
trace: 'on-first-retry' は名前のとおり「1回目の再試行のときに取る」設定です。そして retries: 0 は再試行しない設定です。この2つが並んでいると、トレースは理屈のうえで一生生成されません。設定ファイルとしては両方とも見慣れた行なので、並べて置いてあると違和感がないんですよね。
逆に video: 'on' は素直に効きすぎていて、成功したケースもスキップしたケースも録画が残りました。5ケース流して動画5本、449KBです。ローカルなら誤差ですが、CIのアーティファクトとして毎PR残すと効いてきます。
| 設定 | retries: 0 のとき実際に残るもの | 判断 |
|---|---|---|
| trace: ‘on-first-retry’ | 何も残らない | 再試行を有効にするか、設定を変える |
| video: ‘on’ | 全ケース分(スキップ含む) | 失敗時のみに絞る |
| screenshot: ‘only-on-failure’ | 失敗ケース分のみ | このままでよい |
直した順番
直す順番は、さっきの4段階をそのまま下から潰しました。実行される → 正しい対象に向く → 証跡が残るの順です。
まずワークフロー側。プレビューをS3へ配ったあとに、そのPR専用のURLへ向けて流します。
e2e:
needs: deploy-preview
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "22"
cache: "npm"
- run: npm ci
# ブラウザ本体はキャッシュしないと毎回700MB近く取りに行く
- uses: actions/cache@v4
id: pw-cache
with:
path: ~/.cache/ms-playwright
key: pw-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
- if: steps.pw-cache.outputs.cache-hit != 'true'
run: npx playwright install --with-deps chromium
- run: npm run test:e2e
env:
# 既定の本番ではなく、このPRのプレビューを見る
E2E_BASE_URL: https://preview.xecin.jp/pr-${{ github.event.number }}
次に設定ファイル。証跡まわりを実態に合わせます。
// playwright.config.ts(修正後)
export default defineConfig({
// CIでは1回だけ再試行し、その試行のトレースを残す
retries: process.env.CI ? 1 : 0,
use: {
baseURL: process.env.E2E_BASE_URL ?? 'http://localhost:4321',
trace: 'on-first-retry',
video: 'retain-on-failure',
screenshot: 'only-on-failure',
},
});
既定値は本番からローカルのプレビューサーバに変えました。取り違えたときに壊れるほう、ではなく、取り違えたときに落ちるほうを既定にする、という考え方です。
ここは改善の余地があります。CIでの再試行を1回入れたことで、不安定なテストがあっても2回目で通れば緑になる。フレーキーを隠す方向の設定でもあるので、本来は再試行を0のままにしてトレースの取得条件だけ変えるほうが筋がいい。今は「落ちた理由を追える」ことを優先して1回にしていますが、フレーキーの発生率を1か月ぶん見てから判断し直すつもりです。
受け入れ基準を先に決めた
同じことを繰り返さないために、E2Eを足すときの受け入れ基準を先に文章にしました。曖昧な合意はまた106日を生みます。
- そのテストを起動する行が、CIのワークフローファイル内に存在すること(ローカルで動くことは根拠にしない)
- 検証対象のURLが、その変更で生成された成果物を指していること。これを、意図的に壊したビルドで1回落として確認していること
- 失敗した実行から、原因を特定できる証跡が1つ以上残ること。設定に書いてあることではなく、実際に生成されたファイルで確認すること
- 追加後、最初のPRでチェック欄に表示されることを目視すること
3つ目が今回いちばん効きました。設定に trace と書いてあるのに0件だった、というのは、書いてあることと出てくるものは別だという実例です。
そしてもう一つ、これは自分たちへの宿題として残っています。本番デプロイの deploy.yml には型チェックすら入っていません。検査はプレビュー側にしか無く、mainへのpushはビルドが通ればそのまま出ていきます。ここは順番に足していきます。
振り返って
E2Eテストを書いた5月の自分は、たぶん「書いた」で満足していました。悪気があったわけではなく、テストを書く作業とテストを運用に載せる作業が別物だと切り分けられていなかったんだと思います。
今後は、テストを追加するPRのレビュー観点に「このテストはどのジョブから、どこに向けて呼ばれますか」を必ず入れていきたい。答えられないなら、そのPRはテストを追加していない、と扱うくらいでちょうどいい気がしています。
7秒の検査を106日間動かしていなかった、という事実は、けっこう長く覚えておけそうです。