PlaywrightのE2Eテスト高速化 ─ 登録UIを省くAPI fixtureとpage.clock時計制御の実践

当ページのリンクには広告が含まれています。
この記事の結論
E2Eテストの肥大化を防ぐには、テスト目的ではない前準備のUI操作をAPI経由のfixtureでバイパスし、タイムアウト検証はsleepせずブラウザの時計制御(page.clock)で進める設計が極めて有効でした。本物の画面を動かすべき検証と、省略すべき手間の境界を見極めることで、CIの待ち時間を大幅に削減できます。

お疲れ様です!IT業界で働くアライグマです!

個人開発している技術ニュースキュレーションサービス「DevPick」では、ユーザー操作や画面遷移の信頼性を担保するために、Playwrightを使ったE2Eテストを継続的に整備しています。

機能が増えるにつれてテストケースも自然と増えていくのですが、ある日気がつくとCIの実行時間がじわじわと延び、開発サイクルにブレーキをかけ始めていました。原因を調べてみると、個々のテストの重さというよりは、「どのテストでも毎回繰り返される前準備のUI操作」と「タイムアウトを検証するための実時間待機」が全体の足を引っ張っていたのです。

今回は、Playwrightのカスタムfixtureと時計制御(page.clock)を活用して、E2Eテストの実行時間を一気に短縮した実践知をまとめます。

目次

E2Eテストが増えるほど「前準備のUI操作」がCIのボトルネックになる

DevPickのE2Eテストでは、記事カードのいいねや興味なし、詳細モーダルの表示、トピックのフォローなど、ほぼすべてのテストで「認証済みユーザーであること」が前提になっていました。初回訪問時に発行される認証Cookie(devpick_token)がなければパーソナライズ機能が動かないため、各テストの開始時にユーザーを準備する必要があったのです。

当時のテスト準備では、ブラウザ上で実際に登録フローをなぞる共通ヘルパーを毎回呼び出していました。

// これまでのテスト準備(UI操作によるプロフィールの作成)
test("記事をいいねしたとき、既読状態と件数が正しく更新される", async ({ page }) => {
  await page.goto("/");
  // ヘッダーからシートを開き、名前を入力し、セレクトを選んで「登録する」を押す
  await createProfile(page);

  // ここからが本来検証したいテスト本体
  const card = articleCards(page).first();
  await card.getByRole("button", { name: "いいね" }).click();
  // ...
});

この createProfile ヘルパーは、ヘッダーの「プロフィール設定」ボタンをクリックして設定シート(ダイアログ)を開き、名前入力フィールドに文字を打ち込み、セレクトボックスをクリックして選択肢を選び、「登録する」ボタンを押してシートのアニメーションが完了するのを待つという一連のDOM操作を実行します。

最初にE2Eテストを書き始めた頃は、「ユーザーの操作を忠実にシミュレートするのがE2Eテストの基本だから」と考え、この方法に何の疑問も抱いていませんでした。「画面から登録して、画面からボタンを押す。これこそが最高のエンドツーエンドテストだ」と信じ込んでいたのです。単体のテストをローカルマシンで走らせている間は、わずか2〜3秒の処理に過ぎず、特に気になることもありませんでした。

しかし、記事詳細、リアクション、カテゴリ切り替え、共有、マイページなど、機能追加に合わせてテストケースが20件、30件と増えていくと状況が一変します。

それぞれのテストが検証したい本質は「いいねを押したときの楽観的更新とDB反映」や「共有URLに正しいパラメータが付与されているか」であり、登録ダイアログのフォームバリデーションではありません。それにもかかわらず、すべてのテストが毎回愚直にブラウザのUIを操作し、ダイアログの開閉アニメーションやDOMの描画完了を律儀に待っていたのです。

ヘッドレスブラウザといえども、ダイアログのフェードイン・フェードアウトのアニメーションフレームや、Reactの状態更新、セレクトボックスのクリック判定待機などは省略されず、1回あたり確実に2〜3秒を消費します。

30テスト走ればそれだけで1分半近くが無駄になります。さらにGitHub ActionsなどのCI環境では、2シャードで並列分散していても、片方のシャードにテストが偏ると全体の終了時間が引きずられてしまいます。開発者がプルリクエストを出してからCIの結果が返ってくるまでのフィードバックループが確実に悪化していました。

