家計簿アプリを作る #87:Playwright による E2E テスト

家計簿アプリ作成シリーズの第87回です。お題59でVitestによるユニットテストを導入しましたが、あれはcalculateTotalsのような関数単体の検証でした。今回はPlaywrightを導入し、実際にブラウザを動かして「サインアップして、ログインして、一覧ページに遷移する」といった一連のユーザー操作を自動で検証するE2E(End-to-End)テストを書きました。

導入:sv add playwrightとnpm init playwrightの違い

SvelteKitのドキュメントではnpx sv add playwrightが案内されています。Playwright側にもnpm init playwright@latestというセットアップ手段があり、どちらもPlaywright本体と設定ファイルを入れてくれる点は同じです。

違いは、sv addが既存のSvelteKitプロジェクトに追加する前提で作られていることです。package.jsonへのtest:e2eスクリプト追加や、既に入っているVitestとの共存(testスクリプトで両方を回す)まで面倒を見てくれます。npm init playwrightはフレームワークを問わない汎用のウィザードです。今回は既存プロジェクトへの追加なのでsv addを使いました。

実行すると、以前wranglerを最新化したときと同じ@cloudflare/workers-typesのバージョン競合で依存パッケージのインストールが失敗しました。設定ファイルの生成は完了していたので、残りは手動で入れました。

npm install --legacy-peer-deps
npx playwright install

テスト用サーバーはwrangler devで起動する

sv addが生成した設定は、テスト前にvite previewを起動するものでした。

webServer: { command: 'npm run build && npm run preview', port: 4173 },

vite previewはCloudflareのバインディングを提供しないので、D1にアクセスするページを開いた時点で落ちます。以前ハマった「npm run devだとD1が使えない」のと同じ理由です。wrangler devを使う形に書き換えました。

export default defineConfig({
  webServer: {
    command: 'npm run build && npx wrangler dev --port 8787',
    port: 8787,
    reuseExistingServer: !process.env.CI,
  },
  testDir: 'e2e',
  use: {
    baseURL: 'http://localhost:8787',
  },
});

reuseExistingServerを付けると、既にwrangler devが動いていればそれを再利用するので、ローカルでの試行錯誤が速くなります。baseURLを設定しておくと、テスト内でpage.goto('/login')のようにパスだけで書けます。

sv addはsrc/routes/demo/playwright/というデモページも作っていました。これは/demo/playwrightとして実際にアクセスできるページになってしまうので削除しました。

tsconfig.jsonのtypesの落とし穴

設定ファイルでprocess.env.CIを使ったところ、processが見つからないという型エラーになりました。原因はtsconfig.jsonのこの行です。

"types": ["@cloudflare/workers-types"]

typesを明示すると、そこに書いた型定義だけがグローバルに読み込まれます。普段は自動で読み込まれる@types/node(processの型定義はこちら)が除外されていました。"node"を追加して解決しました。

"types": ["@cloudflare/workers-types", "node"]

ユニットテストはソースの隣、E2Eテストは別ディレクトリ

Vitestのテストはsummary.test.tsのようにソースの隣に置いていますが、E2Eテストはプロジェクト直下のe2e/にまとめました。

ユニットテストは特定のソースファイルとペアになっています。ソースを相対パスでimportし、ソースを消せばテストも消します。一方E2Eテストはソースを一切importせず、ブラウザからHTTPでアクセスするだけです。検証しているのは「ログイン」というユーザーの行動で、これはページ・サーバー処理・フック・DBが協調して初めて成り立つものなので、隣に置くべき特定のファイルがありません。

ロケーター:ユーザーから見た要素を探す

最初のテストはこうなりました。

test('ログインページが表示される', async ({ page }) => {
  await page.goto('/login');
  await expect(page.getByRole('button', { name: 'ログイン' })).toBeVisible();
});

Playwrightが推奨するgetByRoleやgetByLabelは、CSSのクラス名やIDではなく、人間や読み上げソフトがその要素をどう認識するかを基準に要素を探します。実装をリファクタしても壊れにくく、「テストで見つけられない要素は支援技術でも認識しづらい」というアクセシビリティのチェックも兼ねられます。

getByLabelは、名前から<label>要素を探すように見えますが、返すのはそのラベルに紐付いた入力欄の方です。紐付けの判定はforとid、ラベルで囲む、aria-labelなど、ブラウザがラベルを解決するのと同じルールで行われます。

**getByRole**は、ロール(役割)とアクセシブルネーム(名前)の2つで探します。ロールはHTMLタグから暗黙的に決まり(<button>はbutton、<a href>はlink、<th>はcolumnheaderなど)、名前はボタンなら中のテキスト、入力欄なら紐付いたラベル、aria-labelがあればそれが優先されます。お題76で一覧の全選択チェックボックスに付けたaria-label="全選択"のおかげで、getByRole('checkbox', { name: '全選択' })と書けるわけです。

