ReactのアプリをPlaywrightで操作してテストする方法
React のアプリで「入力して、ボタンを押したら、一覧と件数が変わる」ことを確かめたいときは、Playwright Test で本物のブラウザを動かし、利用者と同じ操作をテストに書きます。開発サーバーの起動もテストの実行に任せられるので、npx playwright test の1つのコマンドで済みます。
筆者は検証環境で、Vite の React + TypeScript のプロジェクトに小さな「やること」の一覧の画面を作り、Playwright Test で操作のテストを書いて、成功と失敗の両方を確かめました。
検証環境は Ubuntu 24.04、Node.js 22.22.0、npm 10.9.4、Chromium 141 です。使ったのは react / react-dom 19.3.1、vite 8.3.5、typescript 6.0.3、@playwright/test 1.56.1 です。ブラウザは Chromium だけで試しました。Firefox と WebKit、CI のサービスの上での実行は試していません。
テストする画面
テストの相手は、次のような画面です。起動すると /api/todos.json から一覧を読み込みます。文字を入れて「追加」を押すと、0.8秒後に一覧の末尾へ加わり、下の「残り ○ 件」が変わります。0.8秒待つのは、保存に時間がかかる場面をまねるためです。

コンポーネントは次のとおりです。「残り ○ 件」の段落には role="status" を付け、テストから役割で探せるようにしました。
// src/TodoApp.tsx
import { useEffect, useState } from 'react';
import type { FormEvent } from 'react';
type Todo = { id: number; title: string; done: boolean };
export function TodoApp() {
const [todos, setTodos] = useState<Todo[] | null>(null);
const [title, setTitle] = useState('');
const [saving, setSaving] = useState(false);
useEffect(() => {
fetch('/api/todos.json')
.then((res) => res.json())
.then((data: Todo[]) => setTodos(data));
}, []);
async function handleSubmit(e: FormEvent<HTMLFormElement>) {
e.preventDefault();
if (!title.trim()) return;
setSaving(true);
// 保存に時間がかかる場面の代わりに、0.8秒待つ
await new Promise((resolve) => setTimeout(resolve, 800));
setTodos((prev) => [...(prev ?? []), { id: Date.now(), title: title.trim(), done: false }]);
setTitle('');
setSaving(false);
}
function toggle(id: number) {
setTodos((prev) => prev!.map((t) => (t.id === id ? { ...t, done: !t.done } : t)));
}
return (
<main>
<h1>やること</h1>
<form onSubmit={handleSubmit}>
<label>
新しいやること
<input value={title} onChange={(e) => setTitle(e.target.value)} />
</label>
<button type="submit" disabled={saving}>
{saving ? '追加中…' : '追加'}
</button>
</form>
{todos === null ? (
<p>読み込み中…</p>
) : (
<>
{todos.length === 0 && <p>やることはありません</p>}
<ul>
{todos.map((t) => (
<li key={t.id}>
<label>
<input type="checkbox" checked={t.done} onChange={() => toggle(t.id)} />
{t.title}
</label>
</li>
))}
</ul>
<p role="status">残り {todos.filter((t) => !t.done).length} 件</p>
</>
)}
</main>
);
}
App.tsx はこの TodoApp を返すだけにしました。読み込む一覧は public/api/todos.json に置いています。
[
{ "id": 1, "title": "牛乳を買う", "done": false },
{ "id": 2, "title": "本を返す", "done": true }
]
Playwright Test を入れて、開発サーバーを自動で起動する
公式のインストールの手順では npm init playwright@latest を実行し、質問に答えて設定ファイルとサンプルのテストを作ります。ブラウザもこのときに入れるのが既定です。筆者は、バージョンを固定するために、パッケージだけを入れて設定ファイルを手で書きました。
npm install -D -E @playwright/test@1.56.1
検証環境には、このバージョン用の Chromium がもともと入っていたため、ブラウザを入れる npx playwright install は実行していません。ふつうは、npm init playwright@latest の中で入れるか、npx playwright install を実行してブラウザを入れる必要があります。
設定ファイルは次のとおりです。webServer に開発サーバーを起動するコマンドと URL を書くと、テストの前に Playwright が npm run dev を実行し、その URL が応答するまで待ちます。use の baseURL を書いておくと、テストの中では page.goto('/') のように URL の後ろだけを書けます(公式の開発サーバーの設定の説明)。
// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './e2e',
reporter: [['list'], ['html', { open: 'never' }]],
use: {
baseURL: 'http://localhost:5173',
screenshot: 'only-on-failure',
},
projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }],
webServer: {
command: 'npm run dev',
url: 'http://localhost:5173',
reuseExistingServer: !process.env.CI,
},
});
reporter では、テストごとに結果を1行ずつ端末に出す list と、HTML のレポートを作る html の2つを並べました(list レポーターの説明)。HTML のレポートは、既定では失敗したときに自動で開きます。open: 'never' で、開かないようにしています(レポーターの説明)。screenshot: 'only-on-failure' は、失敗したテストの画面を撮って残す指定です(テストのオプションの一覧)。
役割とラベルで要素を探して操作する
テストは e2e/todo.spec.ts に書きました。要素は CSS のセレクターではなく、getByRole(ボタンやチェックボックスといった役割と、その名前)と getByLabel(ラベルの文字)で探しています。公式のロケーターの説明でも、利用者や支援技術からの見え方に近い、役割で探す方法を先に使うよう勧めています。
// e2e/todo.spec.ts
import { test, expect } from '@playwright/test';
test('やることを追加すると、一覧と残りの件数が増える', async ({ page }) => {
await page.goto('/');
await expect(page.getByRole('listitem')).toHaveCount(2);
await page.getByLabel('新しいやること').fill('猫の爪を切る');
await page.getByRole('button', { name: '追加' }).click();
await expect(page.getByRole('listitem')).toHaveText(['牛乳を買う', '本を返す', '猫の爪を切る']);
await expect(page.getByRole('status')).toHaveText('残り 2 件');
await expect(page.getByLabel('新しいやること')).toHaveValue('');
});
test('チェックを入れると、残りの件数が減る', async ({ page }) => {
await page.goto('/');
await page.getByRole('checkbox', { name: '牛乳を買う' }).check();
await expect(page.getByRole('status')).toHaveText('残り 0 件');
});
test('APIの返す一覧が空なら、そのことを表示する', async ({ page }) => {
await page.route('**/api/todos.json', (route) => route.fulfill({ json: [] }));
await page.goto('/');
await expect(page.getByText('やることはありません')).toBeVisible();
await expect(page.getByRole('listitem')).toHaveCount(0);
});
チェックボックスは <label> の中に入れてあるので、ラベルの文字「牛乳を買う」が名前になり、getByRole('checkbox', { name: '牛乳を買う' }) で見つかります。toHaveText に配列を渡すと、見つかった要素の数と、それぞれの文字が順に合っているかをまとめて確かめます(toHaveText の説明)。
3つ目のテストでは、page.route で /api/todos.json への通信を横取りし、route.fulfill({ json: [] }) で空の配列を返しています。本物のファイルを書き換えずに、一覧が空のときの表示を確かめられます(API の差し替えの説明)。
実行すると、webServer の設定で開発サーバーが起動し、3つのテストが通りました。CI=1 を付けて実行した理由は、後の節で説明します。
$ CI=1 npx playwright test; echo "exit=$?"
Running 3 tests using 1 worker
✓ 1 [chromium] › e2e/todo.spec.ts:3:1 › やることを追加すると、一覧と残りの件数が増える (1.3s)
✓ 2 [chromium] › e2e/todo.spec.ts:15:1 › チェックを入れると、残りの件数が減る (349ms)
✓ 3 [chromium] › e2e/todo.spec.ts:21:1 › APIの返す一覧が空なら、そのことを表示する (311ms)
3 passed (3.9s)
exit=0
テストの名前の一部で絞って1つだけ実行するときは、--grep(-g)を使います(コマンドラインの説明)。ここでは出力を短くするため、--reporter=line も付けました。
$ CI=1 npx playwright test --grep "チェック" --reporter=line; echo "exit=$?"
Running 1 test using 1 worker
[1/1] [chromium] › e2e/todo.spec.ts:15:1 › チェックを入れると、残りの件数が減る
1 passed (2.4s)
exit=0
Playwright は TypeScript のテストをそのまま実行しますが、型のチェックはしません(TypeScript の説明)。筆者は、Vite のひな形の tsconfig.node.json の include に playwright.config.ts と e2e を足し、npx tsc -p tsconfig.node.json --noEmit で型のエラーがないことを確かめました。
表示が変わるのを待つのは expect に任せる
「追加」ボタンを押してから一覧に加わるまで、この画面では0.8秒かかります。それでも最初のテストが通ったのは、await expect(locator).toHaveText(...) のような書き方が、条件を満たすまで要素を探し直して確かめ続けるからです。待つ時間は、既定で5秒です(アサーションの説明)。
比べるために、件数を count() で読んで、ふつうの値として toBe で比べるテストを書きました。
// e2e/no-wait.spec.ts
import { test, expect } from '@playwright/test';
test('件数をすぐに読むと、追加が終わる前の数になる', async ({ page }) => {
await page.goto('/');
await expect(page.getByRole('listitem')).toHaveCount(2);
await page.getByLabel('新しいやること').fill('猫の爪を切る');
await page.getByRole('button', { name: '追加' }).click();
expect(await page.getByRole('listitem').count()).toBe(3);
});
--reporter=list を指定して実行すると、このテストは失敗しました。クリックの直後に数えたため、件数はまだ追加前の2件でした。
$ CI=1 npx playwright test e2e/no-wait.spec.ts --reporter=list; echo "exit=$?"
(中略)
Error: expect(received).toBe(expected) // Object.is equality
Expected: 3
Received: 2
8 | await page.getByRole('button', { name: '追加' }).click();
9 |
> 10 | expect(await page.getByRole('listitem').count()).toBe(3);
| ^
toBe は値を一度比べるだけで、待ちません。公式の説明でも、Web の画面は表示が後から変わることが多く、待たない比べ方はテストが不安定になる原因だと書かれています(待たないアサーションの説明)。count() の説明にも、件数を確かめるなら toHaveCount を使うよう書かれています(count の説明)。
画面の表示を確かめるときは、await expect(ロケーター).toHaveText(...) や toHaveCount(...) のように、ロケーターを expect に渡します。値を先に取り出して比べると、表示が変わるのを待たずに比べてしまいます。
クリックや入力の側も、要素が見えていて、操作できる状態になるまで Playwright が待ってから操作します(自動で待つ仕組みの説明)。
テストが失敗したときにレポートで原因を見る
失敗したときの表示を確かめるため、「残り ○ 件」の計算を、チェックの有無を見ずに全部の件数を数える形に変えました(diff の先頭の 59c59 の行は省いています)。
< <p role="status">残り {todos.filter((t) => !t.done).length} 件</p>
---
> <p role="status">残り {todos.length} 件</p>
テストを実行すると、件数を確かめる2つのテストが失敗し、API を差し替えた3つ目のテストだけが通りました(3つ目は「残り ○ 件」の表示を確かめていないためです)。端末には、期待した文字と実際の文字が並びます。設定の reporter には list と html を並べましたが、前の節で --reporter=list だけを指定して実行したときは、playwright-report のフォルダが作られませんでした。そこで、ここでは --reporter=list,html と2つを指定しています。
$ CI=1 npx playwright test --reporter=list,html; echo "exit=$?"
Running 3 tests using 1 worker
✘ 1 [chromium] › e2e/todo.spec.ts:3:1 › やることを追加すると、一覧と残りの件数が増える (6.3s)
✘ 2 [chromium] › e2e/todo.spec.ts:15:1 › チェックを入れると、残りの件数が減る (5.4s)
✓ 3 [chromium] › e2e/todo.spec.ts:21:1 › APIの返す一覧が空なら、そのことを表示する (347ms)
(中略)
2) [chromium] › e2e/todo.spec.ts:15:1 › チェックを入れると、残りの件数が減る ───────────────────────────────────────
Error: expect(locator).toHaveText(expected) failed
Locator: getByRole('status')
Expected: "残り 0 件"
Received: "残り 2 件"
Timeout: 5000ms
Call log:
- Expect "toHaveText" with timeout 5000ms
- waiting for getByRole('status')
9 × locator resolved to <p role="status">残り 2 件</p>
- unexpected value "残り 2 件"
Call log には、要素は見つかったが文字が違った(unexpected value)という記録が「9 ×」とまとめて出ています。待つ間に何度も確かめ直し、最後まで文字が合わなかったということです。失敗したテストが5秒あまりかかっているのは、この待ち時間のためです。
HTML のレポートは npx playwright show-report で開けます。失敗したテストを選ぶと、端末と同じエラーが、期待した文字と実際の文字の違う部分に色を付けて表示されました。

