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.
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:
subscriptions.retrieve is stubbed at the SDK seam.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.
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.
postgres-test service on port 55432 holding both test DBslesson and integration (real Postgres, per-test rollback)webServer runs a production build; storageState auth; setup + chromium projects.env.test.localsaas_int_testsaas_e2efree entitlement@/db mock + the Stripe SDK stub + the MSW lifecycle.env.test and pins TZ=UTCsubscriptions.retrieve readssignedInAs(opts, tx) seeds an org + entitlementStripe.Subscription factory.auth/admin.jsonadminPage + an org slugEach 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.
Get the starter codebase from the project repository, under Chapter 091/start/.
Install dependencies:
pnpm installBring up the databases — this starts both the db (dev) and postgres-test (both test DBs) services:
docker compose up -dCopy the local env template and fill in your two values:
cp .env.test.local.example .env.test.localCreate and migrate the integration database:
pnpm db:test:setupCreate, migrate, and seed the Playwright database:
pnpm db:e2e:resetFill 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:
✓ |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 testsVitest 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:
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 skippedThe 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.