Playwrightとは、Chromium・Firefox・WebKitのブラウザを自動で操作して、Webアプリの画面を利用者と同じ流れで確かめる「E2Eテスト」を書けるMicrosoft製のテストフレームワークです。要素が表示されるまで自動で待つ仕組みがあり、sleep を書かなくても安定したテストを書きやすいのが特長です。

この記事では、Playwright(@playwright/test 1.63.0)でログインとToDoの追加・削除を行う小さなページをテストし、実際の実行結果とエラー文を載せながら解説します。「同じ名前のボタンが2つあると何が起きるか」「値をその場で取り出して比べるとなぜ失敗するか」など、不安定なテストの原因を、実際に失敗させて確認しました。CI(GitHub Actions)での実行と、Page Objectでの整理まで扱います。

Playwrightの早見表

やりたいこと 書き方・コマンド
新しく始める npm init playwright@latest
既存のプロジェクトに入れる npm i -D @playwright/test → npx playwright install
テストを実行 npx playwright test
画面を見ながら実行 npx playwright test --ui
1つのファイル/名前で絞る npx playwright test tests/login.spec.ts / -g "ログイン"
操作を記録してコードを作る npx playwright codegen http://localhost:3000
レポートを開く npx playwright show-report
トレースを開く npx playwright show-trace trace.zip
要素を探す(推奨) page.getByRole('button', { name: 'ログイン' })
文字を確かめる await expect(locator).toHaveText('...')
表示を確かめる await expect(locator).toBeVisible()
件数を確かめる await expect(locator).toHaveCount(2)

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「Playwright入門|E2Eテストの書き方とCI実行まで実行結果つきで解説」

E2Eテストとは

E2E(End to End)テストは、ブラウザを実際に動かし、利用者と同じ操作で画面全体の動きを確かめるテストです。

テストの種類 確かめる範囲 速さ 本数の目安
単体テスト 関数・クラス1つ とても速い 多い
結合テスト 複数の部品、API、DB 速い 中くらい
E2Eテスト 画面からDBまで全部 遅い 重要な流れに絞る

E2Eテストは、ログイン、購入、申し込みなど「止まると困る流れ」に絞って書くと、実行時間と保守の手間を抑えながら効果を得られます。細かい条件の組み合わせは単体テストで確かめます。

導入

新しく始める場合

npm init playwright@latest

質問に答えると、設定ファイル(playwright.config.ts)、テストの例、必要に応じて GitHub Actions の設定が作られます。

既存のプロジェクトに入れる場合

npm i -D @playwright/test
npx playwright install          # テスト用のブラウザをダウンロード
npx playwright --version
Version 1.63.0

Playwrightのバージョンごとに対応するブラウザのバージョンが決まっています。Playwrightを更新したら npx playwright install もあわせて実行します。

検証環境のメモ:この記事の検証環境ではブラウザのダウンロードができないため、事前に用意されていたChromiumを launchOptions.executablePath で指定して実行しました。通常の環境では npx playwright install で入るブラウザを使うのが推奨です。

テスト対象のページ

検証には、次の機能を持つ小さなHTMLページを使いました。サーバーとの通信を想定して、ログインは0.8秒、タスクの追加は0.5秒遅れて画面に反映されるようにしています。

  • メールアドレスとパスワードでログインする
  • ログインに成功すると「ようこそ、〜さん」と表示され、ToDo欄が現れる
  • タスクを追加・削除すると、一覧と件数(「2件」など)が更新される

要素には <label>、role="status"、aria-label="タスク一覧"、件数の表示にだけ data-testid="count" を付けています。

設定ファイル(playwright.config.ts)

import { defineConfig, devices } from '@playwright/test';

export default defineConfig({
  testDir: './tests',
  fullyParallel: true,
  forbidOnly: !!process.env.CI,          // CIで test.only が残っていたら失敗にする
  retries: process.env.CI ? 2 : 0,       // CIでは2回までリトライ
  reporter: [['list'], ['html', { open: 'never' }]],
  use: {
    baseURL: 'http://localhost:4173',
    trace: 'on-first-retry',             // 1回目のリトライでトレースを記録
    screenshot: 'only-on-failure',       // 失敗したときだけスクリーンショット
  },
  projects: [
    { name: 'chromium', use: { ...devices['Desktop Chrome'] } },
    // { name: 'firefox', use: { ...devices['Desktop Firefox'] } },
    // { name: 'webkit',  use: { ...devices['Desktop Safari'] } },
  ],
  webServer: {
    command: 'python3 -m http.server 4173 -d site',   // テスト前にサーバーを起動
    url: 'http://localhost:4173',
    reuseExistingServer: !process.env.CI,
  },
});
項目 意味
baseURL page.goto('/') のように相対パスで書けるようになる
webServer テストの前に開発サーバーを起動し、URLが応答するまで待つ
projects テストするブラウザや端末。追加するだけで複数ブラウザで動く
retries 失敗したテストを自動でやり直す回数
trace 操作の記録(後述)を残す条件

