catch-img

Playwrightは自動化ツールというより、観測可能性を設計する仕組みだと気づいた話

はじめに

こんにちは。DIVX 霧島ラボの西園です。

今回はE2Eテストの自動化にPlaywrightを使ってみた話です。

検証の中で、スクリーンショットが意図したタイミングと違う画面になってしまう、という状況に出会ったことがありました。調べると、Playwrightで非同期処理を扱う中で await を書き忘れると、意図したタイミングで撮影できていなかったようです。これが実際どの程度起きるものなのか自分の手で確かめてみたいと思い、自分でRailsアプリを作り、Playwrightのテストを実装してみることにしました。

この記事では、実際に手を動かして分かったことと、CIに組み込むところまで進めた話を書きます。



Playwrightとは

Playwright はE2Eテストを自動で実行するツールです。実際にブラウザを起動して、ユーザーの操作を再現してくれます。

  • ブラウザを自動操作(クリック・入力・画面遷移をコードで再現)
  • テスト結果をHTMLレポートとして出力
  • 設定次第でテスト結果のスクリーンショットや動画を自動保存(ステップ単位の記録には別途コードが必要)
  • 失敗時の状況を動画で振り返れる

「画面操作→サーバー処理→DB保存→画面表示」という一連の流れを、コードに書いた通りに毎回再現できるのが特徴です。今回はこれを使って、E2Eテストの自動化を試してみました。

なお、スクリーンショットや動画を自動保存するには、playwright.config.ts 側で以下のような設定が必要です。

export default defineConfig({
  use: {
    screenshot: 'on',
    video: 'retain-on-failure',
  },
});

screenshot: 'on' はテスト実行後に(成否にかかわらず)1枚保存、video はテスト実行中の様子を丸ごと録画する設定です。ステップ単位で状態を記録したい場合は、この設定だけでは足りず、test.step() の中で明示的に page.screenshot() を呼び出す必要があります。

詳細は公式ドキュメント(Test use options / TestOptions API)を参照してください。


うまくいかなかったこと

実際に test.step() の中で page.screenshot() を呼び出し、ステップごとの状態を記録するテストを書いてみました。すると、await を付け忘れた箇所があると、各ステップでスクリーンショットを撮っているはずなのに、出力された画像を見るとステップが違うのに同じ画面のままになる、という現象が起きました。

await を付けずに操作を書くと、次のように再現できます。

test('フォームを送信すると一覧に反映される', async ({ page }) => {
  await page.goto('/records/new');
  page.fill('input[name="record[value]"]', '130');
  await page.screenshot({ path: 'after-fill.png' });
  page.click('input[type="submit"]');
  await page.screenshot({ path: 'after-submit.png' });
  await expect(page.getByText('130')).toBeVisible();
});

page.fill()page.click()await が付いていないと、ブラウザ側の処理を待たずに次の行(次の page.screenshot())が実行されてしまいます。

症状ベースの観察では解決できない

「スクショが同じ」という症状だけを見ていた時は、原因の候補がいくつも浮かんでは消えるだけでした。要素の指定が間違っているのか、ページ自体が壊れているのか、撮影のタイミングがおかしいのか。症状は一つでも、原因候補は一つに絞れません。

バグはブラウザの内部状態や非同期処理の実行タイミングで起きているのに、自分が見ていたのは「スクショが同じ」という出力だけでした。この間にギャップがあると気づいたのが、今回の一番の収穫でした。

自分が見ていた情報を整理すると、次の4層で考えられると気づきました。

何を見るか

今回の例

出力層

目に見えた失敗

「スクショが同じ画面」

状態層

実行時の具体状態

「まだ遷移前のURLだった」「要素がDOMにまだ無かった」

ロジック層

意図と挙動のずれ

「画面遷移を待たずに次の操作へ進んでいた」

設計層

構造・仕組みの問題

「テストコード全体でawaitを徹底するルールが無かった」

出力層しか見ていない時は、原因は広く推測するしかありません。状態層まで踏み込んで考えると、page.fill()page.click()await が付いていなかったことが原因だとわかりました。

ここで重要なのは、なぜ「awaitの付け忘れ」が「同じ画面のスクショ」という症状につながるのか、という点です。Playwrightの操作はすべて非同期処理で、await を付けないとブラウザ側の処理完了を待たずに次の行が実行されてしまいます。今回は page.click() が画面遷移を待たずに次の処理へ進んでしまい、スクリーンショットが「遷移前の画面」のまま撮られていました。テストコードは次の操作に進んでいるつもりでも、ブラウザ側はまだ前の画面のままだった、というズレが起きていたわけです。

ロジック層・設計層まで掘り下げて考えると、その場しのぎの修正ではなく、テストコード全体のルールとして直す方が再発防止になる、ということが見えてきました。


原因を踏まえてテストを書き直し、CIに組み込んでみた

わかった原因を踏まえて、次の3つを徹底してテストコードを書き直しました。

  • 操作の前後に必ず await を書く
  • waitForURL で画面遷移の完了を待ってから検証する
  • test.step() で操作をステップごとに構造化する

実際に書いたテストコードは、次のような形です。

