家計簿アプリを作る #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() |
「効くまで操作を繰り返す」が必要な場面で使う |
| 並列実行 | 単体で通るのに全体で落ちるときは、負荷による遅延で顕在化する競合を疑う |
| テストデータの状態 | データの有無で遷移先が変わることがある。待機条件は広めに取る |