結果として、CI全体の実行時間のうち、かなりの割合が「本質的ではない前準備のUI操作待ち」に奪われていたのです。具体的には、これまでE2Eスイート全体を完走させるのに4分半近くかかっていましたが、そのうち各テストの前準備だけで合計90秒以上が費やされていました。この90秒は本来検証したい機能とは無関係な時間であり、ここを削ることが最優先の改善ターゲットとなりました。

IT女子 アラ美
どのテストも毎回律儀にフォーム入力から始めてたら、そりゃ時間かかるわね。

ITアライグマ
塵も積もれば山となる状態で、CI全体の待ち時間が確実に膨らんでいました!

検証対象外のUIは叩かない ─ 実APIでCookieを投入するauthedPage fixture

改善にあたり、画面操作をバイパスしてバックエンドの実APIを直接呼び出し、ブラウザコンテキストにCookieを注入する方針へ切り替えました。検証したい対象が「いいね」や「共有」であるなら、登録画面のアニメーションやDOM描画を律儀に待つ必然性はないからです。

ここで「ブラウザのCookieに直接ダミートークンを書き込めば済むのでは?」という疑問も湧きます。しかし、モックで適当なCookieを作る方法には大きなリスクがあります。認証トークンの形式やハッシュ化ロジック、あるいはバックエンド側のユーザーテーブル構造が変更された際に、テスト側が追従できずに「本番では動かないのにE2Eテストはパスしてしまう」という偽陽性の温床になるからです。

そこで、データ作成自体は本番と同一の実APIを通過させつつ、ブラウザのUI操作だけをスキップするという設計を取りました。

まず、Playwrightの page.request.post を使ってユーザー作成APIを直接呼び出すヘルパーを用意します。

// 実APIを直接呼び出し、ブラウザコンテキストへ認証Cookieをセットする
export async function createProfileViaApi(
  page: Page,
  options: CreateProfileApiOptions = {},
): Promise {
  const name = options.name ?? "E2Eユーザー";
  const proficiency = options.proficiency ?? "advanced";

  // バックエンドの実エンドポイントへPOSTリクエストを送信
  const res = await page.request.post("/api/users/", {
    data: {
      name,
      proficiency,
      interests: options.interests ?? null,
    },
  });

  if (!res.ok()) {
    const body = await res.text();
    throw new Error(`実APIでのプロフィール作成に失敗しました: ${res.status()} ${body}`);
  }

  // レスポンスに含まれるSet-Cookieにより、ブラウザコンテキストに自動で認証Cookieが入る
  return (await res.json()) as UserProfileApiResult;
}

page.request からリクエストを送ると、バックエンドが返した Set-Cookie ヘッダーがそのままPlaywrightのブラウザコンテキストに自動反映されます。これにより、手動でCookieオブジェクトを組み立てる必要もなく、本番と寸分違わぬ認証状態が作られます。

Playwrightのtest.extendでauthedPage fixtureを自動注入する

さらに、これを個別のテストファイルで都度呼び出す手間を省くため、Playwrightの base.extend を使ってカスタムfixture(authedPage)を定義しました。

// Playwrightのtestを拡張してカスタムfixtureを定義
export const test = base.extend<{
  resetState: void;
  authedUser: UserProfileApiResult;
  authedPage: Page;
}>({
  // 各テスト実行前にサーバー状態を初期化(auto: trueで全テストで自動実行)
  resetState: [
    async ({}, use) => {
      await resetServerState();
      await use();
    },
    { auto: true },
  ],
  // API経由でユーザーを作成
  authedUser: async ({ page }, provide) => {
    const user = await createProfileViaApi(page);
    await provide(user);
  },
  // 認証済みCookieを持ったpageをテストに渡す
  authedPage: async ({ page, authedUser }, provide) => {
    void authedUser;
    await provide(page);
  },
});

前準備が数十ミリ秒で終わり、テストの意図も明確になる

このカスタムfixtureを導入したことで、テストコード側は劇的にシンプルになりました。各テストケースの引数を { authedPage: page } に変えるだけで、事前準備が完了したブラウザページが渡されます。

// authedPageを使うことで、前準備のUI操作を完全に排除
test("共有UIがある環境では、タイトルとURLを渡す", async ({ authedPage: page }) => {
  await stubNativeShare(page, "resolve");
  await page.goto("/");
  // await createProfile(page); ← これが一切不要に!

  const card = articleCards(page).first();
  // テスト対象の機能だけをすぐに検証できる
});

