こんにちは、かつコーチです。
前回は「コンポーネントのユニットテストを書く」というテーマで、個々のコンポーネントを切り離してテストする方法を解説しました。
今回はテスト編の締めくくりとして、実際のブラウザを操作しながらアプリ全体の動きを検証するE2Eテスト(End to End Test)を扱います。
Playwrightというツールを使って、「ユーザーがログインして商品をカートに入れる」といった一連の操作を自動化する方法を見ていきましょう。
E2Eテストとは?なぜユニットテストだけでは足りないのか
E2Eテストの定義
E2Eテストとは、実際のブラウザを立ち上げてアプリを操作し、「ユーザーの視点で見て正しく動いているか」を検証するテスト手法です。
コンポーネント単体ではなく、画面遷移・API通信・状態管理まで含めたアプリ全体の流れを丸ごと検証します。
たとえば「ログインフォームに入力してボタンを押すと、ダッシュボード画面に遷移する」という一連の流れを、実際にブラウザを動かして確認するイメージです。
ユニットテストとの役割分担
ユニットテストは「1つの関数・1つのコンポーネントが正しく動くか」を高速に検証するのが得意です。
一方でユニットテストだけでは、複数のコンポーネントを組み合わせたときに初めて起きる不具合や、実際のブラウザでしか再現しない挙動までは検証できません。
私がよく現場で説明しているのは、「ユニットテストは部品の検品、E2Eテストは完成品の試運転」というイメージです。
どちらか一方だけでは不十分で、両方を組み合わせることで安心してリリースできる体制になります。
E2Eテストは実行に時間がかかるため、すべての機能を網羅するのではなく、ログイン・購入・投稿など「壊れたら致命的な導線」に絞って書くのが定石です。
Playwrightのセットアップ
インストール手順
Playwrightは、Microsoftが開発しているE2Eテストツールです。
Chromium・Firefox・WebKitの3種類のブラウザエンジンに対応しており、1つのコードで複数ブラウザのテストができるのが特徴です。
npm init playwright@latest
このコマンドを実行すると、対話形式で以下のような設定を聞かれます。
- テストコードをTypeScriptで書くか
- テストファイルを置くフォルダ名(デフォルトは
tests) - GitHub Actionsのワークフローを追加するか
- ブラウザバイナリを今すぐインストールするか
すべてデフォルトのままEnterを押していけば、最低限のセットアップは完了します。
設定ファイル(playwright.config.ts)の要点
インストールすると、プロジェクト直下にplaywright.config.tsが生成されます。
とくに押さえておきたいのは以下の3項目です。
import { defineConfig, devices } from "@playwright/test";
export default defineConfig({
testDir: "./tests",
// 開発サーバーのURLをまとめて管理する
use: {
baseURL: "http://localhost:5173",
// 失敗したテストのみスクリーンショットを残す
screenshot: "only-on-failure",
trace: "on-first-retry",
},
// 開発サーバーを自動起動してからテストを走らせる
webServer: {
command: "npm run dev",
url: "http://localhost:5173",
reuseExistingServer: !process.env.CI,
},
projects: [
{ name: "chromium", use: { ...devices["Desktop Chrome"] } },
],
});
baseURLを設定しておくと、テストコード側では/loginのような相対パスだけで画面を指定できるようになります。
webServerを設定しておけば、npx playwright testを実行するたびに開発サーバーが自動起動するので、手動でnpm run devをし忘れる事故を防げます。
基本のテストを書く
ページ遷移とクリック操作
まずは、トップページからリンクをクリックして別ページに遷移する、シンプルなテストを書いてみます。
import { test, expect } from "@playwright/test";
test("トップページからお知らせ一覧に遷移できる", async ({ page }) => {
await page.goto("/");
await page.getByRole("link", { name: "お知らせ一覧" }).click();
await expect(page).toHaveURL("/news");
await expect(page.getByRole("heading", { name: "お知らせ" })).toBeVisible();
});
page.getByRoleは、画面上の要素をHTMLのタグ名や役割(role)で探すAPIです。
CSSセレクタでDOMの構造を直接指定するより、「ユーザーが実際に見て操作する方法」に近い形で要素を探せるのが特徴で、Playwrightでは基本的にこの書き方が推奨されています。
フォーム入力とアサーション
続いて、ログインフォームに値を入力して送信する例です。
import { test, expect } from "@playwright/test";
test("正しい情報でログインするとダッシュボードに遷移する", async ({ page }) => {
await page.goto("/login");
await page.getByLabel("メールアドレス").fill("test@example.com");
await page.getByLabel("パスワード").fill("password123");
await page.getByRole("button", { name: "ログイン" }).click();
await expect(page).toHaveURL("/dashboard");
await expect(page.getByText("ようこそ")).toBeVisible();
});
fillで入力欄に値を入れ、clickでボタンを押し、最後にexpectで結果を検証する、という流れがE2Eテストの基本形です。
getByLabelを使うためには、フォーム側で<label>と<input>がhtmlForとidで正しく結びついている必要があります。
裏を返せば、Playwrightでテストを書くこと自体が、アクセシビリティの実装漏れに気づくきっかけにもなります。
つまずきやすいポイント:待機処理の書き方
かつコーチが実際にハマった「Flakyテスト」
私が最初にE2Eテストを書いたとき、ローカルでは通るのにCI環境だと時々失敗する、いわゆるFlakyテスト(不安定に失敗するテスト)に何度も悩まされました。
原因を調べてみると、APIのレスポンスを待たずに次の操作へ進んでしまい、画面がまだ更新されていないタイミングで要素を探しにいっていたことが分かりました。
❌ Before:固定時間のsleepで「待ったつもり」になる
import { test, expect } from "@playwright/test";
test("商品を検索できる(不安定な書き方)", async ({ page }) => {
await page.goto("/search");
await page.getByPlaceholder("キーワードを入力").fill("ノートPC");
await page.getByRole("button", { name: "検索" }).click();
// 1秒待てば表示されるはず、という思い込みで書いていた
await page.waitForTimeout(1000);
await expect(page.getByText("ノートPC 一覧")).toBeVisible();
});
waitForTimeoutは「決め打ちの時間だけ待つ」処理なので、通信が遅い環境では時間切れになって失敗し、速い環境では無駄な待ち時間になります。
私のチームでは、CIサーバーが混雑している日だけこのテストが落ちるという再現性の低さに、原因調査だけで半日を費やしてしまいました。
✅ After:要素が現れるまで自動的に待つ
import { test, expect } from "@playwright/test";
test("商品を検索できる(安定した書き方)", async ({ page }) => {
await page.goto("/search");
await page.getByPlaceholder("キーワードを入力").fill("ノートPC");
await page.getByRole("button", { name: "検索" }).click();
// expectが「表示されるまで」自動でリトライしながら待ってくれる
await expect(page.getByText("ノートPC 一覧")).toBeVisible();
});
Playwrightのexpectは、条件を満たすまで一定時間ポーリングしながら自動で待ち続ける仕組みを内蔵しています。
固定時間のsleepを入れるのではなく、expect自体に「その状態になるまで待つ」役割を任せる、というのがPlaywrightで安定したテストを書く一番のコツです。
応用:CI環境での実行と複数ブラウザ対応
GitHub Actionsでの自動実行
E2Eテストは、コードを変更するたびに手元で毎回実行するのは現実的ではありません。
GitHub Actionsなどに組み込み、プルリクエストを出すたびに自動実行する運用が一般的です。
- name: Install dependencies
run: npm ci
- name: Install Playwright browsers
run: npx playwright install --with-deps
- name: Run E2E tests
run: npx playwright test
--with-depsオプションを付けることで、CI環境に必要なブラウザ用の依存ライブラリも合わせてインストールしてくれます。
複数ブラウザでの検証
先ほどのplaywright.config.tsのprojectsに、Firefox・WebKitを追加すると、同じテストコードを複数ブラウザで実行できます。
projects: [
{ name: "chromium", use: { ...devices["Desktop Chrome"] } },
{ name: "firefox", use: { ...devices["Desktop Firefox"] } },
{ name: "webkit", use: { ...devices["Desktop Safari"] } },
],
Safari(WebKit)は他のブラウザと挙動が異なることが多いため、業務システムでも「主要ブラウザだけは自動テストの対象に含めておく」と、リリース後の想定外の不具合をかなり減らせます。
まとめ
この記事のポイント
- E2Eテストは実際のブラウザを操作し、アプリ全体のユーザー導線を検証する手法
- ユニットテストとE2Eテストは役割が異なり、両方を組み合わせることで安心して開発できる
getByRoleやgetByLabelなど、ユーザー視点に近いセレクタを使うのがPlaywright流waitForTimeoutではなくexpectの自動待機に任せることで、Flakyテストを防げる- CIに組み込み、複数ブラウザで自動実行することでリリース前の安心感が高まる
次に読むべき記事
テストの基礎が固まったところで、次からはいよいよ「作ったアプリを世の中に公開する」デプロイ編に進みます。
→ 次の記事:Reactアプリをvercelにデプロイする方法