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:e2ethe full Upgrade-to-Pro composition · production build · separate saas_e2e Postgres
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.ymladds a postgres-test service on port 55432 holding both test DBs
vitest.config.tstwo projects — lesson and integration (real Postgres, per-test rollback)
playwright.config.tswebServer runs a production build; storageState auth; setup + chromium projects
.env.testcommitted, no real secrets — the integration tests load it directly
.env.test.local.exampletemplate you copy to the gitignored .env.test.local
Directoryscripts/
test-db-setup.tscreates and migrates saas_int_test
e2e-db-reset.tsresets, migrates, and seeds saas_e2e
seed-e2e.tsseeds one org, an admin, a member, a free entitlement
Directorysrc/
Directorytest/the test harness — walked file by file in the next lesson
integration-setup.tsthe @/db mock + the Stripe SDK stub + the MSW lifecycle
load-test-env.tsloads .env.test and pins TZ=UTC
stripe-retrieve-registry.tsper-test map the stubbed subscriptions.retrieve reads
Directorydb/
worker-db.tslazy test Drizzle client
with-rollback.tswraps each test in a transaction that rolls back
Directoryfixtures/
auth.tssignedInAs(opts, tx) seeds an org + entitlement
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.
Get the starter codebase from the project repository, under Chapter 091/start/.
Install dependencies:
Terminal window
pnpminstall
Bring up the databases — this starts both the db (dev) and postgres-test (both test DBs) services:
Terminal window
dockercomposeup-d
Copy the local env template and fill in your two values:
Terminal window
cp.env.test.local.example.env.test.local
Create and migrate the integration database:
Terminal window
pnpmdb:test:setup
Create, migrate, and seed the Playwright database:
Terminal window
pnpmdb: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:
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
Running2testsusing1worker
✓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
1passed
1skipped
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.