ダイアログの開閉やDOMアニメーションの待機が一切発生せず、HTTPリクエスト1往復でログイン状態が整うため、前準備にかかる時間は1テストあたり数十ミリ秒まで短縮されました。30件以上のテストケースで前準備にかかる時間が平均3秒から約0.05秒へと激減し、テストコード自体も「前準備のノイズ」が消え去り、「何のためのテストなのか」が一目で分かる可読性を手に入れることができました。

ただし運用の注意点として、ユーザー登録フロー自体のUIテスト(入力値バリデーションや異常系エラー表示など)は、別途専用のテストファイルで実ブラウザから画面を叩いて網羅し続ける必要があります。「どのテストで本物のUIを叩き、どのテストでバイパスするか」の線引きを明確にすることが、テストの品質と速度を両立させる最大の鍵でした。

IT女子 アラ美
モックじゃなくて本物のAPIを叩いているから、テストの信頼性も落ちないのね。

ITアライグマ
データは本物を通しつつUIの手間だけを省くのが、一番バランスの良い選択でした!

30秒のタイムアウト検証をsleepせず一瞬で終わらせる ─ page.clockの時計制御

前準備のUIバイパスによって多くのテストが高速化した後も、CIの実行時間を大きく引き延ばしていたのが「通信がタイムアウトした際の復帰テスト」でした。サーバーが無応答のまま固まった際に画面を永久ローディングにしないよう、読み取り15秒・更新30秒で自動Abortして再試行ボタンを出す設計にしています。

以前書いた 保存エラーの「再試行」が保存の取り消しになっていた話 でも触れたように、通信エラー時のロールバックや再試行といった復帰処理は、単体テストのモックだけでなく実ブラウザ上で正しく動いてこそ意味があります。

この挙動を検証するため、以前のE2Eテストでは page.route を使ってレスポンスを故意に返さない「保留状態」を作り出し、ブラウザ上で実際に15秒や30秒の経過を待っていました。

// 以前の実装: タイムアウト時間をブラウザ上で実際に待っていた
test("読み込み中から復帰し、再試行で読み直せる", async ({ page }) => {
  // 15秒の経過を待つため、テスト自体のタイムアウトを延長
  test.setTimeout(120_000);
  await page.goto("/");
  await createProfile(page);

  // 1回目だけレスポンスを返さずに保留する
  await page.route("**/api/news/read/dashboard", async () => {
    await new Promise(() => {}); // 応答を永久に返さない
  });

  await page.goto("/dashboard");

  // 15秒経過してエラー表示が出るのを、実際に15秒待って検証する
  const main = page.getByRole("main");
  await expect(main.getByRole("alert")).toContainText("待ち時間の上限", { timeout: 30_000 });
});

この方法の致命的な問題は、テストを実行するたびに、本当に15秒や30秒の「何もしない時間」が発生してしまうことです。

読み取りの15秒待ちと更新の30秒待ちの2ケースを実行するだけで、CIは最低でも45秒間完全に足止めされます。しかもネットワーク遅延やCIマシンの負荷によるブレを考慮して test.setTimeout(120_000) のように長いタイムアウトを設定せざるを得ず、CIの実行時間を圧迫する大きな要因になっていました。

page.clockとfastForwardでブラウザ内の時計を早送りする

そこで導入したのが、Playwrightの時計制御機能(page.clock)です。

page.clock を使うと、ブラウザ内部の setTimeout や setInterval、Date の進行をテスト側から意図通りに仮想的に早送りできます。

ただし、単に page.clock.fastForward() を呼ぶだけでは罠があります。ブラウザがHTTPリクエストを送信し終える前に時間を進めてしまうと、リクエスト自体が届かないままタイムアウト判定になってしまうレースコンディションが発生するからです。

そこで、「リクエストが実際に送信開始されたこと」をPromiseで待機してから時計を進めるという同期制御を挟みました。

