Skip to content
Chapter 91Lesson 2

Reading the test harness

You have two commands to learn this chapter, and you’ll type them every day for the rest of it. pnpm test:integration runs Vitest against a real test Postgres, wrapping each test in a transaction that rolls back at the end. pnpm test:e2e runs Playwright against a production build of the app on its own port, talking to a separate database. Between them they cover the whole money path — the signed webhook that flips an org to Pro, and the browser journey through Stripe Checkout that triggers it.

Here is the thing that makes this chapter unusual: you write only four files all chapter. The three integration tests in tests/integration/ and the one Playwright spec in tests/e2e/ are yours. Everything else — the Vitest config, the mocks, the fixtures, the helpers, the Playwright config — ships complete in the starter. That is deliberate. The infrastructure was built once so each test you write costs minutes, not hours.

So before you write a single assertion, your job is to read the harness as a set of contracts. Not to memorize it, not to admire it — to know which lines are load-bearing, so that when you write the tests in the next lessons you do it deliberately instead of cargo-culting. There’s one decision at the center of all of it, and I’ll name it now so you can watch for it: the production webhook route is tested completely unedited. There is no test-only branch in it, no injected database handle, no fake. The seam that makes that possible is a mock of the @/db module, and once you see how it works the rest of the harness falls into place around it.

Let’s walk it.

Open the starter and orient yourself. The test surface lives in two places: src/test/ holds the harness that production never imports, and tests/ holds the actual test files split into integration/ (Vitest) and e2e/ (Playwright). The four bold files are the only ones you’ll touch this chapter — three are stubbed describe.todo, one is test.fixme. Read past them; everything dimmed is provided and complete.

  • Directorysrc/
    • Directorytest/ the harness, never imported by production code
      • empty-module.ts blank stub aliased to server-only / client-only
      • load-test-env.ts side-effect: load .env.test + pin TZ=UTC
      • integration-setup.ts the @/db mock + the Stripe SDK stub + MSW lifecycle
      • stripe-retrieve-registry.ts per-test Map the stubbed subscriptions.retrieve reads
      • Directorydb/
        • worker-db.ts lazy memoized test Drizzle client
        • with-rollback.ts wraps a test in a transaction it discards at teardown
      • Directoryfixtures/
        • auth.ts signedInAs(opts, tx), anonymous()
        • stripe-events.ts checkoutCompleted, subscriptionUpdated, subscriptionDeleted
        • stripe-subscription.ts fixtureSubscription(opts)
      • Directoryhelpers/
        • post-webhook.ts signs an event and calls the real route handler
      • Directorymsw/
        • server.ts MSW server (Resend only)
        • handlers/resend.ts records POST /emails into resendCalls
    • app/api/webhooks/stripe/route.ts the system under test, unchanged from the billing project
    • db/test-tx-context.ts the AsyncLocalStorage store the mock and the harness share
  • Directorytests/
    • Directoryintegration/
      • webhook-checkout-completed.int.test.ts TODO (next lesson)
      • webhook-idempotency.int.test.ts TODO
      • webhook-signature-rejected.int.test.ts TODO
    • Directorye2e/
      • auth.setup.ts signs the admin in via the API, writes .auth/admin.json
      • fixtures.ts adminPage (from storageState) + orgSlug
      • checkout-money-path.spec.ts TODO
      • helpers/fill-stripe-card.ts fills the Stripe Checkout card iframe

Back in the first testing chapter you set up a single vitest.config.ts with a lesson project that runs the auto-graded verification files. This chapter adds a second project — integration — to the same file. One config, two projects, run independently by their --project flag.

The lesson project is unchanged: no database, just the verification harness. The integration project is where the real work happens. Read it closely — three of these lines look ordinary and are anything but.

import { fileURLToPath } from 'node:url';
import tsconfigPaths from 'vite-tsconfig-paths';
import { defineConfig } from 'vitest/config';
const emptyModule = fileURLToPath(
new URL('./src/test/empty-module.ts', import.meta.url),
);
export default defineConfig({
test: {
projects: [
{
plugins: [tsconfigPaths()],
test: {
name: 'lesson',
environment: 'node',
globals: false,
include: ['lesson-verification/**/*.ts'],
},
},
{
plugins: [tsconfigPaths()],
resolve: {
alias: { 'server-only': emptyModule, 'client-only': emptyModule },
},
test: {
name: 'integration',
environment: 'node',
globals: false,
include: ['tests/integration/**/*.int.test.ts'],
setupFiles: ['./src/test/integration-setup.ts'],
fileParallelism: false,
},
},
],
},
});

vite-tsconfig-paths sits inside each project’s plugins, not once at the root. In Vitest 4 the root plugins array does not propagate into test.projects — a root-only placement would leave every @/… import unresolved and the suite would fail to even load. This is the most common Vitest-4 setup trip.

