ウェブエンジニア問題集
第9章

Playwrightの実践テクニック — ページ操作・待機・認証・API連携

16
この章の目次開く

前章 では、E2EテストをCIで運用し、Flaky Testを減らす考え方を整理しました。この章では、その前提になる Playwrightの実務テクニック を深掘りします。

Playwrightの導入と基本操作 ではインストール、基本的なテスト、UIモードを扱いました。ここでは一歩進んで、ロケーターの選び方、自動待機、認証状態の共有、APIモック、baseURLreuseExistingServer、ビジュアルリグレッションまでをまとめます。

学習者学習者

Playwrightは動いたけど、実務のテストになると「どのロケーターを使う?」「どこで待つ?」で迷います。

Playwrightの実践では、テストを「動かす」よりも、UI変更や通信タイミングに強い書き方へ寄せることが重要です。

ロケーター戦略の使い分け

Playwrightでは、要素取得に page.locator('css=...') だけを使うのではなく、ユーザーから見える情報を基準にしたロケーターを優先します。

優先順位

優先度ロケーター使いどころ
1getByRole()ボタン、リンク、見出し、チェックボックスなど、役割と名前で取れる要素
2getByLabel()入力欄、セレクトボックス、チェックボックスなどフォーム部品
3getByText()完了メッセージ、一覧の項目名など、非インタラクティブな表示
4getByTestId()文言やアクセシビリティ名が変わりやすいUI、アイコンボタン、複雑な部品
5locator()CSSやXPathが必要な最終手段
import { test, expect } from '@playwright/test';
 
test('商品をカートに追加できる', async ({ page }) => {
  await page.goto('/products');
 
  await page
    .getByRole('listitem')
    .filter({ hasText: 'ワイヤレスキーボード' })
    .getByRole('button', { name: 'カートに追加' })
    .click();
 
  await expect(page.getByText('カートに追加しました')).toBeVisible();
});
ts

getByRole() の構文

page.getByRole(role, options?)
ts
引数説明
rolestringbuttonlinkheadingtextbox などのアクセシビリティロール
options.namestring または RegExp画面上の名前。ボタン文字、リンク文字、ラベルなど
options.exactboolean完全一致にするか

戻り値: Locator.click().fill().filter()expect(locator) などにつなげられます。

getByLabel() の構文

page.getByLabel(text, options?)
ts
引数説明
textstring または RegExp<label>aria-label で関連付けられた名前
options.exactboolean完全一致にするか

戻り値: Locator。入力欄なら .fill()、チェックボックスなら .check() などに使えます。

getByText()getByTestId() の構文

page.getByText(text, options?)
page.getByTestId(testId)
ts
メソッド主な引数戻り値使いどころ
getByText()表示テキスト、正規表現Locator通知、見出し、一覧項目などの表示確認
getByTestId()data-testid の値Locator文言では安定して取れない部品
// アプリ側
<button data-testid="profile-menu-button" aria-label="プロフィールメニュー">
  <IconUser />
</button>
tsx
// テスト側
await page.getByTestId('profile-menu-button').click();
ts

自動待機(Auto-waiting)の仕組み

Playwrightは、多くの操作で対象要素が操作可能になるまで自動で待ちます。たとえば .click() は、要素が表示され、安定し、クリックできる状態になるまで待ってから実行されます。

// 固定時間待ちは不要
await page.getByRole('button', { name: '保存' }).click();
 
// アサーションも一定時間リトライされる
await expect(page.getByText('保存しました')).toBeVisible();
ts
waitForTimeout() で秒数を決め打ちするほど、テストは遅く不安定になります。待つ対象は「時間」ではなく「状態」にします。

Playwrightが自動で待つもの

操作自動で待つ例
locator.click()要素が表示され、安定し、有効で、クリックを受け取れる状態
locator.fill()入力可能な状態
expect(locator).toBeVisible()条件が満たされるまでのリトライ
expect(page).toHaveURL()URLが期待値になるまでのリトライ

自分で待つ必要があるもの

待ちたいもの使うAPI
特定のAPIレスポンスpage.waitForResponse()保存APIが200を返すまで待つ
URL遷移page.waitForURL() または expect(page).toHaveURL()OAuth後のリダイレクト
DOM出現だけを待つlocator.waitFor()表示以外の状態を明示したい場合

