予約サービスは、機能を足すたびに「前に作った部分が壊れていないか」が心配になります。第9回までで、予約・決済・メール・管理画面ができました。毎回すべてを手で操作して確かめるのは、もう現実的ではありません。この回では、画面を自動で操作するテスト(Playwright)を足して、「会議室を選ぶ → 予約する → 支払い通知で確定する」という流れを、コマンド1つで確かめられるようにします。
「開発会社から『テストも作りました』と言われるけれど、何をどこまで確かめてくれているのか、正直よく分からない…」
先に結論です。テストは「部品ごとの確認」と「通しの確認」の2階建てで考えるのが基本です。部品ごとの確認(単体テスト)は速くて数を作れますが、画面のつながりの不具合は見つけられません。通しの確認(e2eテスト=end to end、入口から出口まで)は遅いので、本当に壊れてはいけない流れ(今回なら「予約から確定まで」)だけに絞ります。そして、テストが「いつ・誰の手で・どの環境で」実行されるのかまで決めておくと、発注者も品質を確かめやすくなります。
前回の第9回では、管理者だけが入れる管理画面を作りました。詳しくは次の記事で解説しています。


今回は、ここまで作った機能をまとめて自動で確かめます。
この回で作るもの:予約の通しテストと自動実行の設定
使ったバージョンは Next.js 16・Playwright 1 系・Vitest 5 で、コードは blogs/it_hacchu/series/next-yoyaku/code/ にあります。
| 作るもの | 役割 | 主なファイル |
|---|---|---|
| 単体テスト(既存・Vitest) | 料金計算・日付・署名の検証・DB の制約など、部品ごとの確認。107件 | tests/ |
| 通しテスト(新規・Playwright) | ブラウザを自動で操作し、予約から確定までを確認。4本 | e2e/reservation.spec.ts |
| 偽の Stripe | 本物の決済サービスに通信せず、決済ページの作成にだけ答える小さなサーバー | e2e/fake-stripe.ts |
| 自動実行の設定 | 変更のたびに上記をまとめて実行する(GitHub Actions のひな形) | .github/workflows/ci.yml |
何をテストし、何をテストしないか
Playwright は、実際のブラウザ(Chromium など)を自動で動かしてクリックや入力をするツールです。Next.js の公式ドキュメントでも、e2eテストの方法の一つとして紹介されています。
📰 出典:Next.js Docs「Setting up Playwright with Next.js」
ただ、何でも通しテストにすると、遅くて壊れやすいテストが増えます。この連載では次のように分けました。
| 確かめたいこと | 向いているテスト | 理由 |
|---|---|---|
| 料金の計算・キャンセル規定・日付の扱い | 単体テスト | 画面なしで速く、細かい境界値まで確かめられる |
| 二重予約を DB が止めるか | 単体テスト(DB 接続あり) | 10件同時に予約して1件だけ成功することを直接確かめる |
| 一覧から予約し、確定するまでの流れ | 通しテスト | 画面・Server Action・DB・Webhook のつなぎ目の不具合は、通さないと見つからない |
| 権限(未ログイン・一般会員・管理者) | 通しテスト | 実際の Cookie とページの組み合わせで確かめる |
| 本物の Stripe の決済画面 | テストしない | 他社のサービスで、画面が変わると壊れる。Stripe のテストモードで人が確認する |
通しテストを動かす準備
専用のデータベースで動かす
通しテストはデータを書き換えます。開発中のデータを消さないよう、テスト専用のデータベース(yoyaku_e2e)を使い、名前に e2e が入っていない DB では動かないようにしました。
// e2e/global-setup.ts(抜粋)
// 本番や開発用の DB を誤って消さないよう、名前に e2e を含む DB 以外では動かさない
if (!new URL(e2eEnv.DATABASE_URL).pathname.includes("e2e")) {
throw new Error(`E2E_DATABASE_URL の DB 名に e2e が含まれていません: ${e2eEnv.DATABASE_URL}`);
}
execSync("npx drizzle-kit migrate", { stdio: "ignore", env: process.env });
const { db, client } = createDb();
await db.delete(reservations);
await db.delete(sessions);
await db.delete(users);
await db.delete(stripeEvents);
テストの前に毎回、予約・会員・通知の記録を空にしてサンプルのスペースを入れ直します。「前の実行のデータが残っていて通ったり落ちたりする」状態を避けるためです。
偽の Stripe を用意する
決済ページは Stripe が作るので、そのままでは通しテストに本物の鍵とインターネットが必要になります。そこで、決済ページの作成に答えるだけの小さなサーバーを用意しました。
// e2e/fake-stripe.ts(抜粋)
if (req.method === "POST" && req.url === "/v1/checkout/sessions") {
const id = `cs_test_e2e_${Date.now()}_${++seq}`;
res.end(JSON.stringify({ id, object: "checkout.session", url: `http://localhost:${port}/pay/${id}` }));
}
アプリ側は、環境変数 STRIPE_API_BASE があるときだけ Stripe の宛先を差し替えます。本番では設定しません。
// lib/payment.ts(抜粋)
// STRIPE_API_BASE は自動テスト(e2e)専用。偽の Stripe サーバーに向けるときだけ設定する(本番では設定しない)
const base = process.env.STRIPE_API_BASE ? new URL(process.env.STRIPE_API_BASE) : undefined;
cached = base
? new Stripe(key, { host: base.hostname, port: base.port, protocol: base.protocol.replace(":", "") as "http" | "https" })
: new Stripe(key);
アプリを起動する設定
playwright.config.ts の webServer に、偽の Stripe とアプリ本体の起動を書いておくと、テストの前に自動で立ち上がり、終わると止まります。アプリは開発用ではなく、npm run build 済みの本番と同じ形で起動します。
// playwright.config.ts(抜粋)
fullyParallel: false, // 同じ DB・同じスペースの枠を使うので、順番に実行する
workers: 1,
use: { trace: "retain-on-failure", screenshot: "only-on-failure" }, // 失敗したときだけ操作の記録を残す
webServer: [
{ command: "node e2e/fake-stripe.ts", url: `http://localhost:${stripePort}/pay/ping`, env: { PORT: String(stripePort) }, reuseExistingServer: !process.env.CI },
{ command: `npx next start -p ${appPort}`, url: `http://localhost:${appPort}/api/health`, env: e2eEnv, reuseExistingServer: !process.env.CI, timeout: 60_000 },
],
📰 出典:Playwright Docs「Web server」
予約の通しテストを書く
最初のテストは、利用者が行う操作をそのまま書き下したものです。
// e2e/reservation.spec.ts(抜粋)
test("会議室を選んで予約を申し込み、支払い通知で確定する", async ({ page }) => {
await page.goto("/spaces");
await page.getByRole("link", { name: /渋谷ミーティングルームA/ }).click();
// 3日先の10時の枠を選ぶ(日付は固定せず、実行日からの相対にする)
for (let i = 0; i < 3; i++) await page.getByRole("link", { name: "次の日 →" }).click();
await page.getByRole("link", { name: /^10:00〜 空き/ }).click();
// 何も入れずに送ると、項目ごとのエラーが出て、決済には進まない
await page.getByRole("button", { name: "この内容で予約する" }).click();
await expect(page.getByText("お名前を入力してください")).toBeVisible();
await page.getByLabel("お名前").fill("テスト花子");
await page.getByLabel("メールアドレス").fill("hanako@example.com");
await page.getByLabel("利用時間").selectOption("2");
await page.getByLabel(/利用人数/).fill("3");
await page.getByRole("button", { name: "この内容で予約する" }).click();
// 偽の Stripe の決済ページへ移る(本物の Stripe には通信していない)
await expect(page).toHaveURL(/localhost:4010\/pay\/cs_test_e2e_/);
...
要素は CSS のクラス名ではなく、getByRole(役割)や getByLabel(ラベル)で探しています。見た目の変更に強く、利用者が見ている画面に近い探し方になるためです。
📰 出典:Playwright Docs「Best Practices」
支払い通知は、署名つきで自分で送る
実際の支払い通知は Stripe のサーバーから届きます。テストでは、第7回で作った署名の検証を通る通知を、テスト側で作って送ります。
// e2e/helpers.ts(抜粋)
const signature = stripe.webhooks.generateTestHeaderString({ payload, secret: e2eEnv.STRIPE_WEBHOOK_SECRET });
const res = await page.request.post("/api/stripe/webhook", { data: payload, headers: { "stripe-signature": signature, "content-type": "application/json" } });
expect(res.status()).toBe(200);
署名を省いて通すのではなく、本番と同じ署名の検証を通しているのがポイントです。続けて、確定後の画面に「予約が確定しています」が出ること、同じ通知をもう一度送ると duplicate(処理済み)になることを確かめています。
二重予約・権限のテスト
残りの3本は次のとおりです。
- 押さえ済みの時間は、カレンダーに「予約済み」と出てリンクにならない。URL を直接開いて送信しても、サーバー側で「すでに予約が入っています」と断る
- 未ログインで
/reservationsを開くとログイン画面へ戻される - 一般会員が
/adminを開くと 404、管理者なら「予約管理」が開く
ログイン済みの状態は、メールのリンクをたどらず、DB にセッションを書いて Cookie を渡して作っています。メールのリンクの動作は単体テストで確かめ済みなので、通しテストでは省いて速く・安定させる方針です。
動かして分かったつまずき3つ
実際に動かして、次の3つで引っかかりました。どれも「テストを書き始めると必ず出会う」類のものです。
1. getByRole("alert") が別の要素に当たる
最初は、エラー表示の確認を page.getByRole("alert").first() と書いていました。ところがこのテストは、項目が空のまま送信した直後に、次の入力が消える形で失敗しました。原因は、Next.js が画面遷移を読み上げるために常に置いている alert 役割の要素に当たり、エラーの表示を待たずに次の操作へ進んでいたためです。フォームの処理が終わる前に入力した内容が、処理後のフォームの再描画で消えていました。メッセージの文面(「お名前を入力してください」)で待つように直して解消しました。
テストの失敗は、アプリの不具合とは限らず、「待ち方」の問題であることが多いという典型例です。
2. DB が無い環境で単体テストが落ちる
「DATABASE_URL が無いときは DB のテストを飛ばす」と書いていたのに、DB 無しで npm test を実行すると5ファイルが失敗しました。describe.skipIf(...) の中身は、飛ばす場合でも定義のために一度実行され、その中で DB に接続しようとして例外になっていたためです。飛ばす条件のときは接続しない入れ物を返すヘルパーを用意しました。
// tests/helpers/db.ts
export function createTestDb(): ReturnType<typeof createDb> {
return process.env.DATABASE_URL ? createDb() : ({} as ReturnType<typeof createDb>);
}
直した結果、DB 無しでは62件が通って45件が飛ばされ、DB ありでは107件がすべて通ります。「飛ばす」と書いたテストが本当に飛ぶかは、実際にその環境で実行しないと分かりません。
3. テストからアプリの部品を読み込むと動かないことがある
通しテストのログイン処理で、アプリの lib/auth.ts をそのまま読み込もうとしたところ、Next.js 専用の読み込み(next/headers)がテスト側の実行環境で解決できず、起動時にエラーになりました。テスト側では Next.js に依存する部品を使わず、DB に直接書くようにしました。アプリの部品をテストで使い回せるかは、その部品が何に依存しているかで決まります。
自動で実行する:変更のたびにテストが走る仕組み
テストは、誰かが思い出して実行するものだと、そのうち実行されなくなります。変更を GitHub に出すたびに自動で実行されるよう、GitHub Actions の設定のひな形を用意しました。
# .github/workflows/ci.yml(抜粋)
- run: npm ci
- run: npm run lint
- run: npm run typecheck
- run: npx drizzle-kit migrate
- run: npm test # 単体テスト+DB につないだテスト
- run: npx playwright install --with-deps chromium
- run: npm run build
- run: npm run test:e2e # 本番ビルドを起動して、ブラウザで予約の通しを確認
通しテストが失敗したときは、画面のスクリーンショットと操作の記録(トレース)を保存するようにしてあります。トレースは Playwright のトレースビューアで開くと、各操作の前後の画面を順にたどれます。
📰 出典:Playwright Docs「Trace viewer」
動作確認の結果と、確認できていないこと
| 項目 | 結果 |
|---|---|
| lint・型チェック | 通過 |
| 単体テスト(DB あり) | 107件通過 |
| 通しテスト | 4本通過(続けて2回実行して、どちらも通過) |
| GitHub Actions での実行 | 未確認(この環境では実行していません。設定ファイルは手元のコマンド列に合わせたひな形です) |
| 本物の Stripe・メール送信 | 未確認(通しテストは偽の Stripe を使っています) |
| Docker での起動 | 未確認(この環境では Docker が使えません) |
なお、検証した環境の都合で、ビルドには Turbopack を使わない next build --webpack を使いました。通常の環境では npm run build で動く想定です。
発注者向けメモ:「テストあり」と言われたら確かめること
テストは、品質を目に見える形にできる数少ない成果物です。依頼するときは、次を確認してください。
- ☐ テストが何件あり、何を確かめているのかを一覧で見せてもらえる
- ☐ 部品ごとの確認と、通しの確認の両方がある(片方だけではない)
- ☐ テストが自動で実行される仕組み(CI)があり、結果を発注者も見られる
- ☐ 決済・メールなど外部サービスの部分を、何で代用し、何が未確認かが明記されている
- ☐ テストを書く工数が見積もりに入っている(後から足すと割高になりやすい)
- ☐ 納品物にテストのコードと、実行方法の説明が含まれている
開発会社への質問例です。
- 「予約から支払い・確定まで、どの範囲を自動テストで確かめていますか。人が手で確かめる部分はどこですか」
- 「テストは変更のたびに自動で実行されますか。失敗したときは誰に通知されますか」
- 「決済やメールのテストは、本物のサービスと偽物のどちらで行いますか。本物でしか確かめられないことは何ですか」
- 「テストが落ちたまま公開する、ということは起こり得ますか。公開の条件を教えてください」
テストの工数は「機能の数」だけでなく、外部サービス(決済・メール・外部API)の数と環境(ブラウザ・端末)の数で増えます。スマホの実機や複数のブラウザで確かめたい場合は、最初に範囲を決めておくと、見積もりのずれを小さくできます。
まとめと次回予告
この回では、Playwright で予約から確定までの通しテストを作り、偽の Stripe と署名つきの支払い通知で、外部サービスなしでも流れを確かめられるようにしました。あわせて、DB 無しで単体テストが落ちる不具合を直し、変更のたびにテストを自動実行するための設定のひな形を用意しました。
次回の第11回(最終回)では、このサービスを Vercel に公開します。環境変数、データベースの接続、公開前のチェック、そして運用で気をつけることをまとめます。
この連載の記事一覧
この記事は連載「Next.jsで作るオンライン予約サービス」の1回です。連載のほかの回は次のとおりです(連載の一覧ページ)。
- 【Next.jsで作るオンライン予約サービス 第0回】完成像とNext.js 16の雛形を動かす(App Router・PostgreSQL・Drizzle)
- 【Next.jsで作るオンライン予約サービス 第1回】スペース一覧と詳細ページを作る(Server Components・Drizzle・メタデータ)
- 【Next.jsで作るオンライン予約サービス 第2回】空き状況カレンダーを作る(時間枠・日本時間・Drizzle)
- 【Next.jsで作るオンライン予約サービス 第3回】予約フォームをServer Actionsで作る(Zodによる入力チェックとエラー表示)
- 【Next.jsで作るオンライン予約サービス 第4回】ダブルブッキングを防ぐ(PostgreSQLの排他制約と同時アクセスのテスト)
- 【Next.jsで作るオンライン予約サービス 第5回】会員ログインを付ける(メールのリンクで入る方式と予約履歴)
- 【Next.jsで作るオンライン予約サービス 第6回】Stripeで支払いを受ける(Checkoutとサーバー側で決める金額)
- 【Next.jsで作るオンライン予約サービス 第7回】Webhookで予約を確定する(署名の検証・二重処理の防止・仮予約の期限切れ)
- 【Next.jsで作るオンライン予約サービス 第8回】予約確認メールとキャンセル・返金(キャンセル規定・Stripeの返金・メール送信)
- 【Next.jsで作るオンライン予約サービス 第9回】管理画面を作る(管理者の権限・予約一覧・スペースの料金編集)
- 【Next.jsで作るオンライン予約サービス 第10回】テストを自動化する(VitestとPlaywrightで予約の通しを確かめる)(この記事)








