家計簿アプリを作る #95:所有者チェックのE2Eテスト

家計簿アプリ作成シリーズの第95回です。前回のレシート画像APIの所有者チェックは、目で見て確認しただけでした。うっかり壊しやすい部分なので、E2Eテストで固めておきます。

テストの筋書き

ユーザーを2人作り、一方のレシート画像にもう一方からアクセスして404になることを確認します。

test('他人のレシートにアクセスできない', async ({ page }) => {
  // ユーザAを作り、レシート付きの取引を登録して画像URLを控える
  const { email: emailA, password: passwordA } = await createUser(page);
  await login(page, emailA, passwordA);
  await createCategory(page);
  await createTransactionWithReceipt(page);
  await page.getByRole('link', { name: '編集' }).click();
  const srcA = await page.getByAltText('領収書').getAttribute('src');

  // ユーザBでアクセスすると404
  await logout(page);
  const { email: emailB, password: passwordB } = await createUser(page);
  await login(page, emailB, passwordB);
  expect((await page.request.get(srcA!)).status()).toBe(404);

  // ユーザA自身は200
  await logout(page);
  await login(page, emailA, passwordA);
  expect((await page.request.get(srcA!)).status()).toBe(200);

  // 後片付け
  await deleteUser(page, passwordA);
  await login(page, emailB, passwordB);
  await deleteUser(page, passwordB);
});

page.requestを使うと、そのページのCookie(=ログイン状態)を引き継いだままリクエストを送れます。page.goto()でも確認はできますが、ステータスコードを直接見られるのでテストの意図が明確になります。

お題93の動作確認と同じく、「Bが404になること」だけでなく「Aが200になること」も入れています。全部404になるバグが入っても気付けるようにするためです。

画面の作りとロケーターのずれ

ヘルパーを書いてみたら、画面の実装と合っていない箇所がいくつも出ました。

selectOptionは値で照合する

await page.getByLabel('種類').selectOption('支出');   // ❌

文字列を渡すと<option>のvalue属性と照合します。実際のHTMLは<option value="expense">支出</option>なので一致しません。表示テキストで選ぶならselectOption({ label: '支出' })と書きます。

Buttonにhrefを付けるとlinkになる

<Button href="/categories/new">登録</Button>

flowbite-svelteのButtonはhrefを指定すると<a>要素になるので、ロールはbuttonではなくlinkです。getByRole('button', { name: '登録' })では見つかりません。ヘルパーの中で経由する必要が無いなら、page.goto('/categories/new')で目的のURLへ直接移動する方が簡潔です。

画像は編集画面にしかない

取引の登録後は一覧画面に戻りますが、そこにあるのは「領収書あり」を示すアイコンだけで、画像そのものは編集画面にあります。URLを取得するには編集画面を開く必要がありました。

ハイドレーション前の操作は巻き戻される

いちばん手こずったのがこれです。カテゴリの選択でタイムアウトするのですが、失敗時のページスナップショットを見ると、その前の「種類」が選ばれていませんでした。

- combobox "種類":
    - option "収入" [selected]     ← 支出を選んだはずなのに収入のまま
    - option "カテゴリ"            ← 選択肢が0個

selectOption自体は成功しているので、一度は支出が選ばれ、その後で収入に戻されています。

原因はハイドレーションでした。SvelteKitはサーバーでHTMLを生成して返し、その後ブラウザでJavaScriptが読み込まれて状態と結び付きます。種類のselectはbind:value={selectedType}で状態と結ばれているため、ハイドレーションの時点で初期値"income"がDOMに書き戻されます。Playwrightが支出を選んだ直後にハイドレーションが完了すると、その選択が上書きされるわけです。

金額に入れた値が残っていたのは、そちらがbind:valueを使っていないからでした。バインドされている要素だけが巻き戻るので、気付きにくい現象です。手で操作していて遭遇しないのは、人間がクリックするまでの間にハイドレーションが終わっているからでした。

並列実行で初めて出た失敗

対策として「日付の選択を先に行う」形にしました。カレンダーはJavaScriptが動かないと開かないので、開いて日付を選べた時点でハイドレーションの完了が保証される、という狙いです。

このテスト単体では通るようになったのですが、3本すべてを流すと今度は日付の選択で落ちました。

waiting for getByRole('gridcell', { name: '2026年9月26日土曜日' })

Playwrightはテストファイルごとに並列実行するので、wrangler devとブラウザ3つが同じマシンを奪い合い、ハイドレーションがさらに遅くなります。日付欄の<input>はサーバーが返したHTMLに最初からあるので、Playwrightは「要素がある」と判断してすぐクリックしますが、まだJSが結び付いていないのでカレンダーが開きません。

クリックが1回きりでは保証にならない、ということでした。開くまで繰り返す必要があります。

await expect(async () => {
  await page.getByPlaceholder('Select date').click();
  await expect(page.getByRole('dialog', { name: 'Calendar' })).toBeVisible({ timeout: 1000 });
}).toPass();

toPass()は、中の処理が全体として成功するまで繰り返します。1回目のクリックが空振りしても、内側のアサーションが1秒で失敗し、クリックからやり直します。ハイドレーションが終わればカレンダーが開き、そこで抜けます。

--workers=1で直列にすれば今回の失敗は出なくなりますが、根本的な解決ではありません。実行環境が遅ければ直列でも起こりますし、テストが増えたときに並列実行できないのは損です。「JSが結び付くまで反応しない要素がある」前提で書く方が、結果的に安定します。

データのあるユーザーとないユーザーで遷移先が違う

もう1つ、既存のヘルパーが引っかかりました。

waiting for navigation to "/" until "load"
  navigated to "http://localhost:8787/?month=2026-09&page=1"

一覧ページのloadには「年月の指定が無ければ最新月へリダイレクトする」処理があります。これまでのテストはログイン直後のユーザーが取引を1件も持っていなかったので/のままでしたが、今回は取引を登録した後に再ログインするため、月付きのURLへ飛ばされました。

waitForURLは関数を渡せるので、パスだけを条件にしました。

await page.waitForURL((url) => url.pathname === '/');

後片付けの順番

アカウント削除はログインが必要なので、「Bを削除 → Aでログイン → Aを削除」ではなく、削除したいユーザーで都度ログインし直す形になります。

一度、deleteUserの後にlogoutを呼んで失敗しました。アカウント削除は/loginへ遷移してCookieも消えた状態で終わるので、すでにログアウト済みです。その状態でlogoutを呼ぶと、/へのアクセスが/loginにリダイレクトされ、ログアウトボタンが見つかりません。

まとめ

ポイント 内容
page.request ログイン状態を引き継いだままHTTPリクエストを送り、ステータスコードを直接確認できる
selectOption 文字列はvalueと照合。表示テキストで選ぶなら{ label: ... }
Button href <a>として描画されるのでロールはlink
ハイドレーション bind:valueの要素は、JSが結び付いた時点で初期値に巻き戻る
toPass() 「効くまで操作を繰り返す」が必要な場面で使う
並列実行 単体で通るのに全体で落ちるときは、負荷による遅延で顕在化する競合を疑う
テストデータの状態 データの有無で遷移先が変わることがある。待機条件は広めに取る
← トップページに戻る