import { fileURLToPath } from 'node:url';
import tsconfigPaths from 'vite-tsconfig-paths';
import { defineConfig } from 'vitest/config';
const emptyModule = fileURLToPath(
new URL('./src/test/empty-module.ts', import.meta.url),
);
export default defineConfig({
test: {
projects: [
{
plugins: [tsconfigPaths()],
test: {
name: 'lesson',
environment: 'node',
globals: false,
include: ['lesson-verification/**/*.ts'],
},
},
{
plugins: [tsconfigPaths()],
resolve: {
alias: { 'server-only': emptyModule, 'client-only': emptyModule },
},
test: {
name: 'integration',
environment: 'node',
globals: false,
include: ['tests/integration/**/*.int.test.ts'],
setupFiles: ['./src/test/integration-setup.ts'],
fileParallelism: false,
},
},
],
},
});

The integration project aliases server-only and client-only to a blank module. The route transitively imports server-only (through the Stripe SDK wrapper and the webhook handlers), and that package throws by design when evaluated outside a React Server Component. Under the Node test env there’s no RSC, so the alias swaps it for nothing — letting the test import the real handler without exploding.

import { fileURLToPath } from 'node:url';
import tsconfigPaths from 'vite-tsconfig-paths';
import { defineConfig } from 'vitest/config';
const emptyModule = fileURLToPath(
new URL('./src/test/empty-module.ts', import.meta.url),
);
export default defineConfig({
test: {
projects: [
{
plugins: [tsconfigPaths()],
test: {
name: 'lesson',
environment: 'node',
globals: false,
include: ['lesson-verification/**/*.ts'],
},
},
{
plugins: [tsconfigPaths()],
resolve: {
alias: { 'server-only': emptyModule, 'client-only': emptyModule },
},
test: {
name: 'integration',
environment: 'node',
globals: false,
include: ['tests/integration/**/*.int.test.ts'],
setupFiles: ['./src/test/integration-setup.ts'],
fileParallelism: false,
},
},
],
},
});

Every file in this project loads integration-setup.ts first. That’s where the mocks and the MSW lifecycle live — the next section. The lesson project has no setup file because it never touches the database or the network.

import { fileURLToPath } from 'node:url';
import tsconfigPaths from 'vite-tsconfig-paths';
import { defineConfig } from 'vitest/config';
const emptyModule = fileURLToPath(
new URL('./src/test/empty-module.ts', import.meta.url),
);
export default defineConfig({
test: {
projects: [
{
plugins: [tsconfigPaths()],
test: {
name: 'lesson',
environment: 'node',
globals: false,
include: ['lesson-verification/**/*.ts'],
},
},
{
plugins: [tsconfigPaths()],
resolve: {
alias: { 'server-only': emptyModule, 'client-only': emptyModule },
},
test: {
name: 'integration',
environment: 'node',
globals: false,
include: ['tests/integration/**/*.int.test.ts'],
setupFiles: ['./src/test/integration-setup.ts'],
fileParallelism: false,
},
},
],
},
});

Files run one at a time. The test database is a single shared schema, so isolation comes from per-test transaction rollback, not from running each file in its own worker. That’s the opposite of the per-worker model you used in One database per worker — same goal, different mechanism, because here the rollback boundary already guarantees isolation and one DB is simpler.

1 / 1

The integration setup — the heart of the harness

Section titled “The integration setup — the heart of the harness”

This is the most important file in the lesson. Read it top to bottom; the order of the statements is itself part of the contract.

import '@/test/load-test-env';
import { afterAll, afterEach, beforeAll, vi } from 'vitest';
import { resendCalls } from '@/test/msw/handlers/resend';
import { server } from '@/test/msw/server';
import { resetSubscriptions } from '@/test/stripe-retrieve-registry';
if (!process.env.DATABASE_URL_TEST?.includes('localhost:55432')) {
throw new Error(
`integration tests refuse to run: DATABASE_URL_TEST must point at localhost:55432 (got: ${process.env.DATABASE_URL_TEST ?? 'unset'})`,
);
}
vi.mock('@/db', async (importActual) => {
const actual = await importActual<typeof import('@/db')>();
const { testTxContext } = await import('@/db/test-tx-context');
const { getTestDb } = await import('@/test/db/worker-db');
type Tx = import('@/db').Transaction;
const proxy = new Proxy({} as typeof actual.db, {
get(_target, prop) {
const current = testTxContext.getStore() ?? getTestDb();
if (prop === 'transaction') {
return (fn: (tx: Tx) => Promise<unknown>) =>
fn((testTxContext.getStore() ?? current) as Tx);
}
return Reflect.get(current as object, prop);
},
});
return { ...actual, db: proxy, dbUnpooled: proxy };
});
vi.mock('@/lib/billing/stripe', async (importActual) => {
const actual = await importActual<typeof import('@/lib/billing/stripe')>();
const { lookupSubscription } = await import(
'@/test/stripe-retrieve-registry'
);
return {
...actual,
stripe: {
...actual.stripe,
webhooks: actual.stripe.webhooks,
subscriptions: {
retrieve: async (id: string) => lookupSubscription(id),
},
},
};
});
beforeAll(() => {
server.listen({ onUnhandledRequest: 'error' });
});
afterEach(() => {
server.resetHandlers();
resendCalls.length = 0;
resetSubscriptions();
});
afterAll(() => {
server.close();
});

The very first line is a side-effect import that loads .env.test and pins process.env.TZ = 'UTC'. It must run before any @/… module body reads process.env — the env boundary validates at import time, and a date projection computed under your local timezone would be non-deterministic. This is the same discipline you set in Storage, domain, edge; it lives in its own file so Biome’s import sorter can’t reorder it below the others.