最初のテストを書く

// tests/login.spec.ts
import { test, expect } from '@playwright/test';

test('正しい情報でログインできる', async ({ page }) => {
  await page.goto('/');
  await page.getByLabel('メールアドレス').fill('user@example.com');
  await page.getByLabel('パスワード').fill('secret');
  await page.getByRole('button', { name: 'ログイン' }).click();

  await expect(page.getByRole('status')).toHaveText('ようこそ、user@example.com さん');
  await expect(page.getByLabel('新しいタスク')).toBeVisible();
});

test('パスワードが違うとエラーが表示される', async ({ page }) => {
  await page.goto('/');
  await page.getByLabel('メールアドレス').fill('user@example.com');
  await page.getByLabel('パスワード').fill('wrong');
  await page.getByRole('button', { name: 'ログイン' }).click();

  await expect(page.getByRole('status')).toContainText('パスワードが違います');
  await expect(page.getByLabel('新しいタスク')).toBeHidden();
});

テストの形は 「開く → 操作する → 結果を確かめる」の3段階です。page はテストごとに新しく用意されるブラウザのタブです。

実行結果

後述のToDoのテストと合わせて4本を実行した結果です(サーバーのログは省略)。

Running 4 tests using 1 worker

  ✓  1 [chromium] › tests/login.spec.ts:3:5 › 正しい情報でログインできる (2.1s)
  ✓  2 [chromium] › tests/login.spec.ts:13:5 › パスワードが違うとエラーが表示される (1.7s)
  ✓  3 [chromium] › tests/todo.spec.ts:13:7 › ToDo › タスクを追加すると一覧と件数が増える (2.9s)
  ✓  4 [chromium] › tests/todo.spec.ts:22:7 › ToDo › タスクを削除できる (2.4s)

  4 passed (11.8s)

ログイン結果の表示は0.8秒遅れますが、sleep や waitForTimeout を書かなくても成功しています。これが次に説明する自動待機の効果です。

ロケーター:要素の探し方

ロケーターは「画面のどの要素を操作するか」を表すものです。Playwrightの公式ドキュメントでは、利用者から見える情報(役割・ラベル・文字)で探す方法を推奨しています。

優先度 ロケーター 例 向いている要素
1 getByRole getByRole('button', { name: 'ログイン' }) ボタン、リンク、見出し、チェックボックスなど
2 getByLabel getByLabel('メールアドレス') ラベルのある入力欄
3 getByPlaceholder getByPlaceholder('例:山田太郎') ラベルのない入力欄
4 getByText getByText('保存しました') 文字で見つける要素(ボタン以外)
5 getByAltText / getByTitle getByAltText('会社ロゴ') 画像など
6 getByTestId getByTestId('count') 役割や文字で特定しにくい要素
最後 locator('css') locator('#add') ほかの方法が使えない場合

getByRole を優先する理由は、CSSのクラス名やHTMLの構造が変わってもテストが壊れにくく、同時にアクセシビリティ(読み上げソフトなどでも使えるか)も確かめられるからです。getByRole で見つけられないボタンは、読み上げソフトでも「ボタン」として扱われていない可能性があります。

絞り込み

// 「牛乳を買う」を含む行の中の「削除」ボタン
await page.getByRole('listitem')
  .filter({ hasText: '牛乳を買う' })
  .getByRole('button', { name: '削除' })
  .click();

// 名前に完全一致させる
page.getByRole('button', { name: '追加', exact: true });

実験:同じ名前のボタンが2つあると?

タスクを2つ追加した状態で、行を絞り込まずに「削除」ボタンをクリックしてみました。

test('NG1: 同じ名前のボタンが2つある', async ({ page }) => {
  await page.getByRole('button', { name: '削除' }).click();
});
Error: locator.click: Error: strict mode violation: getByRole('button', { name: '削除' }) resolved to 2 elements:
    1) <button>削除</button> aka getByRole('listitem').filter({ hasText: '牛乳を買う 削除' }).getByRole('button')
    2) <button>削除</button> aka getByRole('listitem').filter({ hasText: '本を返す 削除' }).getByRole('button')

