はじめに
前回、Hono + TypeScriptでTodo APIを作った。今回はそのAPIに簡単な画面を追加し、Playwrightでブラウザ操作を自動化したE2E(End-to-End)テストを書く。
E2Eテストは「ユーザーが実際に使う操作」をそのまま自動化してテストする手法。ボタンをクリックする、フォームに入力する、画面に表示された文字を確認する、といった一連の流れをコードで再現する。
この記事では:
- Hono側にTodo画面(HTML)を追加する
- Playwrightのセットアップ
- 「追加→表示確認→完了操作」のE2Eテストを書く
1. Hono側に画面を追加する
前回のAPIサーバーに、シンプルなHTML画面を追加する。フロントエンドフレームワークは使わず、素のHTML + JSで最小限に済ませる。
src/index.ts に以下のルートを追加する。
app.get("/app", (c) => {
return c.html(`
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<title>Todo App</title>
</head>
<body>
<h1>Todo App</h1>
<form id="todo-form">
<input type="text" id="title-input" placeholder="新しいTodo" required>
<button type="submit">追加</button>
</form>
<ul id="todo-list"></ul>
<script>
const form = document.getElementById("todo-form");
const input = document.getElementById("title-input");
const list = document.getElementById("todo-list");
async function fetchTodos() {
const res = await fetch("/todos");
const todos = await res.json();
list.innerHTML = "";
for (const todo of todos) {
const li = document.createElement("li");
li.textContent = todo.title;
li.dataset.testid = "todo-item";
if (todo.done) {
li.style.textDecoration = "line-through";
} else {
const doneButton = document.createElement("button");
doneButton.textContent = "完了";
doneButton.dataset.testid = "done-button";
doneButton.addEventListener("click", async () => {
await fetch(\`/todos/\${todo.id}/done\`, { method: "PUT" });
fetchTodos();
});
li.appendChild(doneButton);
}
list.appendChild(li);
}
}
form.addEventListener("submit", async (e) => {
e.preventDefault();
await fetch("/todos", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ title: input.value }),
});
input.value = "";
fetchTodos();
});
fetchTodos();
</script>
</body>
</html>
`);
});
data-testid 属性をつけておくと、後述のPlaywrightのテストコードから要素を安定して見つけられる。CSSクラス名やテキストで要素を探すとデザイン変更のたびにテストが壊れやすいが、data-testid はテスト専用の目印なので影響を受けにくい。
動作確認:
npx tsx src/index.ts
ブラウザで http://localhost:3000/app を開いて、Todoの追加・完了操作ができることを確認する。
2. Playwrightのセットアップ
npm init playwright@latest
対話式のセットアップが始まる。
✔ Do you want to use TypeScript or JavaScript? · TypeScript
✔ Where to put your end-to-end tests? · tests
✔ Add a GitHub Actions workflow? (y/N) · N
✔ Install Playwright browsers? (Y/n) · Y
TypeScript・tests ディレクトリ・ブラウザインストールは Y を選ぶ。
インストール後の構成:
todo-hono/
├── tests/
│ └── example.spec.ts # サンプルテスト(後で消す)
├── playwright.config.ts
└── ...
サンプルテストは消しておく。
rm tests/example.spec.ts
開発サーバーと連携する設定
playwright.config.ts に、テスト実行時に自動でHonoサーバーを起動する設定を追加する。
import { defineConfig } from "@playwright/test";
export default defineConfig({
testDir: "./tests",
use: {
baseURL: "http://localhost:3000",
},
webServer: {
command: "npx cross-env NODE_ENV=test tsx src/index.ts",
url: "http://localhost:3000/app",
reuseExistingServer: !process.env.CI,
},
});
webServer を設定しておくと、npx playwright test を実行したときに自動でサーバーが起動し、テスト終了後に自動で停止する。手動でサーバーを起動しておく必要がなくなる。
url にはサーバーが実際に200を返すパスを指定する。前回作ったHono APIには / へのルートを用意していないため、url: "http://localhost:3000" のままだと404が返り続けてPlaywrightが起動を検知できずタイムアウトする。テストで実際に使う /app を指定するのが確実。
NODE_ENV=test は後述のテスト用リセットエンドポイントを有効にするために設定する。Windows環境では NODE_ENV=test tsx ... のような環境変数の指定方法がそのままでは動作しないため、クロスプラットフォーム対応の cross-env を使う。
npm install cross-env --save-dev
3. E2Eテストを書く
tests/todo.spec.ts を作る。
Todoを追加できることを確認する
import { test, expect } from "@playwright/test";
test("Todoを追加すると一覧に表示される", async ({ page }) => {
await page.goto("/app");
await page.getByPlaceholder("新しいTodo").fill("買い物");
await page.getByRole("button", { name: "追加" }).click();
await expect(page.getByTestId("todo-item")).toContainText("買い物");
});
-
page.goto("/app")—baseURLの設定によりhttp://localhost:3000/appにアクセスする -
page.getByPlaceholder(...)— placeholder属性で入力欄を見つける -
page.getByRole("button", { name: "追加" })— ボタンのアクセシビリティ上のロールとテキストで見つける -
page.getByTestId(...)— 先ほどHTMLに仕込んだdata-testidで要素を見つける
getByRole や getByPlaceholder のような「ユーザーが実際に見る情報」で要素を探す方法が、Playwrightでは推奨されている。CSSセレクタで直接DOM構造を指定するより、画面の見た目の変更に強い。
完了操作を確認する
test("Todoを完了にすると取り消し線がつく", async ({ page }) => {
await page.goto("/app");
await page.getByPlaceholder("新しいTodo").fill("掃除");
await page.getByRole("button", { name: "追加" }).click();
await page.getByTestId("done-button").click();
const todoItem = page.getByTestId("todo-item").filter({ hasText: "掃除" });
await expect(todoItem).toHaveCSS("text-decoration-line", "line-through");
});
toHaveCSS でスタイルの変化(取り消し線がついたこと)を検証する。完了ボタンをクリックした後、そのボタン自体が消えて取り消し線がつく実装なので、テストとしても仕様通りの挙動を確認できている。
複数のTodoが正しく表示されることを確認する
test("複数のTodoを追加すると全部表示される", async ({ page }) => {
await page.goto("/app");
const titles = ["買い物", "掃除", "洗濯"];
for (const title of titles) {
await page.getByPlaceholder("新しいTodo").fill(title);
await page.getByRole("button", { name: "追加" }).click();
await expect(page.getByTestId("todo-item").filter({ hasText: title })).toBeVisible();
}
const items = page.getByTestId("todo-item");
await expect(items).toHaveCount(titles.length);
});
toHaveCount で要素の個数を検証できる。ループで複数回操作するのもE2Eテストらしい書き方。
ループの中で toBeVisible() を挟まずに次の操作へ進むと、フォーム送信からDOM更新(fetchTodos() による再取得・再描画)までのタイミングが間に合わず、期待した件数より少ない状態でテストが進んでしまうことがある。「操作した結果が画面に反映されるまで待つ」というステップを都度挟むのがE2Eテストの定石。
4. テストを実行する
npx playwright test
Running 3 tests using 1 worker
✓ tests/todo.spec.ts:4:1 › Todoを追加すると一覧に表示される (1.2s)
✓ tests/todo.spec.ts:12:1 › Todoを完了にすると取り消し線がつく (1.4s)
✓ tests/todo.spec.ts:24:1 › 複数のTodoを追加すると全部表示される (1.8s)
3 passed (4.5s)
ブラウザを表示しながら実行する(デバッグ用)
デフォルトはヘッドレス(画面非表示)で実行されるが、実際にブラウザが動く様子を見たい場合は --headed オプションをつける。
npx playwright test --headed
UIモードで対話的にデバッグする
Playwright独自のUIモードを使うと、各ステップのスクリーンショットやDOMの状態を確認しながらデバッグできる。
npx playwright test --ui
テストが失敗した箇所で「なぜ失敗したか」を視覚的に確認できるので、CI上で落ちたテストをローカルで調査する際に重宝する。
5. テスト間のデータ干渉に注意する
上記のテストを続けて実行すると、前のテストで追加したTodoが次のテストにも残ってしまう(インメモリストアなので、サーバーが再起動されない限りデータが蓄積される)。
toHaveCount のテストが他のテストの影響を受けないよう、beforeEach でリセット用のエンドポイントを呼ぶ設計にするのが実務では一般的。
src/index.ts にテスト用のリセットエンドポイントを追加する(本番では無効化する想定)。playwright.config.ts の webServer.command で NODE_ENV=test を設定済みなので、テスト実行時のみこのルートが有効になる。
if (process.env.NODE_ENV === "test") {
app.post("/__reset", (c) => {
todoStore.reset();
return c.json({ ok: true });
});
}
src/store.ts にも reset メソッドを追加する。
export const todoStore = {
// ...既存のメソッド
reset(): void {
todos = [];
nextId = 1;
},
};
テスト側で毎回リセットする:
import { test, expect } from "@playwright/test";
test.beforeEach(async ({ request }) => {
await request.post("/__reset");
});
request はPlaywrightが提供するHTTPクライアントで、画面操作を介さず直接APIを叩ける。テストの前提条件を整えるのに便利。
まとめ
| 要素 | 役割 |
|---|---|
data-testid |
テスト専用の安定した要素の目印 |
getByRole / getByPlaceholder
|
ユーザー視点での要素の見つけ方 |
webServer 設定 |
テスト実行時にサーバーを自動起動 |
--ui モード |
失敗原因を視覚的にデバッグ |
beforeEach + リセットAPI |
テスト間のデータ干渉を防ぐ |
E2Eテストは実装の詳細(内部のクラス構造など)ではなく、ユーザーが実際に体験する操作をそのまま検証する。今回のようにAPIとフロントを両方自作している場合、E2Eテストが「バックエンドとフロントエンドが正しく繋がっているか」を保証する最後の砦になる。