// page.clockを使い、時間を早送りして検証する
test("読み込み中から復帰し、再試行で読み直せる", async ({ authedPage: page }) => {
  // 時計の制御を開始(普段は実時間で動かし、要求開始を待ってから早送り)
  await page.clock.install();
  await page.clock.resume();

  let notifyRequestStarted: () => void = () => {};
  const requestStarted = new Promise((resolve) => {
    notifyRequestStarted = resolve;
  });

  await page.route("**/api/news/read/dashboard", async (route) => {
    // リクエストが開始されたことを通知し、応答は保留
    notifyRequestStarted();
    await new Promise(() => {});
  });

  await page.goto("/dashboard");
  // リクエストがブラウザから発射されたことを確実に待つ
  await requestStarted;

  const main = page.getByRole("main");

  // 1. 期限(15秒)の直前(14.9秒)まで早送り
  // まだエラーは出ず、読み込み中(処理継続)であることを確認
  await page.clock.fastForward(14_900);
  await expect(main.getByRole("alert")).toHaveCount(0);

  // 2. さらに200ms進めて期限(15.1秒)を経過させる
  // Abortが走り、即座にエラー案内が表示される
  await page.clock.fastForward(200);
  await expect(main.getByRole("alert")).toContainText("待ち時間の上限");
});

14.9秒と15.1秒の境界値をミリ秒単位で正確に検証できるメリット

この制御によって、テストの実行体験が劇的に変わりました。

15秒や30秒の実時間を待機する必要が一切なくなり、テスト全体の処理はわずか数百ミリ秒で完了します。

さらに大きな収穫は、「タイムアウト直前の境界値」を正確に検証できるようになったことです。実時間待機では「14.9秒の時点ではまだローディング中であり、15.1秒で初めてエラーになる」といったミリ秒単位の挙動確認は不可能でした。fastForward を活用することで、フライングでエラーが出ていないか、期限経過後に確実にAbortされるかを決定論的(再現率100%)にテストできるようになりました。

この結果、これまでテスト全体で最低でも45秒以上かかっていた実時間待ちが完全にゼロ秒になりました。テストシャード間の所要時間(durations)も均等に平準化され、CI全体の所要時間は約128秒まで短縮されました。タイムアウト待ち起因のflakyテスト(偶発的なCI失敗)も一掃され、安心してプルリクエストをマージできる環境が整いました。

IT女子 アラ美
実時間を待たずに、境界線のギリギリ手前まで時間を飛ばせるのは便利ね。

ITアライグマ
無駄な待機時間が消えただけでなく、テストの精度自体も大幅に向上しました!

まとめ

E2Eテストの最大の強みは、「本物のブラウザ上で、ユーザーと全く同じ操作感を忠実に再現できること」にあります。しかし、すべての工程を愚直にブラウザUIで動かしてしまうと、テストケースが増えるにつれてCIの実行時間が肥大化し、開発サイクルのスピードを奪ってしまいます。

今回の改善を通じて実感したのは、「そのテストが本当に確かめたい関心事」に合わせて、検証の境界線を意識的に引き分ける重要性です。

  • 登録画面自体の検証:実ブラウザでフォーム入力やアクセシビリティを最後まで厳密にテストする
  • 認証を前提とする機能の検証:実APIを直接叩く authedPage fixture を使い、Cookie注入でUI待ちをバイパスする
  • 通信タイムアウトの検証:実時間を待たず、page.clock で決定論的に時間を進めて境界値を確認する

以前 どうせレビューで落ちるPRにテストもE2Eも全部流していた話 でCIジョブの実行順序を最適化しましたが、ジョブ内の各テストに潜む「無駄な待機時間」を削ぎ落とすことも同様に絶大な効果がありました。

結果として、DevPickのE2Eテストでは各シャードの所要時間が平準化され、CI全体の待ち時間を大幅に削減できました。

E2Eテストの実行時間が延びて困っている方は、「前準備のUI操作をAPI経由に置き換えられないか」「sleep待機を時計制御に置き換えられないか」をぜひ点検してみてください。

IT女子 アラ美
全部画面から動かすのが正義じゃないのね。メリハリが大事ってことか。

ITアライグマ
本物の検証とスマートな省略を使い分けることで、CIを高速かつ安定して回せます!

運営サービス・姉妹メディア

この記事をシェアする
  • URLをコピーしました!
  • URLをコピーしました!

この記事を書いた人

ITアライグマのアバター ITアライグマ ITエンジニア / PM

都内で働くPM兼Webエンジニア(既婚・子持ち)です。
AIで作業時間を削って実務をラクにしつつ、市場価値を高めて「高年収・自由な働き方」を手に入れるキャリア戦略を発信しています。

目次