Playwrightは、操作の対象が複数見つかるとエラーにします(strict mode)。「たまたま1つ目をクリックした」という曖昧なテストを防ぐための仕組みです。エラー文には絞り込み方の候補まで表示されるので、それを参考に filter で行を特定すれば解決します。

自動待機とアサーション

操作の前の自動待機

click() や fill() は、実行前に要素が次の状態になるまで自動で待ちます。

確認すること 内容
表示されている 画面に見えている
安定している アニメーションで動いていない
操作を受け付ける ほかの要素に隠れていない
有効である disabled になっていない
編集できる 入力欄が読み取り専用でない(fill の場合)

自動で再試行するアサーション

await expect(locator).toHaveText(...) のようなロケーターに対するアサーションは、条件を満たすまで繰り返し確かめます(既定では最大5秒)。

アサーション 確かめること
toBeVisible() / toBeHidden() 表示されている/いない
toHaveText() 文字が一致する(配列なら複数要素を順に確認)
toContainText() 文字を含む
toHaveCount() 要素の数
toHaveValue() 入力欄の値
toBeEnabled() / toBeDisabled() 押せる/押せない
toBeChecked() チェックされている
toHaveAttribute() 属性の値
toHaveURL() ページのURL
toHaveTitle() ページのタイトル
toHaveScreenshot() 見た目が保存した画像と一致する

実験:値をその場で取り出して比べると?

タスクを追加した直後に、件数の表示を2通りの方法で確かめました。

// NG:値をその場で取り出して比べる
test('NG2: 値をその場で取り出して比べる', async ({ page }) => {
  await page.getByLabel('新しいタスク').fill('傘を持つ');
  await page.getByRole('button', { name: '追加' }).click();
  expect(await page.getByTestId('count').textContent()).toBe('3件');
});

// OK:自動で待つアサーションを使う
test('OK2: 自動で待つアサーションを使う', async ({ page }) => {
  await page.getByLabel('新しいタスク').fill('傘を持つ');
  await page.getByRole('button', { name: '追加' }).click();
  await expect(page.getByTestId('count')).toHaveText('3件');
});
  ✘  2 [chromium] › tests/bad.spec.ts:16:5 › NG2: 値をその場で取り出して比べる (3.4s)
  ✓  3 [chromium] › tests/bad.spec.ts:22:5 › OK2: 自動で待つアサーションを使う (4.3s)

    Error: expect(received).toBe(expected) // Object.is equality

    Expected: "3件"
    Received: "2件"
書き方 結果 理由
expect(await ...textContent()).toBe('3件') 失敗("2件" のまま) 取り出した瞬間の値で比べるため、画面の更新(0.5秒後)を待たない
await expect(locator).toHaveText('3件') 成功 "3件" になるまで繰り返し確かめる

値を取り出してから比べる書き方は、画面の更新を待ちません。タイミング次第で成功したり失敗したりする「不安定なテスト(flaky test)」の代表的な原因です。ロケーターを expect に直接渡す書き方にすると、Playwrightが待ってくれます。

失敗したときのエラー文

期待と違う文字が表示されている場合は、次のようなエラーになります(timeout を2秒にして実行)。

Error: expect(locator).toHaveText(expected) failed

Locator:  getByTestId('count')
Expected: "5件"
Received: "2件"
Timeout:  2000ms

Call log:
  - Expect "toHaveText" getByTestId('count') with timeout 2000ms
  - waiting for getByTestId('count')
    21 × locator resolved to <p data-testid="count">2件</p>
       - unexpected value "2件"

「21回確かめたが、ずっと "2件" だった」ことがわかります。待つ時間が足りなかったのか、そもそも値が違うのかを、このログで見分けられます。

不安定なテストを防ぐ書き方

不安定になる書き方 安定する書き方
await page.waitForTimeout(3000) await expect(locator).toBeVisible() など、条件で待つ
expect(await locator.textContent()).toBe(...) await expect(locator).toHaveText(...)
expect(await locator.isVisible()).toBe(true) await expect(locator).toBeVisible()
locator('.btn-primary >> nth=0') getByRole('button', { name: '保存' })
同じ名前の要素を絞り込まずに操作 filter({ hasText }) で行を特定する
前のテストで作ったデータを使う 各テストで必要なデータを用意する(beforeEach やAPIで作成)
外部APIの応答に依存する page.route() で応答を固定する
日付や乱数に依存する 時刻を固定する(page.clock)、値を固定する

外部APIの応答を固定する例です。

await page.route('**/api/items', (route) =>
  route.fulfill({ json: [{ id: 1, name: 'テスト商品' }] }),
);