waitForSelector() も使えますが、Playwrightでは Locator ベースの locator.waitFor()expect(locator) を優先する方が、読みやすく保守しやすいです。

待機APIの構文

await page.waitForResponse(urlOrPredicate, options?)
await page.waitForURL(urlOrPredicate, options?)
await locator.waitFor(options?)
ts
API主な引数戻り値使いどころ
page.waitForResponse()URL文字列、正規表現、判定関数Promise<Response>API完了を待ってからレスポンス内容を検証
page.waitForURL()URL文字列、正規表現、判定関数Promise<void>別ページへの遷移完了を待つ
locator.waitFor(){ state, timeout }Promise<void>attachedvisiblehiddendetached などを明示
test('注文を確定できる', async ({ page }) => {
  await page.goto('/checkout');
 
  const responsePromise = page.waitForResponse((response) => {
    return response.url().includes('/api/orders') && response.status() === 201;
  });
 
  await page.getByRole('button', { name: '注文を確定' }).click();
 
  const response = await responsePromise;
  const order = await response.json();
 
  expect(order.id).toMatch(/^order_/);
  await expect(page).toHaveURL(/\/orders\/order_/);
});
ts
チェックしながら作業する女性のイラスト

認証状態の共有(storageState)

ログインが必要なテストで、毎回ログインフォームを通ると実行時間が伸びます。Playwrightでは、ログイン後のCookieやlocalStorageなどを storageState として保存し、複数のテストで再利用できます。

テストシナリオ設計 でも認証状態の共有に触れました。ここでは設定パターンをもう少し具体的に見ます。

方式1: setupプロジェクトで保存する

Playwright Testでは、認証用のセットアップテストを1つのプロジェクトとして定義し、通常のテストから依存させる方法が扱いやすいです。レポートにもセットアップ結果が残るため、ログイン失敗の原因を追いやすくなります。

// tests/auth.setup.ts
import { test as setup, expect } from '@playwright/test';
 
const authFile = 'playwright/.auth/user.json';
 
setup('認証状態を保存する', async ({ page }) => {
  await page.goto('/login');
  await page.getByLabel('メールアドレス').fill('alice@example.com');
  await page.getByLabel('パスワード').fill(process.env.E2E_PASSWORD ?? '');
  await page.getByRole('button', { name: 'ログイン' }).click();
 
  await expect(page).toHaveURL('/dashboard');
  await page.context().storageState({ path: authFile });
});
ts
// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';
 
export default defineConfig({
  projects: [
    {
      name: 'setup',
      testMatch: /.*\.setup\.ts/,
    },
    {
      name: 'chromium',
      use: {
        ...devices['Desktop Chrome'],
        storageState: 'playwright/.auth/user.json',
      },
      dependencies: ['setup'],
    },
  ],
});
ts

方式2: globalSetupで保存する

既存のテスト基盤で globalSetup を使っている場合は、Playwrightのブラウザを起動してログインし、storageState を保存できます。

// global-setup.ts
import { chromium, type FullConfig } from '@playwright/test';
 
async function globalSetup(config: FullConfig) {
  const baseURL = config.projects[0].use.baseURL ?? 'http://localhost:3000';
  const browser = await chromium.launch();
  const page = await browser.newPage();
 
  await page.goto(`${baseURL}/login`);
  await page.getByLabel('メールアドレス').fill('alice@example.com');
  await page.getByLabel('パスワード').fill(process.env.E2E_PASSWORD ?? '');
  await page.getByRole('button', { name: 'ログイン' }).click();
  await page.waitForURL('**/dashboard');
 
  await page.context().storageState({ path: 'playwright/.auth/user.json' });
  await browser.close();
}
 
export default globalSetup;
ts
// playwright.config.ts
import { defineConfig } from '@playwright/test';
 
export default defineConfig({
  globalSetup: './global-setup.ts',
  use: {
    baseURL: 'http://localhost:3000',
    storageState: 'playwright/.auth/user.json',
  },
});
ts

storageState() の構文

await page.context().storageState(options?)
ts
引数説明
options.pathstringCookieやlocalStorageを保存するJSONファイルのパス

戻り値: Promise<StorageState>path を指定した場合はファイルにも保存されます。