注意点が2つあります。

  • 名前の一致はデフォルトで部分一致です。完全一致にしたいときはexact: trueを付けます
  • 複数の要素に一致するとエラーになります(strict mode)。意図しない要素を操作してしまう事故を防ぐための仕様です

また、ロケーターは「探した結果」ではなく「探し方」です。click()やexpect()が呼ばれた時点で検索が走り、要素が現れるまで自動で待ってくれます。

テスト同士を依存させない

サインアップのテストを書いた後、ログインのテストをどう書くかで迷いました。「signup.spec.tsで作ったユーザーでlogin.spec.tsがログインする」形は避けるべきです。

  • Playwrightはファイル単位で並列実行するので、login.spec.tsが先に走ることがあります
  • 失敗したテストだけの再実行や、特定テストだけの実行ができなくなります

各テストはそれ単体で実行しても成功する必要があります。かといって同じファイル内に順番に並べると、今度はファイル内での順序依存が生まれます。しかもPlaywrightはテストごとに新しいブラウザコンテキストを作るので、ログイン状態はtest()をまたいで引き継がれません。

解決策は、依存先を「他のテスト」ではなく「関数」にすることでした。

// e2e/helpers.ts
export async function createUser(page: Page) {
  const email = `test-${Date.now()}-${Math.random().toString(36).slice(2)}@example.com`;
  const password = 'password1234';

  await page.goto('/signup');
  await page.getByLabel('名前').fill('Test User');
  await page.getByLabel('メールアドレス').fill(email);
  await page.getByLabel('パスワード').fill(password);
  await page.getByRole('button', { name: 'サインアップ' }).click();
  await page.waitForURL('/login');

  return { email, password };
}
// e2e/login.spec.ts
test('ログインして一覧ページに遷移できる', async ({ page }) => {
  const { email, password } = await createUser(page);  // 自分でユーザーを用意する
  await login(page, email, password);
  await expect(page).toHaveURL('/');
  await deleteUser(page, password);
});

メールアドレスは、並列実行で同じミリ秒に2つのテストが走っても衝突しないよう、ランダム文字列を足しています。

ヘルパーは操作の完了を待ってから返す

当初createUserとloginは、ボタンをclick()したところで終わっていました。click()が待つのはクリック操作の実行までで、その後のPOST送信とリダイレクトの完了は待ちません。

後片付けのために「ログイン→/settingsへ移動」とつなげたところ、ログインのPOSTが終わる前にページを離れてしまい、未ログインのまま/settingsへ行って/loginに戻されていました。Playwrightが失敗時に出力するtest-results/.../error-context.mdにはその時点のページのスナップショットが入っていて、そこにログイン画面が映っていたことで原因が分かりました。

それまで気付かなかったのは、呼び出し側のexpect(page).toHaveURL(...)が自動リトライでたまたま待ってくれていたからです。ヘルパーの最後にwaitForURLを入れ、操作が完了した状態で返す形に揃えました。こうしておけば、ヘルパーをどう組み合わせても安全です。

後片付けはアカウント削除機能を使う

テストのたびにローカルD1へユーザーが増えていくので、テストの最後に削除することにしました。一般論としては「後片付けはUIを経由せず、DBを直接操作する」のが定石ですが、今回はアカウント削除機能をUIから使っています。

  • アカウント削除は、アプリのテーブルに加えてBetter Authのuser・session・accountまで整合性を保って消しています。これをテスト側のSQLで再現すると、本体と二重管理になります
  • wrangler devが開いているローカルD1のファイルに、別プロセスから書き込むのは不安定です
  • アカウント削除機能自体の検証も兼ねられます
export async function deleteUser(page: Page, password: string) {
  await page.goto('/settings');
  await page.getByRole('tab', { name: 'アカウントの削除' }).click();
  await page.getByLabel('パスワードを入力してください').fill(password);

  page.once('dialog', (dialog) => dialog.accept());
  await page.getByRole('button', { name: 'アカウントの削除' }).click();
  await page.waitForURL('/login');
}

削除ボタンにはconfirm()の確認ダイアログがあります。Playwrightはダイアログをデフォルトで自動的にキャンセルするので、page.once('dialog', ...)で明示的に承認しています。

E2Eテストが見つけたアプリの不具合

deleteUserのgetByLabel('パスワードを入力してください')が、入力欄ではなくタブのボタンを掴んでエラーになりました。

locator resolved to <button role="tab" ... id="password" ...>