retries は不安定さを隠すものではなく、調べるための時間を作るものと考えます。HTMLレポートでは、リトライで成功したテストが「flaky」と表示されるので、それを手がかりに原因を直します。

trace・スクリーンショットで原因を調べる

trace(トレース)

traceは、テスト中の操作・画面・ネットワーク・コンソールを時系列で記録したファイルです。CIで失敗したテストを、あとから手元で1手ずつ再生できます。

trace: 'on-first-retry' の設定で、CIと同じ条件(CI=1)で失敗するテストを実行した結果です。

[1/1] [chromium] › tests/bad.spec.ts:16:5 › NG2: 値をその場で取り出して比べる
[2/1] (retries) [chromium] › tests/bad.spec.ts:16:5 › NG2: 値をその場で取り出して比べる (retry #1)
[3/1] (retries) [chromium] › tests/bad.spec.ts:16:5 › NG2: 値をその場で取り出して比べる (retry #2)
...
    attachment #3: trace (application/zip) ────────────────────────
    test-results/bad-NG2-値をその場で取り出して比べる-chromium-retry1/trace.zip
    Usage:

        npx playwright show-trace test-results/bad-NG2-値をその場で取り出して比べる-chromium-retry1/trace.zip

1回目のリトライのときだけ trace.zip が作られ、開き方のコマンドまで表示されました。

ほかに、毎回記録する 'on' や、毎回記録して失敗したものだけ残す 'retain-on-failure'(リトライしない設定向け)もあります。

スクリーンショットと動画

screenshot: 'only-on-failure' にすると、失敗したテストの画面が test-results/ に保存されます(上の実行結果にも test-failed-1.png が添付されていました)。動画は video: 'retain-on-failure' で残せます。

Page Objectで整理する

Page Objectは、画面ごとのロケーターと操作をクラスにまとめる書き方です。画面の変更があっても、直す場所が1か所で済みます。

// pages/TodoPage.ts
import { type Page, type Locator, expect } from '@playwright/test';

export class TodoPage {
  readonly page: Page;
  readonly email: Locator;
  readonly password: Locator;
  readonly loginButton: Locator;
  readonly newTodo: Locator;
  readonly addButton: Locator;
  readonly items: Locator;

  constructor(page: Page) {
    this.page = page;
    this.email = page.getByLabel('メールアドレス');
    this.password = page.getByLabel('パスワード');
    this.loginButton = page.getByRole('button', { name: 'ログイン' });
    this.newTodo = page.getByLabel('新しいタスク');
    this.addButton = page.getByRole('button', { name: '追加' });
    this.items = page.getByRole('list', { name: 'タスク一覧' }).getByRole('listitem');
  }

  async goto() {
    await this.page.goto('/');
  }

  async login(email: string, password: string) {
    await this.email.fill(email);
    await this.password.fill(password);
    await this.loginButton.click();
  }

  async addTodo(text: string) {
    await this.newTodo.fill(text);
    await this.addButton.click();
    await expect(this.items.filter({ hasText: text })).toBeVisible();
  }
}
// tests/todo.spec.ts
import { test, expect } from '@playwright/test';
import { TodoPage } from '../pages/TodoPage';

test.describe('ToDo', () => {
  let todo: TodoPage;

  test.beforeEach(async ({ page }) => {
    todo = new TodoPage(page);
    await todo.goto();
    await todo.login('user@example.com', 'secret');
  });

  test('タスクを追加すると一覧と件数が増える', async ({ page }) => {
    await todo.addTodo('牛乳を買う');
    await todo.addTodo('本を返す');

    await expect(todo.items).toHaveCount(2);
    await expect(todo.items).toHaveText([/牛乳を買う/, /本を返す/]);
    await expect(page.getByTestId('count')).toHaveText('2件');
  });

  test('タスクを削除できる', async ({ page }) => {
    await todo.addTodo('牛乳を買う');
    await todo.items.filter({ hasText: '牛乳を買う' })
      .getByRole('button', { name: '削除' }).click();

    await expect(todo.items).toHaveCount(0);
    await expect(page.getByTestId('count')).toHaveText('0件');
  });
});

addTodo の最後で「追加した行が表示されるまで待つ」ようにしているのがポイントです。操作の完了を Page Object の中で確かめておくと、呼び出す側のテストが待ち方を気にせずに書けます。

アサーションは基本的にテスト側に書くと、テストを読んだだけで何を確かめているかがわかります。

CI(GitHub Actions)で実行する

公式ドキュメントに掲載されているワークフローです。

# .github/workflows/playwright.yml
name: Playwright Tests
on:
  push:
    branches: [ main, master ]
  pull_request:
    branches: [ main, master ]
jobs:
  test:
    timeout-minutes: 60
    runs-on: ubuntu-latest
    steps:
    - uses: actions/checkout@v6
    - uses: actions/setup-node@v6
      with:
        node-version: lts/*
    - name: Install dependencies
      run: npm ci
    - name: Install Playwright Browsers
      run: npx playwright install --with-deps
    - name: Run Playwright tests
      run: npx playwright test
    - uses: actions/upload-artifact@v4
      if: ${{ !cancelled() }}
      with:
        name: playwright-report
        path: playwright-report/
        retention-days: 30
ポイント 理由
npx playwright install --with-deps ブラウザと、動かすのに必要なOSのライブラリをまとめて入れる
if: ${{ !cancelled() }} テストが失敗しても、レポートをアップロードする
forbidOnly: !!process.env.CI test.only の消し忘れで一部しか動かない事故を防ぐ
retries: process.env.CI ? 2 : 0 CIでだけリトライし、traceを残す

テストの数が増えたら、--shard=1/4 のようにテストを分割して、複数のジョブで並列に実行できます。

よくある質問

Q. SeleniumやCypressとの違いは何ですか? A. Playwrightは、自動待機と再試行するアサーションが標準で入っており、Chromium・Firefox・WebKitを1つのAPIで扱えます。複数タブやiframe、ファイルのダウンロードなども同じ書き方で扱えます。すでに多くのテストがある場合は、無理に移行せず、新しいテストから試す方法もあります。

Q. waitForTimeout は使ってはいけませんか? A. デバッグで一時的に使うのは問題ありませんが、テストに残すと、遅い環境では足りず、速い環境では無駄に待つことになります。toBeVisible() や toHaveText() など、「何を待つか」を条件で書くのがおすすめです。

Q. TypeScriptでなくJavaScriptでも書けますか? A. 書けます。npm init playwright@latest の質問でJavaScriptを選ぶと、.js のファイルで始められます。PythonやJava、.NET向けのPlaywrightもあります。

Q. テストを書くのが大変です。 A. npx playwright codegen <URL> を使うと、ブラウザで操作した内容からテストコードを自動で作れます。できたコードは getByRole などの推奨ロケーターで出力されるので、アサーションを足して仕上げると効率的です。

まとめ

  • Playwrightは、複数のブラウザを自動操作してE2Eテストを書けるフレームワーク
  • 要素は getByRole → getByLabel → getByText → getByTestId の順で探す
  • 同じ名前の要素が複数あると strict mode のエラーになる。filter で特定する
  • 確かめるときは await expect(locator).toHaveText()。値を取り出して比べると待たない
  • waitForTimeout ではなく、条件で待つと不安定なテストを防げる
  • CIでは trace: 'on-first-retry' とレポートのアップロードで失敗の原因を追える
  • Page Object で画面の操作をまとめ、アサーションはテスト側に書く

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「Playwright入門|E2Eテストの書き方とCI実行まで実行結果つきで解説」

株式会社RJCでは、品質を支えるテストの書き方も、プロジェクトの中で仲間と学び合っています。こうした知識を共有しながら、日々技術を磨いています。

株式会社RJC ― SI事業・SES事業・AI駆動開発。RJCは一緒に成長を楽しめる会社です。

WE ARE HIRING

RJCで一緒に開発しながら、
成長を楽しみませんか?

RJCは、Web・モバイル・AIを活用した開発プロジェクトで、テックリードやPM・PMOも活躍するシステム開発会社です。会社を知る、待遇を確かめる、話を聞いてみる。気になるところ見てみてください!

  • 127日年間休日
  • 12時間平均残業時間
  • 毎日ガチャ遊びココロも大切にする福利厚生。アマギフなどの賞品ラインナップ!

ほかにも、チケットレストラン、書籍読み放題、2年ごとの慰労報奨(休暇 or 金一封)、11期連続の黒字決算。

ABOUT RJC RJCがどんな会社か知る 考え方、研修、働き方、福利厚生、社員の前職まで。RJCのことが丸わかり! RJC丸わかりページへ JOB DESCRIPTION 仕事内容・待遇を見てみる 仕事内容、給与・待遇、選考の流れ。経験者も未経験も!応募前に知りたいこと、まとめました! 募集要項を見る ENTRY エントリーする エントリーは1〜2分・履歴書不要です。まずは話を聞いてみたい、という方でも歓迎です! エントリーフォームへ

RJCで一緒に開発しながら、 成長を楽しみませんか?