APIリクエストのモック・インターセプト

E2Eテストでは本物のバックエンドを通すのが基本ですが、外部APIや不安定なデータに依存するとテストが壊れやすくなります。Playwrightの page.route() を使うと、ブラウザから出るリクエストを横取りし、テスト用レスポンスに差し替えられます。

先生先生

「全部モックする」より、「外部依存や作りにくい状態だけ差し替える」と考えると実務で使いやすくなります。

page.route() の構文

await page.route(url, handler, options?)
ts
引数説明
urlstring、RegExp、判定関数横取りするリクエストURL
handlerfunctionrouterequest を受け取り、fulfill()continue()abort() などを呼ぶ関数
options.timesnumber何回だけ適用するか

戻り値: Promise<void>。ルート登録が完了したら解決します。

test('在庫切れの商品を表示できる', async ({ page }) => {
  await page.route('**/api/products/keyboard', async (route) => {
    await route.fulfill({
      status: 200,
      contentType: 'application/json',
      body: JSON.stringify({
        id: 'keyboard',
        name: 'ワイヤレスキーボード',
        stock: 0,
      }),
    });
  });
 
  await page.goto('/products/keyboard');
 
  await expect(page.getByRole('heading', { name: 'ワイヤレスキーボード' })).toBeVisible();
  await expect(page.getByText('在庫切れ')).toBeVisible();
  await expect(page.getByRole('button', { name: 'カートに追加' })).toBeDisabled();
});
ts

一部だけ上書きして本物のレスポンスを使う

本物のAPIを呼びつつ、レスポンスの一部だけをテスト用に変えることもできます。

test('キャンペーン価格を表示できる', async ({ page }) => {
  await page.route('**/api/products/*', async (route) => {
    const response = await route.fetch();
    const product = await response.json();
 
    await route.fulfill({
      response,
      json: {
        ...product,
        campaignPrice: 3980,
      },
    });
  });
 
  await page.goto('/products/keyboard');
 
  await expect(page.getByText('キャンペーン価格')).toBeVisible();
  await expect(page.getByText('3,980円')).toBeVisible();
});
ts

baseURLreuseExistingServer の設定

baseURLreuseExistingServer は、Playwrightの環境構築系クエリでよく検索される設定です。どちらも playwright.config.ts に書きますが、役割が違います。

設定場所役割
use.baseURLusepage.goto('/')expect(page).toHaveURL('/dashboard') の基準URL
webServer.commandwebServerテスト前に起動する開発サーバーのコマンド
webServer.urlwebServerサーバーが起動したか確認するURL
webServer.reuseExistingServerwebServerすでに起動済みのサーバーを再利用するか
// playwright.config.ts
import { defineConfig } from '@playwright/test';
 
export default defineConfig({
  use: {
    baseURL: 'http://localhost:3000',
    trace: 'on-first-retry',
  },
 
  webServer: {
    command: 'npm run dev',
    url: 'http://localhost:3000',
    timeout: 120 * 1000,
    reuseExistingServer: !process.env.CI,
  },
});
ts

baseURL の構文

use: {
  baseURL: 'http://localhost:3000'
}
ts
説明
http://localhost:3000ローカルのNext.js、Vite、Railsなどの開発サーバー
https://staging.example.comステージング環境でE2Eを走らせる場合

戻り値: 設定値なので戻り値はありません。テスト内では page.goto('/') のような相対URL解決に使われます。

reuseExistingServer の使い分け

webServer: {
  command: 'npm run dev',
  url: 'http://localhost:3000',
  reuseExistingServer: !process.env.CI,
}
ts
向いている場面注意点
trueローカル開発。手元で起動済みのサーバーを使って高速に回す古いコードのサーバーを使っていないか注意
falseCI。毎回クリーンに起動して再現性を高める既に同じポートで起動していると失敗する
!process.env.CIローカルでは再利用、CIでは新規起動多くのプロジェクトで扱いやすい設定
baseURL は「どこへアクセスするか」、reuseExistingServer は「既存サーバーを使うか」を決める設定です。役割を分けて理解すると迷いにくくなります。

スクリーンショットとビジュアルリグレッション

通常のE2Eテストは「ボタンを押したらURLが変わる」「メッセージが表示される」のようなふるまいを検証します。一方、レイアウト崩れや見た目の差分は通常のアサーションだけでは拾いにくいです。