import '@/test/load-test-env';
import { afterAll, afterEach, beforeAll, vi } from 'vitest';
import { resendCalls } from '@/test/msw/handlers/resend';
import { server } from '@/test/msw/server';
import { resetSubscriptions } from '@/test/stripe-retrieve-registry';
if (!process.env.DATABASE_URL_TEST?.includes('localhost:55432')) {
throw new Error(
`integration tests refuse to run: DATABASE_URL_TEST must point at localhost:55432 (got: ${process.env.DATABASE_URL_TEST ?? 'unset'})`,
);
}
vi.mock('@/db', async (importActual) => {
const actual = await importActual<typeof import('@/db')>();
const { testTxContext } = await import('@/db/test-tx-context');
const { getTestDb } = await import('@/test/db/worker-db');
type Tx = import('@/db').Transaction;
const proxy = new Proxy({} as typeof actual.db, {
get(_target, prop) {
const current = testTxContext.getStore() ?? getTestDb();
if (prop === 'transaction') {
return (fn: (tx: Tx) => Promise<unknown>) =>
fn((testTxContext.getStore() ?? current) as Tx);
}
return Reflect.get(current as object, prop);
},
});
return { ...actual, db: proxy, dbUnpooled: proxy };
});
vi.mock('@/lib/billing/stripe', async (importActual) => {
const actual = await importActual<typeof import('@/lib/billing/stripe')>();
const { lookupSubscription } = await import(
'@/test/stripe-retrieve-registry'
);
return {
...actual,
stripe: {
...actual.stripe,
webhooks: actual.stripe.webhooks,
subscriptions: {
retrieve: async (id: string) => lookupSubscription(id),
},
},
};
});
beforeAll(() => {
server.listen({ onUnhandledRequest: 'error' });
});
afterEach(() => {
server.resetHandlers();
resendCalls.length = 0;
resetSubscriptions();
});
afterAll(() => {
server.close();
});

A fail-fast guard. The integration suite destroys data — it writes rows and rolls them back, but a misconfigured DATABASE_URL_TEST could point at something real. The throw refuses to run unless the URL names localhost:55432, the throwaway test Postgres. A loud refusal at setup beats discovering you truncated production at teardown.

import '@/test/load-test-env';
import { afterAll, afterEach, beforeAll, vi } from 'vitest';
import { resendCalls } from '@/test/msw/handlers/resend';
import { server } from '@/test/msw/server';
import { resetSubscriptions } from '@/test/stripe-retrieve-registry';
if (!process.env.DATABASE_URL_TEST?.includes('localhost:55432')) {
throw new Error(
`integration tests refuse to run: DATABASE_URL_TEST must point at localhost:55432 (got: ${process.env.DATABASE_URL_TEST ?? 'unset'})`,
);
}
vi.mock('@/db', async (importActual) => {
const actual = await importActual<typeof import('@/db')>();
const { testTxContext } = await import('@/db/test-tx-context');
const { getTestDb } = await import('@/test/db/worker-db');
type Tx = import('@/db').Transaction;
const proxy = new Proxy({} as typeof actual.db, {
get(_target, prop) {
const current = testTxContext.getStore() ?? getTestDb();
if (prop === 'transaction') {
return (fn: (tx: Tx) => Promise<unknown>) =>
fn((testTxContext.getStore() ?? current) as Tx);
}
return Reflect.get(current as object, prop);
},
});
return { ...actual, db: proxy, dbUnpooled: proxy };
});
vi.mock('@/lib/billing/stripe', async (importActual) => {
const actual = await importActual<typeof import('@/lib/billing/stripe')>();
const { lookupSubscription } = await import(
'@/test/stripe-retrieve-registry'
);
return {
...actual,
stripe: {
...actual.stripe,
webhooks: actual.stripe.webhooks,
subscriptions: {
retrieve: async (id: string) => lookupSubscription(id),
},
},
};
});
beforeAll(() => {
server.listen({ onUnhandledRequest: 'error' });
});
afterEach(() => {
server.resetHandlers();
resendCalls.length = 0;
resetSubscriptions();
});
afterAll(() => {
server.close();
});

This is the anchor decision of the whole chapter. The production route imports { db } from @/db and calls db.transaction(fn). This mock replaces @/db with a Proxy: every property access resolves to the current test transaction (or a lazily-opened test client when no test is running), and transaction(fn) runs fn(tx) directly on the in-scope tx instead of opening a nested one. So the route’s own transaction becomes a no-op join onto the transaction the test already opened — and the rollback the test owns discards everything the route wrote. The route runs unedited; the harness owns the transaction it thinks it owns.