同じページの下には、テストの手順ごとの結果と時間、失敗したときに撮った画面が並びます。チェックの操作は成功し、その後の toHaveText で5秒かかって失敗したことと、画面には「残り 2 件」と出ていたことが分かります。

開発サーバーを先に起動しておく場合
reuseExistingServer: !process.env.CI は、環境変数 CI がなければ、すでに動いている開発サーバーをそのまま使う指定です。CI があるときは、URL がすでに使われていると、エラーにして止めます(開発サーバーの設定の説明)。この記事のほかの節で CI=1 を付けていたのは、どの実行でも、テストが自分で開発サーバーを起動するようにそろえるためです。
npm run dev で開発サーバーを先に起動しておき、CI=1 を付けて実行すると、テストは始まらずに止まりました。
$ CI=1 npx playwright test --reporter=line; echo "exit=$?"
Error: http://localhost:5173 is already used, make sure that nothing is running on the port/url or set reuseExistingServer:true in config.webServer.
exit=1
CI を付けずに実行すると、動いている開発サーバーを使って3つのテストが通りました。この設定なら、手元では開発サーバーを動かしたまま何度もテストを実行し、CI では毎回起動し直す、という使い分けができます。
$ npx playwright test --reporter=line; echo "exit=$?"
Running 3 tests using 1 worker
[1/3] [chromium] › e2e/todo.spec.ts:3:1 › やることを追加すると、一覧と残りの件数が増える
[2/3] [chromium] › e2e/todo.spec.ts:15:1 › チェックを入れると、残りの件数が減る
[3/3] [chromium] › e2e/todo.spec.ts:21:1 › APIの返す一覧が空なら、そのことを表示する
3 passed (3.0s)
exit=0
React の画面を Playwright でテストするときのポイント
テストの流れは、page.goto で開き、getByRole や getByLabel で要素を探して fill や click で操作し、await expect(ロケーター) で表示を確かめる、の繰り返しです。React の state が更新されて表示が変わるまでの待ちは expect が引き受けるので、テストの中に待ち時間を書く必要はありませんでした。
開発サーバーは webServer で起動を任せ、API は page.route で差し替えられます。失敗したときは、端末の Expected と Received、HTML のレポートに残る手順ごとの結果と画面から、どの時点で何が違ったのかをたどれます。