Playwrightの toHaveScreenshot() を使うと、スクリーンショットを保存し、次回以降の実行で差分を検出できます。

toHaveScreenshot() の構文

await expect(page).toHaveScreenshot(name?, options?)
await expect(locator).toHaveScreenshot(name?, options?)
ts
引数説明
namestring または string[]スナップショットファイル名
options.maxDiffPixelsnumber許容する差分ピクセル数
options.animations'disabled' または 'allow'アニメーションの扱い
options.maskLocator[]動的に変わる領域をマスク

戻り値: Promise<void>。初回は期待画像を生成し、2回目以降は差分が許容範囲か検証します。

test('料金ページの主要レイアウトが崩れていない', async ({ page }) => {
  await page.goto('/pricing');
 
  await expect(page).toHaveScreenshot('pricing-page.png', {
    fullPage: true,
    animations: 'disabled',
    mask: [page.getByTestId('current-time')],
  });
});
ts

実務での注意点

注意点理由
動的な日時・広告・アバターはマスクする実装と関係ない差分で失敗するため
OSやブラウザをCIで固定するフォント描画差分が出るため
すべての画面に使わない差分レビューのコストが高くなるため
コンポーネント単位も検討するページ全体より原因を特定しやすい場合があるため
虫眼鏡で確認する女性のイラスト

よくあるハマりどころ

タイムアウトを長くするだけで解決しようとする

タイムアウトを長くすると、遅い環境で一時的に通ることはあります。しかし、API待ちなのか、URL遷移待ちなのか、要素表示待ちなのかが曖昧なままだと、Flaky Testは残ります。

// NG — 何を待っているのかわからない
test.setTimeout(60_000);
await page.waitForTimeout(10_000);
 
// OK — 保存APIと完了メッセージを待つ
const responsePromise = page.waitForResponse('**/api/profile');
await page.getByRole('button', { name: '保存' }).click();
await responsePromise;
await expect(page.getByText('保存しました')).toBeVisible();
ts

waitForResponse() をクリック後に書いて取り逃がす

通信が速いと、クリック後に waitForResponse() を登録してもレスポンスを取り逃がすことがあります。待機のPromiseは、トリガー操作の前に作ります。

// NG
await page.getByRole('button', { name: '検索' }).click();
await page.waitForResponse('**/api/search');
 
// OK
const responsePromise = page.waitForResponse('**/api/search');
await page.getByRole('button', { name: '検索' }).click();
await responsePromise;
ts

iframe内の要素を普通のlocatorで探してしまう

iframeの中は別の文書です。frameLocator() でiframeを指定してから、その中の要素を探します。

await page
  .frameLocator('iframe[title="決済フォーム"]')
  .getByLabel('カード番号')
  .fill('4242 4242 4242 4242');
ts

Shadow DOMをCSSやXPathで無理に取ろうとする

Playwrightのロケーターは、通常のShadow DOM内も扱えます。ただしXPathはShadow DOMをまたげません。まずは getByRole()getByText() で取れるかを確認します。

await page.getByRole('button', { name: '詳細を開く' }).click();
await expect(page.getByText('追加情報')).toBeVisible();
ts

getByText() でボタンを押してしまう

getByText('送信') は「送信」という文字を含む要素を探します。ボタンを押すなら、役割まで含めた getByRole('button', { name: '送信' }) の方が意図が明確です。


ちゃんと使うためのポイント

  • ロケーターは getByRolegetByLabel を優先し、CSSやXPathは最後にする
  • 固定時間の waitForTimeout() ではなく、URL、レスポンス、要素状態を待つ
  • ログイン済み状態は storageState で共有し、認証JSONはコミットしない
  • 外部APIや作りにくい状態だけ page.route() で差し替える
  • baseURLwebServer は分けて考える。ローカルは reuseExistingServer: true、CIは false が基本
  • スクリーンショット比較は重要画面に絞り、動的領域をマスクする

ここまでで、結合テストとE2Eテストの考え方、Playwrightの導入、シナリオ設計、CI運用、実務テクニックまでを一通り扱いました。必要な章に戻りながら、自分のプロジェクトに合うテスト戦略へ落とし込んでください。

参考リンク