import '@/test/load-test-env';
import { afterAll, afterEach, beforeAll, vi } from 'vitest';
import { resendCalls } from '@/test/msw/handlers/resend';
import { server } from '@/test/msw/server';
import { resetSubscriptions } from '@/test/stripe-retrieve-registry';
if (!process.env.DATABASE_URL_TEST?.includes('localhost:55432')) {
throw new Error(
`integration tests refuse to run: DATABASE_URL_TEST must point at localhost:55432 (got: ${process.env.DATABASE_URL_TEST ?? 'unset'})`,
);
}
vi.mock('@/db', async (importActual) => {
const actual = await importActual<typeof import('@/db')>();
const { testTxContext } = await import('@/db/test-tx-context');
const { getTestDb } = await import('@/test/db/worker-db');
type Tx = import('@/db').Transaction;
const proxy = new Proxy({} as typeof actual.db, {
get(_target, prop) {
const current = testTxContext.getStore() ?? getTestDb();
if (prop === 'transaction') {
return (fn: (tx: Tx) => Promise<unknown>) =>
fn((testTxContext.getStore() ?? current) as Tx);
}
return Reflect.get(current as object, prop);
},
});
return { ...actual, db: proxy, dbUnpooled: proxy };
});
vi.mock('@/lib/billing/stripe', async (importActual) => {
const actual = await importActual<typeof import('@/lib/billing/stripe')>();
const { lookupSubscription } = await import(
'@/test/stripe-retrieve-registry'
);
return {
...actual,
stripe: {
...actual.stripe,
webhooks: actual.stripe.webhooks,
subscriptions: {
retrieve: async (id: string) => lookupSubscription(id),
},
},
};
});
beforeAll(() => {
server.listen({ onUnhandledRequest: 'error' });
});
afterEach(() => {
server.resetHandlers();
resendCalls.length = 0;
resetSubscriptions();
});
afterAll(() => {
server.close();
});

The Stripe mock is surgical: it replaces only stripe.subscriptions.retrieve, wiring it to read from a per-test registry. Crucially, webhooks.* stays real — so generateTestHeaderString (when the helper signs your event) and constructEvent (when the route verifies it) both run the genuine SDK code, and your signature tests test real signature verification. Why isn’t Stripe on MSW like Resend? Because stripe@22’s HTTP client writes its request in a socket handler that never fires against MSW’s mock socket — a retrieve call over the interceptor would hang forever. The network-boundary discipline still holds; the seam just moves from the network to the SDK.

import '@/test/load-test-env';
import { afterAll, afterEach, beforeAll, vi } from 'vitest';
import { resendCalls } from '@/test/msw/handlers/resend';
import { server } from '@/test/msw/server';
import { resetSubscriptions } from '@/test/stripe-retrieve-registry';
if (!process.env.DATABASE_URL_TEST?.includes('localhost:55432')) {
throw new Error(
`integration tests refuse to run: DATABASE_URL_TEST must point at localhost:55432 (got: ${process.env.DATABASE_URL_TEST ?? 'unset'})`,
);
}
vi.mock('@/db', async (importActual) => {
const actual = await importActual<typeof import('@/db')>();
const { testTxContext } = await import('@/db/test-tx-context');
const { getTestDb } = await import('@/test/db/worker-db');
type Tx = import('@/db').Transaction;
const proxy = new Proxy({} as typeof actual.db, {
get(_target, prop) {
const current = testTxContext.getStore() ?? getTestDb();
if (prop === 'transaction') {
return (fn: (tx: Tx) => Promise<unknown>) =>
fn((testTxContext.getStore() ?? current) as Tx);
}
return Reflect.get(current as object, prop);
},
});
return { ...actual, db: proxy, dbUnpooled: proxy };
});
vi.mock('@/lib/billing/stripe', async (importActual) => {
const actual = await importActual<typeof import('@/lib/billing/stripe')>();
const { lookupSubscription } = await import(
'@/test/stripe-retrieve-registry'
);
return {
...actual,
stripe: {
...actual.stripe,
webhooks: actual.stripe.webhooks,
subscriptions: {
retrieve: async (id: string) => lookupSubscription(id),
},
},
};
});
beforeAll(() => {
server.listen({ onUnhandledRequest: 'error' });
});
afterEach(() => {
server.resetHandlers();
resendCalls.length = 0;
resetSubscriptions();
});
afterAll(() => {
server.close();
});

The MSW lifecycle. server.listen({ onUnhandledRequest: 'error' }) in beforeAll makes any unstubbed outbound call a loud failure — nothing escapes to the real network silently. afterEach resets MSW’s handlers, empties the resendCalls array, and clears the subscription registry, so one test’s state never leaks into the next. afterAll closes the server.

1 / 1

The inversion in that @/db mock is the kind of thing a diagram makes click. Here’s the path a single request takes, and where the harness quietly takes over.

postWebhook(event) the test helper
POST route handler app/api/webhooks/stripe/route.ts — unedited
db.transaction(fn) what the route calls
@/db Proxy mock intercepts the call
testTxContext tx opened by withRollback
test Postgres localhost:55432
The route thinks it opened its own transaction. The harness opened it, and the harness rolls it back — so the production code runs completely unedited.

The transaction the mock joins onto is opened by withRollback, the wrapper you’ll put around every integration test. You met this pattern in Rollback against real Postgres, so this is recall, not a re-derivation: it opens a transaction on the test client, runs your test body inside testTxContext.run(tx, …) so the @/db Proxy resolves to this tx, and then forces a rollback so nothing the test or the route wrote survives.

