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 has to fire in order: your Server Action opens a Stripe Checkout session, Stripe charges the card and fires a webhook back, your route handler verifies the signature and writes the new plan, and the UI flips to Pro. A silent break anywhere means a customer who paid and got nothing, or one who churned and is still billed. This project is where you stop hoping the path works and prove it.

You already built the path: in the Stripe billing project you shipped the webhook route that ingests checkout.session.completed, claims each event so a replay can’t double-charge, and writes the subscription into a plan_entitlements row. What was missing is the test suite underneath it, the one that catches a regression before a customer does. You build it here: integration tests that drive signed webhook events through your real route handler against a real Postgres, plus one Playwright test that drives the full Upgrade-to-Pro flow through a browser against a production build. The payoff is being able to change the handler knowing that the moment you break the contract, a named test goes red.

Here is where you are headed, the integration suite green with all three .int.test.ts files passing:

Above it sits one Playwright spec: pnpm test:e2e drives the whole path through a real browser and captures a trace you can replay when a step goes red. It runs against your own Stripe test-mode key, so you generate that report yourself in the final lesson.

You write only the four test files; everything they lean on ships working in the starter, so the skill on display is reading and writing tests as behavior contracts, not wiring up a framework. The four files exercise:

  • Reading a test as a behavior contract: naming what it proves from its name alone, then running it to confirm that behavior holds.
  • Mocking at the network boundary, not at your own functions, so your handler, projection, and helpers run as real code: MSW intercepts the outbound Resend call, and Stripe’s subscriptions.retrieve is stubbed at the SDK seam.
  • Integration tests against a real Postgres with per-test transaction rollback, so the suite passes twice in a row with no cleanup step.
  • Driving the money path end to end with Playwright: signed-in browser state, role-first locators, the Stripe Checkout iframe, and the trace viewer as your debugger.
  • Proving the suite is behavior-anchored by breaking the handler and watching the failure localize to the one test that asserts the broken behavior.

The bugs in a money path cluster at the seam where the framework, the database, the Stripe signature contract, and the outbound email all meet, so the integration tests sit there and hammer it directly. One Playwright test sits on top, covering the full composition that no integration test can reach. That 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.

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.

Every moving part beyond the four test files ships working in the starter — the two-project Vitest config, the harness that lets your real route join the test transaction, the Stripe and Resend mocks, the fixtures and factories, and the Playwright setup. This lesson gets them running on your machine; the next walks through each one.

The carried-in app from the Stripe billing project is the system under test : you read it but never touch it, so it collapses to a few labeled nodes. What you edit is the four highlighted files — three integration test stubs and one Playwright spec, each a describe.todo / test.fixme placeholder a later 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

Each of the five remaining lessons ends on a test you run green and watch pinpoint its failure.

Lesson 2 — Reading the test harness

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

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 idempotency replay 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 runs: 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 it is anchored to behavior.

This project needs two Postgres databases, both in one postgres-test service on port 55432 (off your dev DB’s 5432): saas_int_test for the integration tests, where each test rolls back its transaction, and saas_e2e for Playwright, which gets a full reset and a deterministic seed.

Two environment files carry the config. .env.test is committed: 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 holds your values: your Stripe test-mode key and a password for the seeded admin. The fixed STRIPE_WEBHOOK_SECRET=whsec_test_fixed_for_tests replaces a dynamic stripe listen secret, so the signature contract is deterministic: handler and test helper sign with the 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

Fill these two values into .env.test.local:

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

With both databases up and seeded, boot each suite once to confirm the harness works. 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, since each is a describe.todo stub. That’s the green-on-empty baseline: the config resolves, the test Postgres is reachable, and the suite is wired. Now 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

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

The harness is now proven before you’ve written a test. The next lesson reads every provided file, starting with the one mock that lets your real production handler run inside a transaction it never knows is there.