Skip to content
Chapter 91Lesson 1

Project overview

A money path is the one part of a SaaS you cannot afford to be wrong about. When a customer clicks “Upgrade to Pro,” a chain of moving parts has to fire in order — your Server Action opens a Stripe Checkout session, Stripe charges the card and fires a webhook back at your server, your route handler verifies the signature, writes the new plan to the database, and the UI flips to Pro. A silent break anywhere in that chain means a customer who paid you and got nothing, or one who churned and is still being billed. This is the project where you stop hoping that path works and start proving it.

You already built this path. Back in the Stripe billing project you shipped the webhook route that ingests checkout.session.completed, claims the event so a replay can’t double-charge, and projects the subscription into a plan_entitlements row. What was missing was the safety net underneath it — the test suite that catches a regression before a customer does. That’s what you build here: a layer of integration tests that drive signed webhook events through your real route handler against a real Postgres, and one Playwright test that drives the full Upgrade-to-Pro flow through a browser against a production build. By the end you can change that handler with confidence, because the moment you break the contract, a named test goes red.

This is where you are headed — the integration suite green against a real test Postgres, with all three .int.test.ts files passing:

Above that sits one Playwright spec — pnpm test:e2e drives the full Upgrade-to-Pro money path through a real browser against a production build (sign-in, the upgrade Server Action, the Stripe test-mode Checkout round-trip, the webhook, and the UI flip), and the trace it captures becomes your debugger when a step goes red. You run that one against your own Stripe test-mode key, so its report is yours to generate as you reach the final lesson.

The payoff to keep in view: a money path that is proven rather than hoped. Once the suite exists, every change you make to the webhook handler runs against it, and the cost of catching a billing regression drops from “an angry support ticket three weeks from now” to “a red test in your terminal before you’ve even pushed.” The four test files you write are the whole exercise — everything they lean on is provided — so the skill on display is reading and writing tests as behavior contracts, not wiring up a test framework.

The skills you’ll build toward that:

  • Reading a test as a behavior contract — naming what a test proves from its name alone, and running it to confirm the behavior actually holds.
  • Mocking at the network boundary, not at your own functions — MSW intercepts the outbound Resend call, and Stripe’s subscriptions.retrieve is stubbed at the SDK seam (MSW can’t intercept the way the Stripe SDK dispatches over the network), so your handler, projection, and every internal helper run as real code.
  • Driving integration tests against a real Postgres with per-test transaction rollback, so the suite runs green twice in a row with no cleanup step in between.
  • Driving a money path end to end with Playwright against a production build — signed-in browser state, role-first locators, the Stripe Checkout iframe, and the trace viewer as your debugger.
  • Proving a suite is behavior-anchored by deliberately breaking the handler and watching the failure localize to the one test that asserts the broken behavior.

A test suite is a coverage decision, and the shape of this one is deliberate. The bug-density layer of a money path isn’t the pure functions or the rendered components — it’s the seam where the framework, the database, the Stripe signature contract, and the outbound email all meet at once. So the integration tests sit at the center of gravity, hammering that seam directly. One Playwright test sits above them, covering the full composition — sign-in, the upgrade Server Action, the Stripe round-trip, the webhook, and the UI poll — that no integration test can reach on its own. This is the honeycomb shape from the testing chapters: fat in the middle where the bugs live, thin on top where one test earns its keep because the failure costs money.

Playwright — pnpm test:e2e the full Upgrade-to-Pro composition · production build · separate saas_e2e Postgres
Integration — pnpm test:integration the webhook route seam · per-test transaction rollback
The two layers and what each covers. Integration tests prove the webhook seam in isolation — the wide base where the bugs live; one Playwright test on top proves the whole money path composed.

You write only the four test files. Every other moving part ships in the starter, already working: the two-project Vitest config, the mock that lets your real route join the test transaction, the Stripe SDK stub and its per-test subscription registry, the MSW server that intercepts Resend, the auth fixture that seeds an org, the rollback helper, the factories that build Stripe events and subscriptions, the helper that signs and posts a webhook, the Playwright config, the auth setup, and the helper that fills the Stripe card iframe. The next lesson walks every one of those files; this lesson just gets them running on your machine.

The tree below is shallow on purpose — the carried-in app from the Stripe billing project (the route handler, the billing and webhook libraries, the inspector page) is the system under test, collapsed here to a few labeled nodes because you read it but never edit it. What you do edit is the four highlighted files: three integration test stubs and one Playwright spec, each shipping as a describe.todo / test.fixme placeholder that the named lesson fills in.

  • docker-compose.yml adds a postgres-test service on port 55432 holding both test DBs
  • vitest.config.ts two projects — lesson and integration (real Postgres, per-test rollback)
  • playwright.config.ts webServer runs a production build; storageState auth; setup + chromium projects
  • .env.test committed, no real secrets — the integration tests load it directly
  • .env.test.local.example template you copy to the gitignored .env.test.local
  • Directoryscripts/
    • test-db-setup.ts creates and migrates saas_int_test
    • e2e-db-reset.ts resets, migrates, and seeds saas_e2e
    • seed-e2e.ts seeds one org, an admin, a member, a free entitlement
  • Directorysrc/
    • Directorytest/ the test harness — walked file by file in the next lesson
      • integration-setup.ts the @/db mock + the Stripe SDK stub + the MSW lifecycle
      • load-test-env.ts loads .env.test and pins TZ=UTC
      • stripe-retrieve-registry.ts per-test map the stubbed subscriptions.retrieve reads
      • Directorydb/
        • worker-db.ts lazy test Drizzle client
        • with-rollback.ts wraps each test in a transaction that rolls back
      • Directoryfixtures/
        • auth.ts signedInAs(opts, tx) seeds an org + entitlement
        • stripe-events.ts event factories
        • stripe-subscription.ts minimal Stripe.Subscription factory
      • Directoryhelpers/
        • post-webhook.ts signs an event and calls the real route handler
      • Directorymsw/
        • server.ts MSW server (Resend only)
        • handlers/resend.ts records outbound Resend calls
    • Directoryapp/ the carried-in app — the system under test, unchanged
    • Directorylib/ billing + webhook libraries — unchanged
  • Directorytests/
    • Directoryintegration/
      • webhook-checkout-completed.int.test.ts TODO — the happy-path test
      • webhook-idempotency.int.test.ts TODO — the replay test
      • webhook-signature-rejected.int.test.ts TODO — the tampered-signature test
    • Directorye2e/
      • auth.setup.ts signs the admin in once, writes .auth/admin.json
      • fixtures.ts Playwright fixtures: a signed-in adminPage + an org slug
      • checkout-money-path.spec.ts TODO — the full Upgrade-to-Pro Playwright test
      • Directoryhelpers/
        • fill-stripe-card.ts fills the Stripe Checkout card iframe