import type { Transaction } from '@/db';
import { testTxContext } from '@/db/test-tx-context';
import { getTestDb } from '@/test/db/worker-db';
class RollbackSignal extends Error {
constructor() {
super('__rollback__');
this.name = 'RollbackSignal';
}
}
type RollbackBody = (ctx: { tx: Transaction }) => Promise<void>;
export const withRollback =
(body: RollbackBody): (() => Promise<void>) =>
async () => {
try {
await getTestDb().transaction(async (tx) => {
await testTxContext.run(tx as Transaction, async () => {
await body({ tx: tx as Transaction });
throw new RollbackSignal();
});
});
} catch (error) {
if (error instanceof RollbackSignal) {
return;
}
throw error;
}
};

Look hard at the catch. It swallows only RollbackSignal and rethrows everything else. That single discriminating line is what keeps the helper honest: a catch-all here would silently eat a failed assertion — your test would pass green while the behavior it claims to check was actually broken. The rollback is a private sentinel thrown on purpose; a real failure is anything else, and anything else must propagate.

The transaction runs on getTestDb(), a lazily-memoized Drizzle client pointed at DATABASE_URL_TEST. Lazy matters: the connection opens on the first call inside a test, never at import, so merely importing the harness has no side effects. The testTxContext itself is an AsyncLocalStorage living at src/db/test-tx-context.ts, stored on globalThis so the mocked @/db and the harness share one instance even if the module gets evaluated twice across the test graph.

You’ll write your tests in this shape, and you’ll see it constantly over the next four lessons:

it('does the thing', withRollback(async ({ tx }) => {
// arrange / act / assert, reading and writing through tx
}));

The webhook reads and writes one org’s plan_entitlements row, so every integration test needs an org seeded first. That’s what signedInAs is for. It inserts a user, an org, a membership, a session, and a plan_entitlements row (default plan: 'free') all inside the rollback tx, then hands back the rows it made. Pass { role: 'admin' } for the admin case.