原因は、お題85でタブの不具合対策として各TabItemに付けたkeyでした。flowbite-svelteのTabItemはkeyをそのままタブボタンのidに使うため、key="password"のタブボタンがid="password"になり、アカウント削除フォームの<Input id="password">と同じIDの要素が2つできていました。<label for="password">は文書内で最初に見つかったid="password"に紐付くので、ラベルはタブボタンの方を指していたわけです。

これはテストだけの問題ではなく、アプリの不具合でした。ラベルの文字をクリックすると、入力欄にフォーカスが移るのではなく「パスワードの変更」タブに切り替わってしまいます。スクリーンリーダーでも、この入力欄はラベルが無いものとして扱われます。

タブの不具合の本当の原因はuse:enhanceの誤用で、keyは結果的に不要だったので、すべて削除しました。getByLabelが支援技術と同じ基準でラベルを見ていることが、そのまま不具合の発見につながった形です。

ついでに:アカウント削除の漏れとdb.batch()

アカウント削除の処理を読み直したところ、transactionTemplatesとbudgetAlertsが削除対象から漏れていました。どちらもアカウント削除機能(お題61)より後に追加したテーブルで、お題85のリストアで漏れていたのと同じパターンです。

あわせて、1件ずつawaitしていた削除をdb.batch()にまとめました。ここでも1つハマりました。

// ❌ 配列を作る時点で1件ずつ実行されてしまう
const queries = [
  await db.delete(transactions).where(eq(transactions.userId, userId)),
  // ...
];
await db.batch(queries);

Drizzleのクエリはawaitした時点で実行されるので、この配列には実行前のクエリではなく実行結果(D1Result)が入っていました。型エラーで気付けましたが、仮にキャストで通しても削除はバラバラに実行済みなので、db.batch()の意味がありません。ロケーターと同じく、awaitしなければ「実行方法の記述」、すれば「実行結果」です。

// ✅
await db.batch([
  db.delete(transactions).where(eq(transactions.userId, userId)),
  db.delete(categories).where(eq(categories.userId, userId)),
  db.delete(budgets).where(eq(budgets.userId, userId)),
  db.delete(recurringTransactions).where(eq(recurringTransactions.userId, userId)),
  db.delete(monthlyBudgets).where(eq(monthlyBudgets.userId, userId)),
  db.delete(transactionTemplates).where(eq(transactionTemplates.userId, userId)),
  db.delete(budgetAlerts).where(eq(budgetAlerts.userId, userId)),
]);

await auth.api.deleteUser({ body: { password }, headers: request.headers });

配列リテラルをdb.batch()の引数に直接書くと、TypeScriptがタプル型として推論してくれるので、リストアのときに必要だったasのキャストも要りません。

削除の順番

db.batch()とauth.api.deleteUser()は別々の処理なので、まとめてアトミックにはできません。そこで、データを先に消し、ユーザーを最後に消す順番にしています。

  • db.batch()が失敗した場合:バッチ全体がロールバックされ、deleteUserまで進まないので、何も消えません
  • db.batch()が成功し、deleteUserが失敗した場合:データは消えてアカウントだけ残ります。ユーザーは同じパスワードで削除をやり直せます

逆の順番だと、ユーザーだけ消えてデータが残ることがあり、ログインできる人がいないので誰もそのデータを消せなくなります。失敗したときに再試行できる側に倒れる順番にしておく、という考え方です。

根本的には、アプリ側のテーブルのuserIdにuser.idへの外部キーとonDelete: 'cascade'を付ければ、ユーザー削除だけで全データがDBの仕組みで一緒に消えます。テーブルを追加するたびに削除処理へ書き足す必要もなくなりますが、スキーマ変更とマイグレーションが必要なので、別の機会に取り組むことにしました。

まとめ

ポイント 内容
テスト用サーバー D1を使うのでvite previewではなくwrangler devを起動する
tsconfigのtypes 明示すると@types/nodeが外れる。必要なら"node"を追加する
テストの置き場所 ソースと1対1のユニットテストは隣に、アプリ全体を外から見るE2Eは別ディレクトリに
ロケーター getByLabelは紐付いた入力欄を返す。getByRoleはロールと名前で探す
テストの独立性 他のテストに依存せず、必要なデータはヘルパー関数で自分で用意する
ヘルパーの責任 操作の完了まで待ってから返す。呼び出し側のアサーション頼みにしない
失敗の調べ方 error-context.mdのページスナップショットで、失敗時にどの画面にいたかを確認する
E2Eの副産物 IDの重複によるラベルの紐付け誤りという、アプリ本体の不具合が見つかった
awaitとdb.batch() awaitしたクエリは実行済みの結果になる。バッチには実行前のクエリを渡す
← トップページに戻る