test('フォームを送信すると一覧に反映される', async ({ page }) => {
  await test.step('入力ページにアクセスする', async () => {
    await page.goto('/records/new');
  });

  await test.step('フォームに入力する', async () => {
    await page.fill('input[name="record[value]"]', '130');
  });

  await test.step('送信するボタンをクリックする', async () => {
    await page.click('input[type="submit"]');
    await page.waitForURL(/\/records\/\d+/);
    await page.screenshot({ path: 'after-submit.png' });
  });

  await test.step('登録した内容が表示されることを確認する', async () => {
    await expect(page.getByText('130')).toBeVisible();
  });
});

この3つを徹底したことで、「スクショが同じ画面になる」事象は発生せず、正しいタイミングでスクリーンショットが撮れるようになりました。登録ボタンをクリックした直後、同じステップ・同じタイミングで撮影したスクリーンショットを比較すると、その違いがよくわかります。

OK: awaitを正しく書いた場合

NG: awaitを意図的に外した場合(バグ再現)

OK側は登録完了後の画面が表示されているのに対し、NG側は画面遷移が終わる前にスクショが撮られ、まだ入力フォームのままになっています。どちらもテスト自体は「成功」判定であり、awaitの有無によってズレるのはスクリーンショットの中身だけです。

今回のNG再現で確認できたのは、あくまで「送信直後の1枚が遷移前の状態で記録される」という部分です。全ステップが完全に同じ画面になるケースまでは再現していませんが、awaitの付け忘れが「意図したタイミングとは違う状態を記録してしまう」という核の部分は、この再現で確認できています。

ただ、ローカルで書いただけでは「自分が気をつける」というルールで終わってしまいます。それでは再発防止になりません。そこで、GitHub Actionsに組み込み、push のたびに自動でテストが実行される仕組みにしました。

name: E2E Tests
on:
  pull_request:
  push:
    branches: [ main ]

jobs:
  e2e:
    runs-on: ubuntu-latest
    env:
      RAILS_ENV: test
    steps:
      - uses: actions/checkout@v4
      - uses: ruby/setup-ruby@v1
        with:
          ruby-version: '.ruby-version'
          bundler-cache: true
      - uses: actions/setup-node@v4
        with:
          node-version: lts/*
          cache: npm
      - run: npm ci
      - run: npx playwright install --with-deps chromium
      - run: |
          bin/rails db:create
          bin/rails db:migrate
      - name: Start Rails server
        run: bin/rails server -p 3000 -d
      - name: Wait for Rails to be ready
        run: |
          for i in $(seq 1 30); do
            curl -s http://localhost:3000 > /dev/null && break
            sleep 1
          done
      - run: npx playwright test
      - uses: actions/upload-artifact@v4
        if: always()
        with:
          name: playwright-report
          path: |
            playwright-report/
            test-results/

サーバー起動をバックグラウンド(-d)で行っている都合上、起動が完了する前にテストが走ってしまうとCI上でだけ不安定に失敗することがあります。今回は curl でヘルスチェックしながら起動完了を待つステップを挟み、ローカルの成功・失敗とCIの結果がずれないようにしました。

CIに組み込んだことで、次の状態になりました。

  • 個人の「気をつける」ではなく、仕組みとして await 漏れやタイミングのズレを検知できる
  • 失敗時は playwright-report がArtifactとして残るため、出力層だけでなく状態も後から確認できる
  • push のたびに実行されるので、修正が再発防止として機能しているかを継続的に確認できる

チームへの共有とこれから試したいこと

今回の「await徹底」「waitForURLで待つ」「test.stepで構造化する」というルールは、 個人のメモで終わらせず、スライドにまとめてチームに共有しました。ルールとして 徹底するというより、この知識を持っている人と持っていない人の差をなくしたい、 というのが正直なモチベーションです。案件によって定着度合いは変わってくるだろうと 思いつつ、まずは共有できたこと自体を一歩目にしたいです。

CIに組み込んだ後も、さらに次の2つを試したいと考えています。

1. 症状だけで直さず、最低1段上の情報を残す

テストが落ちた時、失敗メッセージだけを見て直すのではなく、「どの状態で失敗したか」をログやレポートに残す習慣をつけたいです。

2. レポートの粒度を先に固定する

test.step() の粒度を、テストを書く前に決めておきます。「1操作=1ステップ」を最初に決めておくだけで、後からレポートを見返した時に追いやすくなります。

「Playwrightを使いこなす」の中身

Playwrightを使いこなすと言うと、APIの使い方やセレクタの書き方を知っていることのように聞こえる時があります。でも実際には逆で、必要なのはツールの知識というより、いつ・何を観測できる状態にしておくかを設計する力だと思います。

どのタイミングの画面を見ているのか。何を期待していたのか。どの前提が崩れているのか。どこまでが症状で、どこからが状態なのか。

この整理ができるほど、テストは書きやすく・読みやすくなります。逆にここが曖昧だと、どれだけ便利なツールを使っても、テストは同じ失敗を繰り返しやすいと思います。

おわりに

今回この記事を通して、Playwrightでのつまずきを、単なる「ハマった話」で終わらせず、 原因を層で捉え直し、CIという仕組みにまで落とし込むことができました。

今後も、こうした検証と仕組み化を積み重ねて、チーム全体の開発効率と品質向上に つなげていきたいです。