export const signedInAs = async (
opts: SignedInOptions,
tx: Transaction,
): Promise<SignedIn> => {

Now the honest part, and the file says so itself in a comment you should read: the Stripe webhook route is session-less. It never calls getSession; Stripe authenticates by signing the request body, not by carrying a cookie. So your three integration tests use signedInAs purely to seed the org and its entitlement the handler reads and writes. The session and cookieJar it returns are inert on this path — they exist only to keep the fixture’s shape identical to the one you built in The signedInAs fixture, where a session-reading system under test would actually use them. Don’t let the name fool you into thinking the webhook reads a session; it doesn’t.

The module exports exactly two things: signedInAs, and anonymous() — the signed-out marker, a no-op on the webhook path that documents intent.

The Stripe fixtures and the retrieve registry

Section titled “The Stripe fixtures and the retrieve registry”

The handler’s job on a checkout is to retrieve the subscription from Stripe and project it into the entitlement row. In a test there is no Stripe, so you have to declare, per test, exactly what subscriptions.retrieve should return. Three pieces cooperate to make that clean.

First, the event factories. Each returns a fully-typed Stripe.Event envelope — id, type, created, data.object — that the route reads exactly as production would.

export const checkoutCompleted = ({
customerId,
subscriptionId,
eventId = defaultEventId(),
createdAt = defaultCreated(),
}: CheckoutCompletedOptions): Stripe.Event => {

Notice the defaults on eventId and createdAt: they’re deterministic but unique per call, generated from a module sequence and the clock, never Math.random. That matters because eventId is the dedup key — the value the route claims in processed_events to make replays no-ops. If two tests reused the same eventId, the second test’s event would look like a duplicate of the first and the assertions would lie. So the convention is: happy-path tests let eventId auto-generate; the idempotency test you’ll write later this chapter pins it explicitly, because making both sends carry the same key is the entire point of that test.

Second, the subscription fixture builds a minimal Stripe.Subscription populated only with the fields the projection actually reads — the item-level lookup_key and current_period_end, the status, cancel_at_period_end, and the organization_id carried in metadata. That metadata is the tenancy carry-channel from Harden the webhook against forged tenancy — the handler cross-checks it against the org it resolved from the customer.

export const fixtureSubscription = ({
id,
lookupKey = 'course_pro_monthly',
status = 'trialing',
currentPeriodEnd = Math.floor(Date.now() / 1000) + 60 * 60 * 24 * 30,
cancelAtPeriodEnd = false,
quantity = 1,
orgId,
}: FixtureSubscriptionOptions): Stripe.Subscription => {

Third, the registry ties them together. registerSubscription(sub) puts a fixture in a per-test Map; the mocked subscriptions.retrieve calls lookupSubscription(id) to read it back, and lookupSubscription throws a clear error if nothing is registered for that id.

export const lookupSubscription = (id: string): Stripe.Subscription => {
const sub = registry.get(id);
if (!sub) {
throw new Error(
`stripe-retrieve-registry: no fixture registered for subscription "${id}" — call registerSubscription(fixtureSubscription({ id: "${id}", … })) in the test's arrange step`,
);
}
return sub;
};

So each test declares exactly the subscription its handler will retrieve, and resetSubscriptions() in afterEach clears the map between tests. This is the same per-test discipline MSW’s server.use(...) gives you on the network seam — just applied to the stubbed SDK seam. The signature-rejection test, later this chapter, will register nothing on purpose: a forged request never reaches the retrieve call, so a missing registration is itself part of the proof.

Resend, unlike Stripe, uses fetch — which MSW intercepts cleanly. So the one genuine network-boundary mock in this harness is the Resend handler. It records every POST to the Resend API into a module-scoped resendCalls array and returns a fake id.

export const resendCalls: ResendCall[] = [];
export const resendHandlers = [
http.post('https://api.resend.com/emails', async ({ request }) => {
const body = (await request.clone().json()) as {
to: string | string[];
subject: string;
html?: string;
};
resendCalls.push({ to: body.to, subject: body.subject, html: body.html });
return HttpResponse.json({ id: 'fake_resend_id' });
}),
];

The rule from Mock the wire stands: you intercept at the network boundary, never by reaching into lib/email.ts. The captured body is read through request.clone() so the handler doesn’t consume the stream Resend’s SDK already read.

Here’s the project-specific twist, and it’s a small surprise: no email fires off the webhook in this project. Notification fan-out lives in a dedicated dispatcher built later in the course, not in the billing handler. So every one of your tests asserts that resendCalls is empty — a negative boundary assertion proving the path stayed quiet. The mock is here not because you expect a call, but so that an unexpected one would show up loudly.

This is the helper that ties the front of the harness together: it takes one of your event fixtures, signs it the way Stripe would, and drives it through the real route handler.

export const postWebhook = async (
event: Stripe.Event,
opts: PostWebhookOptions = {},
): Promise<Response> => {
const body = JSON.stringify(event);
const secret = opts.secret ?? env.STRIPE_WEBHOOK_SECRET;
let signature = stripe.webhooks.generateTestHeaderString({
payload: body,
secret,
});
if (opts.tamperSignature) {
const last = signature.at(-1) ?? '0';
const flipped = last === '0' ? '1' : '0';
signature = signature.slice(0, -1) + flipped;
}
const request = new Request('http://localhost/api/webhooks/stripe', {
method: 'POST',
headers: {
'content-type': 'application/json',
'stripe-signature': signature,
},
body,
});
return POST(request);
};

The event is serialized to a string once, and that exact string is both signed and used as the request body. This is the trap: a second JSON.stringify could produce different bytes — key order, whitespace — and the signature would no longer match the body. Sign the bytes you send, the bytes you send are the bytes you signed.

export const postWebhook = async (
event: Stripe.Event,
opts: PostWebhookOptions = {},
): Promise<Response> => {
const body = JSON.stringify(event);
const secret = opts.secret ?? env.STRIPE_WEBHOOK_SECRET;
let signature = stripe.webhooks.generateTestHeaderString({
payload: body,
secret,
});
if (opts.tamperSignature) {
const last = signature.at(-1) ?? '0';
const flipped = last === '0' ? '1' : '0';
signature = signature.slice(0, -1) + flipped;
}
const request = new Request('http://localhost/api/webhooks/stripe', {
method: 'POST',
headers: {
'content-type': 'application/json',
'stripe-signature': signature,
},
body,
});
return POST(request);
};

Signing uses the real SDK method, stripe.webhooks.generateTestHeaderString — never a hand-rolled HMAC. Because the setup’s mock kept webhooks.* real, the route’s real constructEvent will verify this header for real. You resisted the temptation to recompute the HMAC yourself back in Webhook receivers under test; the same reasoning applies here.

export const postWebhook = async (
event: Stripe.Event,
opts: PostWebhookOptions = {},
): Promise<Response> => {
const body = JSON.stringify(event);
const secret = opts.secret ?? env.STRIPE_WEBHOOK_SECRET;
let signature = stripe.webhooks.generateTestHeaderString({
payload: body,
secret,
});
if (opts.tamperSignature) {
const last = signature.at(-1) ?? '0';
const flipped = last === '0' ? '1' : '0';
signature = signature.slice(0, -1) + flipped;
}
const request = new Request('http://localhost/api/webhooks/stripe', {
method: 'POST',
headers: {
'content-type': 'application/json',
'stripe-signature': signature,
},
body,
});
return POST(request);
};

When tamperSignature: true, the helper flips one character of the signature so verification fails while the header stays well-formed — the rejection comes from the signature check, not a parse error. This is the one knob the signature-rejection test turns.

export const postWebhook = async (
event: Stripe.Event,
opts: PostWebhookOptions = {},
): Promise<Response> => {
const body = JSON.stringify(event);
const secret = opts.secret ?? env.STRIPE_WEBHOOK_SECRET;
let signature = stripe.webhooks.generateTestHeaderString({
payload: body,
secret,
});
if (opts.tamperSignature) {
const last = signature.at(-1) ?? '0';
const flipped = last === '0' ? '1' : '0';
signature = signature.slice(0, -1) + flipped;
}
const request = new Request('http://localhost/api/webhooks/stripe', {
method: 'POST',
headers: {
'content-type': 'application/json',
'stripe-signature': signature,
},
body,
});
return POST(request);
};

The last line calls POST imported straight from app/api/webhooks/stripe/route — the same function production runs. No fake handler, no test-only branch. Combined with the @/db mock, this is what “test the real route unedited” means in practice.

1 / 1

Where the Server Action tests would go (but don’t)

Section titled “Where the Server Action tests would go (but don’t)”

A quick note to pre-empt a question. In Server Action tests you learned to test a Server Action directly by passing it a { db: tx } handle. You might expect the same here for the upgrade action. It isn’t — and that’s correct. The system under test in the integration suite is the session-less webhook route, which takes no handle and reads no session. The upgrade authedAction that opens the Checkout session is exercised only through the browser, in the Playwright money-path test at the end of this chapter. So there is no Server Action test in tests/integration/; the convention is named so you recognize its absence as intentional.

Switch tabs to the end-to-end side. The Playwright config drives a production build of the app, not a dev server, against the dedicated saas_e2e database. You set up this shape in Config, storageState, and the trace viewer, so focus on the three things this project pins.

export default defineConfig({
testDir: 'tests/e2e',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 1 : 0,
reporter: [['github'], ['html']],
use: {
baseURL: 'http://localhost:3001',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
},
webServer: {
command: 'pnpm build && pnpm start -p 3001',
url: 'http://localhost:3001',
reuseExistingServer: !process.env.CI,
timeout: 180_000,
env: {
DATABASE_URL: process.env.DATABASE_URL_E2E ?? '',
DATABASE_URL_UNPOOLED: process.env.DATABASE_URL_E2E ?? '',
STRIPE_SECRET_KEY: process.env.STRIPE_SECRET_KEY ?? '',
STRIPE_WEBHOOK_SECRET: process.env.STRIPE_WEBHOOK_SECRET ?? '',
APP_URL: process.env.APP_URL ?? 'http://localhost:3001',
},
},
projects: [
{ name: 'setup', testMatch: /auth\.setup\.ts/ },
{
name: 'chromium',
dependencies: ['setup'],
use: { ...devices['Desktop Chrome'], storageState: '.auth/admin.json' },
},
],
});

webServer runs a full production build then serves it on port 3001 — never next dev. Two reasons: you test the optimized output users actually get, and the non-default port means a dev server you left running on 3000 can’t accidentally serve the test, or worse, get hit with the test’s data. This is the canonical guard against the “my test was hitting dev data” bug.

export default defineConfig({
testDir: 'tests/e2e',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 1 : 0,
reporter: [['github'], ['html']],
use: {
baseURL: 'http://localhost:3001',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
},
webServer: {
command: 'pnpm build && pnpm start -p 3001',
url: 'http://localhost:3001',
reuseExistingServer: !process.env.CI,
timeout: 180_000,
env: {
DATABASE_URL: process.env.DATABASE_URL_E2E ?? '',
DATABASE_URL_UNPOOLED: process.env.DATABASE_URL_E2E ?? '',
STRIPE_SECRET_KEY: process.env.STRIPE_SECRET_KEY ?? '',
STRIPE_WEBHOOK_SECRET: process.env.STRIPE_WEBHOOK_SECRET ?? '',
APP_URL: process.env.APP_URL ?? 'http://localhost:3001',
},
},
projects: [
{ name: 'setup', testMatch: /auth\.setup\.ts/ },
{
name: 'chromium',
dependencies: ['setup'],
use: { ...devices['Desktop Chrome'], storageState: '.auth/admin.json' },
},
],
});

The env block points the test server at the e2e database (DATABASE_URL_E2E) and at your Stripe test account. The integration suite and the e2e suite talk to different databases — the integration one rolls back, the e2e one is reset and seeded wholesale — so they can never step on each other.

export default defineConfig({
testDir: 'tests/e2e',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 1 : 0,
reporter: [['github'], ['html']],
use: {
baseURL: 'http://localhost:3001',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
},
webServer: {
command: 'pnpm build && pnpm start -p 3001',
url: 'http://localhost:3001',
reuseExistingServer: !process.env.CI,
timeout: 180_000,
env: {
DATABASE_URL: process.env.DATABASE_URL_E2E ?? '',
DATABASE_URL_UNPOOLED: process.env.DATABASE_URL_E2E ?? '',
STRIPE_SECRET_KEY: process.env.STRIPE_SECRET_KEY ?? '',
STRIPE_WEBHOOK_SECRET: process.env.STRIPE_WEBHOOK_SECRET ?? '',
APP_URL: process.env.APP_URL ?? 'http://localhost:3001',
},
},
projects: [
{ name: 'setup', testMatch: /auth\.setup\.ts/ },
{
name: 'chromium',
dependencies: ['setup'],
use: { ...devices['Desktop Chrome'], storageState: '.auth/admin.json' },
},
],
});

Two projects. setup signs in once and writes the session to disk; chromium dependencies: ['setup'] so it always runs after, and inherits the saved storageState — so the money-path spec never logs in itself. Chromium is the only browser built by default; WebKit and Firefox are named-not-built, a deliberate CI-cost choice you can flip on later.

1 / 1

The setup project authenticates the seeded admin once and saves the session, so every later test starts already logged in. Read how it logs in — it’s not what you’d guess.

const ADMIN_FILE = '.auth/admin.json';
setup('authenticate as admin', async ({ page }) => {
const password = process.env.E2E_ADMIN_PASSWORD;
if (!password) {
throw new Error(
'E2E_ADMIN_PASSWORD is not set (copy .env.test.local.example to .env.test.local)',
);
}
const response = await page.request.post('/api/auth/sign-in/email', {
data: { email: 'admin@e2e.test', password },
});
expect(response.ok()).toBe(true);
await page.goto('/dashboard');
await expect(page).toHaveURL(/\/dashboard/);
await page.context().storageState({ path: ADMIN_FILE });
});

It signs in by hitting Better Auth’s API endpoint directly — page.request.post('/api/auth/sign-in/email', …) — not by filling and submitting the sign-in form. Why? Under Playwright, the form’s React-Compiler-built useActionState submit is unreliable: the automated submit leaks React’s internal action-encoding fields into the action’s strict-parsed FormData, and the login flakes. API-based auth setup is the robust, recommended pattern — it runs the same server-side credential check the form would, just without driving a brittle UI. It also still avoids the UI-login-per-test anti-pattern: you log in once, here, and reuse the cookie everywhere.

That cookie lands in .auth/admin.json. It’s a real session credential, so .auth/ is gitignored — confirm it never shows up in a git status before you commit.

The Playwright fixtures and the Stripe-card helper

Section titled “The Playwright fixtures and the Stripe-card helper”

Two small files finish the e2e side. The fixtures file extends Playwright’s test so your specs import { test, expect } from here, not from @playwright/test directly — keeping the storageState wiring and shared constants in one place.

export const test = base.extend<Fixtures>({
adminPage: async ({ browser }, use) => {
const context = await browser.newContext({
storageState: '.auth/admin.json',
});
const page = await context.newPage();
await use(page);
await context.close();
},
orgSlug: 'e2e-org',
});
export { expect } from '@playwright/test';

adminPage is a Page already authenticated from the saved session; orgSlug is the seeded org’s slug as a plain constant fixture.

The card helper centralizes the single most fragile third-party seam in the whole suite: reaching the card fields inside Stripe’s hosted Checkout iframe.

export const fillStripeCard = async (
page: Page,
card = '4242 4242 4242 4242',
): Promise<void> => {
const frame = page.frameLocator('iframe[src*="js.stripe.com"]').first();
const cardInput = frame.getByPlaceholder(/card number/i);
await expect(cardInput).toBeVisible({ timeout: 30_000 });
await cardInput.fill(card);
await frame.getByPlaceholder(/mm \/ yy/i).fill('12 / 34');
await frame.getByPlaceholder(/cvc/i).fill('123');
const zip = frame.getByPlaceholder(/zip|postal/i);
if (await zip.count()) {
await zip.fill('12345');
}
};

Stripe owns these selectors and changes them without telling anyone, so they live in one file — when a break happens, the fix is one place, not scattered across specs. Note the auto-waiting expect(cardInput).toBeVisible() before the first fill: the number-one Stripe-iframe flake is typing before the frame paints, and an auto-waiting assertion is the cure, never a waitForTimeout. The money-path test (the chapter’s last lesson) uses both files.

One more thing you don’t have to do: the products and prices the Checkout flow needs were already seeded into your Stripe test account when you built the billing project — pnpm seed:stripe ran then. The Playwright test reuses them, so there’s no extra Stripe-side seeding for this chapter.

You’ve read the contract. Now prove the harness is alive before writing a single test — a green-but-empty run means every wire is connected and any failure you see in the next lessons is yours, not the harness’s.

Run the integration suite first. The three test files are still describe.todo stubs, so Vitest collects them as pending and executes nothing.

Terminal window
pnpm test:integration

You should see a run that reports the todo blocks and zero executed tests — something like:

✓ tests/integration/webhook-checkout-completed.int.test.ts (1 todo)
✓ tests/integration/webhook-idempotency.int.test.ts (1 todo)
✓ tests/integration/webhook-signature-rejected.int.test.ts (1 todo)
Test Files 3 passed (3)
Tests 3 todo (3)

No assertion ran, but the config loaded, the @/db mock installed, the env guard passed, and MSW started — the whole setup path executed cleanly. That’s the signal.

Now the e2e suite. This one does real work even with no spec written: the setup project signs the admin in and writes the session file, then the chromium project finds only the test.fixme spec and skips it.

Terminal window
pnpm test:e2e

Expect the setup project to pass and the spec to report as skipped:

Running 2 tests using 1 worker
✓ [setup] › auth.setup.ts › authenticate as admin
- [chromium] › checkout-money-path.spec.ts › admin can upgrade to Pro via Stripe Checkout
1 passed
1 skipped

Confirm one artifact landed: .auth/admin.json now exists. That file is the proof the API sign-in worked and the storage state was captured — it’s the credential every browser test will reuse. It’s gitignored, so check it’s absent from git status and leave it be.

If you ever want to poke at the live app while debugging a failing test later, pnpm dev still boots the full app on port 3000, and /inspector — the entitlement, processed-events, and audit panels you built in the billing project — is right where you left it, reading the dev database.

The harness is alive. In the next lesson you write the first real test against it: a signed checkout.session.completed driven through this exact path, asserting on every row it writes.

The harness leans on four primitives worth bookmarking as you write your tests — each doc page is the authoritative reference for one wire you just read.