• #E2Eテスト
  • #Playwright
  • #CI
  • #オンボーディング
  • #eurekapu
開発eurekapu-nuxt4メモ

発端: PR #5のCIが赤い

PR #5「docs: 短期借入金の純額表示が認められる経済実態の解説を追記」を出したところ、CIのE2Eテストが失敗した。ドキュメント追記だけのPRでテストが落ちるのは不自然なので、mainブランチのCI履歴を遡った。直近3回、すべて赤だった。

PR #5の変更が壊したのではなく、既存のテストがすでに壊れていた。

原因1: トップページのセレクタが古い

症状

トップページのテストが.hero-headingを探して見つからずタイムアウトしていた。

調査

実際のHTMLを確認すると、ヒーローセクションのリファクタリングで.hero-heading.concept-headingに変わっていた。さらに.cardの数が4枚から7枚に増えている。テストコードだけが古いまま取り残されていた。

修正

// Before
await expect(page.locator('.hero-heading')).toBeVisible()
expect(await page.locator('.card').count()).toBe(4)

// After
await expect(page.locator('.concept-heading')).toBeVisible()
expect(await page.locator('.card').count()).toBe(7)

修正してテストを走らせた。トップページのテストは通ったが、/quizページのテストがまだ落ちる。

原因2: オンボーディングオーバーレイがクリックを塞ぐ

症状

/quizページに遷移した直後、テストが要素をクリックしようとして失敗していた。エラーメッセージには「element is not clickable at point」と出ている。

調査

ブラウザを手動で開いてlocalStorageを空にした状態で/quizにアクセスしたところ、QuizOnboardingコンポーネントが全画面オーバーレイを表示した。初回訪問ユーザーに操作説明を出す仕組みで、localStorageに完了フラグが立つまで毎回表示される。

E2Eテストは毎回クリーンな状態で起動するため、localStorageは常に空。テストが走るたびにオンボーディングが出てきて、その下のクイズUIへのクリックを遮っていた。

修正

テスト開始前にlocalStorageでオンボーディング完了済みフラグをセットした。

// /quizページ遷移前にオンボーディング完了済みにする
await page.evaluate(() => {
  localStorage.setItem('quiz-onboarding-completed', 'true')
})
await page.goto('/quiz')

オーバーレイが消え、クイズUIへのクリックが通るようになった。しかし「10問回答して結果画面が表示される」テストがまだ失敗する。

原因3: 回答が記録されないまま結果画面に到達する

症状

10問の回答を完了して結果画面に遷移するテストで、スコアが0/10になっていた。回答操作自体は動いているように見えるのに、answers配列が空のまま結果画面に到達していた。

調査

テストコードを読むと、選択肢のボタンをクリックした後にrevealAnswerボタン、続いてNextボタンを押す流れになっていた。この操作順だと、正解を表示する処理は走るがanswers配列への記録がスキップされるケースがあった。

UIの実装を確認すると、選択肢をクリックした時点で回答がanswersに記録される設計になっている。ただし、テストが1つ目の選択肢だけをクリックしていたため、それが正解でないときに回答が記録されないパスが存在した。

修正

選択肢を全てクリックする方式に変更した。drButtons.allInnerTexts()で選択肢テキストを取得し、全ボタンをクリックする。これで必ず正解を含む回答がanswersに記録される。

// Before: 1つ目の選択肢だけクリック
await page.locator('.dr-button').first().click()

// After: 全選択肢をクリックして確実にanswersに記録
const buttons = page.locator('.dr-button')
const count = await buttons.count()
for (let i = 0; i < count; i++) {
  await buttons.nth(i).click()
}

もう1つの罠: ログイン促進オーバーレイ

回答テストを修正して走らせると、今度は途中でログイン促進のモーダルが出現してテストが止まった。未ログイン状態で一定問数を解くとログインを促すオーバーレイが表示される仕様だ。

.btn-later(「あとで」ボタン)をクリックして閉じる処理を追加した。

// ログイン促進オーバーレイが出たら閉じる
const laterBtn = page.locator('.btn-later')
if (await laterBtn.isVisible({ timeout: 1000 })) {
  await laterBtn.click()
}

結果: 36テスト全パス

修正をすべて適用してテストを走らせた。

  • 修正前: 35テスト中、複数が失敗
  • 修正後: 36テスト(1テスト追加)全パス

CIも緑に変わり、PR #5をマージした。

振り返り

今回の問題は「PR #5の変更が原因だ」という思い込みを捨てたところから始まった。mainブランチのCI履歴を3回分遡って確認したことで、既存テストの劣化という本当の原因が見えた。

3つの原因はそれぞれ性質が違う。

  1. セレクタの古さ: UIリファクタリングとテスト更新が連動していなかった。CSSクラス名を変えたらテストも一緒に直す、という当たり前のことが抜けていた
  2. オンボーディングの干渉: 「初回ユーザーだけに表示される機能」はE2Eテストが毎回初回ユーザーになるという事実を見落としていた。localStorageに依存する機能はテストのセットアップで状態を制御する必要がある
  3. 回答記録の不整合: テストが「操作が動く」ことだけを確認していて、「データが正しく記録される」ことを検証していなかった。全選択肢クリックという力技で解決したが、根本的にはテストの粒度の問題だ

E2Eテストは書いて終わりではない。UIが変わるたびにテストも一緒に動かし続けないと、いつの間にか「常に赤いCI」ができあがる。CIが赤い状態が常態化すると、本当に壊れたときに誰も気づかなくなる。