Five lessons, each closing on a test you run green and prove localizes failure.

Lesson 2 — Reading the test harness

Walk every provided fixture, helper, and config — the two-project Vitest setup, the @/db mock, the Stripe stub, the MSW server, the rollback helper, and the Playwright wiring — then boot both empty suites to confirm the harness is alive.

Lesson 3 — The happy-path webhook test

Drive a signed checkout.session.completed event through the real handler and assert on the rows it writes: the entitlement, the claimed event, and the audit log.

Lesson 4 — The replay/idempotency test

Send the same event twice and prove the second send is a no-op — duplicate: true, no extra rows, no state change.

Lesson 5 — The signature-tampered rejection test

Tamper the signature and prove the request is rejected with a 400 before any work happens — nothing claimed, nothing written.

Lesson 6 — Driving Checkout end to end

Drive the full Upgrade-to-Pro money path with Playwright, then run the suite-wide mutation and coverage drills that prove the suite is behavior-anchored.

This project needs two Postgres databases, and they live in one place. The starter’s docker-compose.yml adds a postgres-test service on port 55432 (off your dev DB’s 5432) that holds both: saas_int_test for the integration tests, where each test wraps in a transaction that rolls back, and saas_e2e for Playwright, which gets a full reset and a deterministic seed. The setup scripts create each database against that one service.

Two environment files carry the config, and the split matters. .env.test is committed — it carries no real secrets, just the throwaway test DB URLs and a fixed test-only webhook secret — and the integration tests load it directly. .env.test.local is gitignored, and it’s where your values go: your own Stripe test-mode key (the Playwright test needs it to open a real test-mode Checkout session) and a password you choose for the seeded admin user. The integration tests sign and verify webhooks with the fixed STRIPE_WEBHOOK_SECRET=whsec_test_fixed_for_tests rather than a dynamic stripe listen secret, so the contract under test is fully deterministic — the route handler and the test helper sign with the exact same value.

  1. Get the starter codebase from the project repository, under Chapter 091/start/.

  2. Install dependencies:

    Terminal window
    pnpm install
  3. Bring up the databases — this starts both the db (dev) and postgres-test (both test DBs) services:

    Terminal window
    docker compose up -d
  4. Copy the local env template and fill in your two values:

    Terminal window
    cp .env.test.local.example .env.test.local
  5. Create and migrate the integration database:

    Terminal window
    pnpm db:test:setup
  6. Create, migrate, and seed the Playwright database:

    Terminal window
    pnpm db:e2e:reset

The two values you fill into .env.test.local:

  • STRIPE_SECRET_KEY — your own Stripe test-mode secret key, from your Stripe dashboard’s API keys page in test mode. It’s needed only for the Playwright Checkout test, which opens a real test-mode session; the integration tests never call live Stripe, because subscriptions.retrieve is stubbed. Never paste a live key here.
  • E2E_ADMIN_PASSWORD — any password you choose for the seeded admin user (admin@e2e.test). The seed script hashes this exact value into the admin’s credential, and Playwright’s auth setup signs in with it, so the two have to match.

With both databases up and seeded, boot each suite once to confirm the harness is alive before a single assertion exists. Run the integration suite first:

pnpm test:integration
|integration| tests/integration/webhook-checkout-completed.int.test.ts (0 test)
|integration| tests/integration/webhook-idempotency.int.test.ts (0 test)
|integration| tests/integration/webhook-signature-rejected.int.test.ts (0 test)
Test Files 3 passed (3)
Tests no tests

Vitest collected all three files and ran nothing — each one is a describe.todo stub, so there are no tests to execute yet. That’s the green-on-empty baseline: the config resolves, the test Postgres is reachable, and the suite is wired. Now boot the end-to-end suite:

pnpm test:e2e
Running 2 tests using 1 worker
1 [setup] › auth.setup.ts:17:1 › authenticate as admin
- 2 [chromium] › checkout-money-path.spec.ts:4:6 › admin can upgrade to Pro via Stripe Checkout
1 passed
1 skipped

Playwright ran the setup project — it signed the admin in through the Better Auth API using your E2E_ADMIN_PASSWORD and wrote the session to .auth/admin.json — and then the chromium project found only the test.fixme spec, which it skips. The setup step passing is the proof that matters: it means your seed and your password line up and a real session cookie was captured.

By the end of this lesson both databases are alive and seeded, both suites boot clean, and the harness is proven before you’ve written a test. The next lesson reads every provided file so you understand the contract surface before you write tests against it — starting with the one mock that lets your real production handler run inside a transaction it never knows is there.