- 技術 💻
Playwright入門|E2Eテストの書き方とCI実行まで実行結果つきで解説
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期連続の黒字決算。