Skip to content
Chapter 88Lesson 7

Server Actions through the full wrapper

Integration-test a Server Action end to end with Vitest, calling the exported action the way production does so the parse, authorize, mutate, revalidate, and typed return are all under test at once.

From the outside, a Server Action looks like one function call. Inside, it does five distinct things before it returns, and each one is a separate place production can break. createInvoice, the action you have carried through this whole chapter, parses its input through Zod, authorizes the caller against their role, mutates Postgres, revalidates the cache path the new row affects, and returns a typed Result. That is five seams behind one call.

A unit test of the inner body sees only one of those five. It hands the body a fake input it already knows is valid, a fake user it already knows is allowed, and asserts on the insert. The parse never runs, the authorize never runs, the revalidate never runs, and the typed return the wrapper assembles never runs. Four-fifths of what the action does to a real request is invisible to that test, and four-fifths of the bugs live there.

This lesson closes that gap. Every primitive the chapter built is a piece you assemble here: withRollback and tx, the per-worker database, signedInAs and anonymous, and MSW. You build nothing new. The work is the assembly: calling the exported action exactly the way production calls it, so the wrapper, the session resolution, the revalidate, and the typed return are all under test in one go. By the end you will have written a complete action-test file: one describe and six to ten it blocks, one per branch the action owes, each composing identity, transaction, and (where the action talks to a third party) the wire mock into a single test.

The signedInAs lesson said it owned identity up to the authorize seam and handed everything past it to this lesson. This is where that handoff is picked up.

Start from the whole shape, then vary one line at a time. Every branch you write after this one is a small change against this test, so it pays to see the skeleton complete before you start carving variants out of it.

Here is the action under test, shown at its wrapper shape so the seams are legible:

export const createInvoice = authedAction(
'member',
createInvoiceSchema,
async (input, { user, orgId, db }) => {
const [invoice] = await db
.insert(invoices)
.values({ ...input, orgId, createdBy: user.id })
.returning();
revalidatePath('/invoices');
return ok(invoice);
},
);

You built this wrapper when you wired up Server Actions; here you test what it produces. The four gates (parse, authorize, the body, and the typed return) are the wrapper’s job, and you do not test them as implementation. What you do test is the contract they produce, observed from the outside, exactly as the form that submits this action observes it.

Here is the complete happy-path test. Walk it part by part: the focus moves down through arrange, act, and the three things it asserts.

it('creates an invoice for a member', withRollback(async ({ tx }) => {
const ctx = await signedInAs({ role: 'admin', plan: 'pro' }, tx);
const result = await createInvoice(
{ amount: 4200, currency: 'eur' },
{ db: tx },
);
expect(result).toBeOkResult({ id: expect.stringMatching(/^inv_/) });
const [row] = await tx
.select()
.from(invoices)
.where(eq(invoices.orgId, ctx.org.id));
expect(row).toMatchObject({ amount: 4200, currency: 'eur' });
expect(revalidatePath).toHaveBeenCalledWith('/invoices');
}));

The test body runs inside a transaction that rolls back unconditionally when the test ends. tx is the handle you write against, and nothing here ever commits.

it('creates an invoice for a member', withRollback(async ({ tx }) => {
const ctx = await signedInAs({ role: 'admin', plan: 'pro' }, tx);
const result = await createInvoice(
{ amount: 4200, currency: 'eur' },
{ db: tx },
);
expect(result).toBeOkResult({ id: expect.stringMatching(/^inv_/) });
const [row] = await tx
.select()
.from(invoices)
.where(eq(invoices.orgId, ctx.org.id));
expect(row).toMatchObject({ amount: 4200, currency: 'eur' });
expect(revalidatePath).toHaveBeenCalledWith('/invoices');
}));

One call inserts the user, org, membership, and session inside tx, and stubs the session seam so the wrapper reads this identity. The admin/pro caller is over-privileged on purpose: the happy path proves success, so give it every permission.

it('creates an invoice for a member', withRollback(async ({ tx }) => {
const ctx = await signedInAs({ role: 'admin', plan: 'pro' }, tx);
const result = await createInvoice(
{ amount: 4200, currency: 'eur' },
{ db: tx },
);
expect(result).toBeOkResult({ id: expect.stringMatching(/^inv_/) });
const [row] = await tx
.select()
.from(invoices)
.where(eq(invoices.orgId, ctx.org.id));
expect(row).toMatchObject({ amount: 4200, currency: 'eur' });
expect(revalidatePath).toHaveBeenCalledWith('/invoices');
}));

This is the act. Call the exported action the way production does, with a parsed input object plus an options object { db: tx }. That handle substitutes tx for the default db, so the action’s write lands in the same transaction the test will roll back. The next three steps are the assertion axes.

it('creates an invoice for a member', withRollback(async ({ tx }) => {
const ctx = await signedInAs({ role: 'admin', plan: 'pro' }, tx);
const result = await createInvoice(
{ amount: 4200, currency: 'eur' },
{ db: tx },
);
expect(result).toBeOkResult({ id: expect.stringMatching(/^inv_/) });
const [row] = await tx
.select()
.from(invoices)
.where(eq(invoices.orgId, ctx.org.id));
expect(row).toMatchObject({ amount: 4200, currency: 'eur' });
expect(revalidatePath).toHaveBeenCalledWith('/invoices');
}));

Axis 1 is the return. The custom matcher from the previous chapter asserts the Result is ok and its data matches the expected shape. Assert the shape of the id (/^inv_/), never an exact value: sequence-backed ids advance across rolled-back tests, so an exact match is a flake waiting to happen.

it('creates an invoice for a member', withRollback(async ({ tx }) => {
const ctx = await signedInAs({ role: 'admin', plan: 'pro' }, tx);
const result = await createInvoice(
{ amount: 4200, currency: 'eur' },
{ db: tx },
);
expect(result).toBeOkResult({ id: expect.stringMatching(/^inv_/) });
const [row] = await tx
.select()
.from(invoices)
.where(eq(invoices.orgId, ctx.org.id));
expect(row).toMatchObject({ amount: 4200, currency: 'eur' });
expect(revalidatePath).toHaveBeenCalledWith('/invoices');
}));

Axis 2 is the database. Re-read the row through the same tx. The action’s write is uncommitted, so only tx can see it; a read on the global db would find nothing. A green ok proves the action said it wrote, and this proves Postgres actually stored the right values.

it('creates an invoice for a member', withRollback(async ({ tx }) => {
const ctx = await signedInAs({ role: 'admin', plan: 'pro' }, tx);
const result = await createInvoice(
{ amount: 4200, currency: 'eur' },
{ db: tx },
);
expect(result).toBeOkResult({ id: expect.stringMatching(/^inv_/) });
const [row] = await tx
.select()
.from(invoices)
.where(eq(invoices.orgId, ctx.org.id));
expect(row).toMatchObject({ amount: 4200, currency: 'eur' });
expect(revalidatePath).toHaveBeenCalledWith('/invoices');
}));

Axis 3 is the cache. revalidatePath is a spy, wired in setup in the next section. Assert it was called with the path the new invoice invalidates. A written row does not prove the cache was told about it, and this is the axis that catches a forgotten revalidatePath.

1 / 1

Notice that this is not one assertion but three, and they are independent. A green ok does not prove the row was written: the action could return ok and silently skip the insert. A written row does not prove the cache was revalidated: the list page would then serve stale data even though the database is correct. Each axis catches a regression the other two are blind to. This three-axis shape is the spine of every action test you will write.

one call createInvoice(input, { db: tx })
Return
expect(result).toBeOkResult(…) .toBeErrResult(…) what the caller gets back
Database
tx.select().from(invoices) what Postgres actually stored
Cache
expect(revalidatePath) .toHaveBeenCalledWith(…) what the cache was told
One call, three independent things to verify, each catching a regression the others miss.

The action reaches for three pieces of the Next.js runtime that do not exist inside a test process: it reads cookies, it resolves a session, and it calls into the cache. Register those mocks once in the integration setupFiles and every test in the suite inherits them. This is the same register-once, set-per-call discipline you met for the session seam in the signedInAs lesson: set the shared mocks up front, and let each test set the per-call values it needs.

Here are the three mocks, named by the seam they cover:

  • next/headers: the wrapper reads cookies(). This is already registered in the signedInAs setup, where the fixture’s cookieJar is what it returns. There is nothing to add here.
  • @/lib/auth: auth.api.getSession is the session seam. It is already registered in the signedInAs setup, where signedInAs sets its per-call value and anonymous sets it to null. Nothing to add here either.
  • next/cache: this is the one this lesson adds. Mocking it turns the action’s cache call into a spy you can assert on, and it stops a real cache invalidation from firing against a router that does not exist in the test process.

Here is the next/cache mock as it sits in the setup file:

src/test/setup.int.ts
vi.mock('next/cache', () => ({
revalidatePath: vi.fn(),
revalidateTag: vi.fn(),
updateTag: vi.fn(),
}));

These are vi.fn() placeholders: empty spies that record their calls and do nothing else. Because they record across calls, you have to wipe their history between tests, or test B inherits the calls test A made and your toHaveBeenCalledWith reports a false result. The setup file already does this for the session mock, and the same afterEach(() => vi.clearAllMocks()) covers these. Leaking mock history between tests is one of the named flake causes you will catalogue in the next lesson; for now, just know that the reset is what keeps the cache spy accurate.

One accuracy note is worth pinning down, because the signatures shifted in Next.js 16. revalidatePath(path) takes a single argument, which is what createInvoice calls and what the happy-path test asserts. revalidateTag(tag, profile) now requires a cacheLife profile as its second argument; the single-argument form is deprecated and produces a TypeScript error. So if a test ever asserts a tag revalidation, it reads expect(revalidateTag).toHaveBeenCalledWith('invoices', 'max'), where 'max' is the sensible default. updateTag(tag) is the read-your-writes companion that runs synchronously inside the action; mock it as the third spy and reach for it only when an action actually calls it.

Validation failure: the wrapper short-circuits before the body

Section titled “Validation failure: the wrapper short-circuits before the body”

Here is the first variation. Hand the action a structurally invalid input and the wrapper’s parse gate fires before the body: it returns err('validation', …) and the mutate code never runs. The proof that the short-circuit worked is not just the returned error; it is that nothing was written.

createInvoice.int.test.ts
it('rejects invalid input without touching the database', withRollback(async ({ tx }) => {
const ctx = await signedInAs({ role: 'admin', plan: 'pro' }, tx);
const result = await createInvoice(
{ amount: -1, currency: 'xyz' },
{ db: tx },
);
expect(result).toBeErrResult('validation');
expect(result.error.fieldErrors).toEqual(expect.any(Object));
const rows = await tx
.select()
.from(invoices)
.where(eq(invoices.orgId, ctx.org.id));
expect(rows).toHaveLength(0);
}));

Two assertion choices in there are deliberate. First, you assert that fieldErrors is present (expect.any(Object)), not what it says. The exact per-field strings, like “Amount must be positive” and “Unsupported currency”, are localizable copy that a translator or a product change can rewrite tomorrow; pinning them makes the test break on a wording change that broke nothing real. The contract is that there are field errors, not what they say. This is the same instinct you applied to error messages in the previous chapter.

Second, and this is the structural choice: you assert zero rows. The session is valid here, since the caller is a pro admin and fully entitled, so the only reason the database stays empty is that the parse gate returned before the body ran. A test that only checked the returned code would pass even if the wrapper had a bug that ran the body and then reported a validation error. The empty table is what proves the short-circuit, so assert it.

Unauthenticated: the action redirects, it does not return an error

Section titled “Unauthenticated: the action redirects, it does not return an error”

This is the branch most people model wrong, so it is worth treating carefully. Your intuition, trained on every other branch, says: call the action with no session and get back err('unauthenticated'). After all, “no session” is a failure, and failures come back as Result.err.

That is not how it works in this codebase.

The project’s auth ladder, where requireOrgUser calls down to requireUser, does not return an error for a session-less caller. It redirects them to sign-in. A redirect is a navigation, not a value, so you cannot pack it into a Result. Under the hood, requireUser calls Next.js’s redirect(), and redirect() works by throwing. Calling the action with no session throws NEXT_REDIRECT straight out of the wrapper, before the body ever runs. The action’s Result contract only covers the failures the body can produce, such as a validation reject or a domain refusal. A missing session is not a body failure; it is handled one layer up, by a throw.

Why does redirect() throw rather than return? Because there is no value it could return that would reliably stop the rest of the function from running. A return only ends the current function, and the calling wrapper might keep going. Throwing aborts the whole call stack in one move. redirect() throws a sentinel error whose digest is a string of the form NEXT_REDIRECT;<type>;<url>;<status>;, and Next.js’s framework boundary catches exactly that digest and turns it into an HTTP redirect. The contract is that the throw must propagate; anything that swallows it breaks navigation.

So the test asserts the throw, not a Result. Here is the intuition next to the reality.

it('returns an error when signed out', withRollback(async ({ tx }) => {
anonymous();
const result = await createInvoice(input, { db: tx });
expect(result).toBeErrResult('unauthenticated');
}));

This test never reaches its assertion. The act throws, so execution stops at the createInvoice line and the test fails with an uncaught NEXT_REDIRECT: not a clean assertion message, just a thrown error. And there is no 'unauthenticated' code anywhere in this codebase to assert against, because the unauthenticated path was never modeled as a Result.

If you want to be precise about why it threw, rather than catch any error at all, inspect the digest: error.digest.startsWith('NEXT_REDIRECT;') proves it was a redirect and not some unrelated failure that happened to bubble up. Next.js ships a predicate for exactly this check, isRedirectError(error), and that is what production-grade code uses when it genuinely needs to distinguish a redirect from a real error. In a test, rejects.toThrow('NEXT_REDIRECT') is the simple stand-in. Recognize isRedirectError when you see it, but reach for the string match by default.

This pattern is not special to the auth bounce. Any action that calls redirect() itself on success, such as a create-then-redirect that sends the user to the new resource, is tested the same way: rejects.toThrow('NEXT_REDIRECT') to observe the redirect, plus a DB-row assertion to prove the write happened before the redirect threw. The throw is how redirect() always reports, whether the redirect is an auth refusal or a deliberate navigation, so your test reads the same either way.

Insufficient role: the authorize gate refuses in place

Section titled “Insufficient role: the authorize gate refuses in place”

This is the third wrapper branch, and the one that draws the sharpest line against the last section. An unauthenticated caller gets redirected: thrown out and sent to sign-in. An authenticated but underprivileged caller gets something different: the authorize gate returns err('forbidden') in place, as a value. Same gate family, opposite exit.

To show this you need an action whose floor is higher than member. deleteInvoice requires admin, because destroying a record is not something a regular member should do:

deleteInvoice.int.test.ts
it('refuses a member who lacks the admin role', withRollback(async ({ tx }) => {
const ctx = await signedInAs({ role: 'member', plan: 'pro' }, tx);
const invoice = await buildInvoice({ orgId: ctx.org.id }, tx);
const result = await deleteInvoice({ id: invoice.id }, { db: tx });
expect(result).toBeErrResult('forbidden');
const [row] = await tx
.select()
.from(invoices)
.where(eq(invoices.id, invoice.id));
expect(row).toBeDefined();
}));

The arrange seeds a member and a real invoice for them to fail to delete. The assertion is toBeErrResult('forbidden'): a value, handed back, that a form can render as “you don’t have permission to do that” right where the user is standing. The side-effect check is the mirror of the validation test’s empty table: here the row still exists after the call, because the authorize gate fired before the body and the delete never ran.

Why two different exits for two refusals? Because they fit two different situations. An unauthenticated caller has no identity to show a permission error to, so the right move is to send them to sign in, which is a navigation and therefore a redirect. An authenticated caller is somewhere in the app with a session, and bouncing them to sign-in would be wrong, so the gate refuses in place and lets the UI explain. The same roleAtLeast machinery runs underneath; the exit is chosen to fit the caller.

Plan-gated branch: an entitlement check inside the body

Section titled “Plan-gated branch: an entitlement check inside the body”

You may have noticed signedInAs takes a plan parameter and wondered where it earns its keep, since the wrapper only checks the role. The answer is that the authedAction wrapper handles session, role, and schema, and nothing more. There is no plan gate in the wrapper. A plan or entitlement check therefore lives inside the action body, early, and returns a domain err(...) that the wrapper passes straight through untouched. So a “plan test” is really a body-logic test reached through the full wrapper, and that is exactly why it belongs in this suite rather than in a unit test: you want the real session resolution feeding a real body that makes a real entitlement decision.

exportInvoices is a pro-only feature. Its body checks the caller’s plan before it does any work:

exportInvoices.int.test.ts
it('refuses a free-plan caller before exporting', withRollback(async ({ tx }) => {
await signedInAs({ role: 'admin', plan: 'free' }, tx);
const result = await exportInvoices({ format: 'csv' }, { db: tx });
expect(result).toBeErrResult('forbidden');
}));

The caller is an admin, so the role is not the obstacle here, but the plan is free. The body sees the plan, refuses, and returns err('forbidden') before it touches a single invoice. Assert the transport code; if the project attaches a domain reason to the error payload, assert that through result.error, never through the user-facing message string. There are no side effects to check, because the entitlement check sits at the very top of the body, before any read or write, which is exactly where it belongs. A refusal should cost no more than the wrapper’s own gates do, so check the plan first and fail cheaply.

Outbound HTTP inside the action: the full stack in one test

Section titled “Outbound HTTP inside the action: the full stack in one test”

This is the capstone: an action that mutates the database and calls a third party, tested end to end. It is the one test where every primitive this chapter built appears at once. The action is createSubscription, the outbound sibling you mocked the wire for two lessons ago: it posts to Stripe and writes a subscription row.

This is one test with four assertion axes. Walk the arrange, then the four things it proves.

createSubscription.int.test.ts
it('subscribes a pro user: charges Stripe and records the row', withRollback(async ({ tx }) => {
const ctx = await signedInAs({ role: 'admin', plan: 'pro' }, tx);
const seen: Request[] = [];
server.use(
http.post('https://api.stripe.com/v1/subscriptions', async ({ request }) => {
seen.push(request.clone());
return HttpResponse.json({ id: 'sub_123', status: 'active' });
}),
);
const result = await createSubscription(
{ priceId: 'price_pro_monthly' },
{ db: tx },
);
expect(result).toBeOkResult({ id: 'sub_123' });
const body = await seen[0].text();
expect(body).toContain(`metadata[orgId]=${ctx.org.id}`);
expect(seen[0].headers.get('Idempotency-Key')).toBeTruthy();
const [row] = await tx
.select()
.from(subscriptions)
.where(eq(subscriptions.orgId, ctx.org.id));
expect(row).toMatchObject({ stripeSubscriptionId: 'sub_123' });
}));

Here are the four axes. The return (toBeOkResult) proves the action reported success. The intercepted request proves the wire: seen[0].text() decodes the form body Stripe actually received, and you assert that the metadata fields and the Idempotency-Key header are on it. The database row, read through tx, proves the subscription was persisted with Stripe’s id. And if this action revalidated a path, a fourth assertion on the revalidatePath spy would prove the cache axis too.

The second axis is the whole point of the wire-mocking lessons restated as an assertion. You assert the intercepted request, the bytes Stripe would have seen, never that the SDK’s create method was called. The Idempotency-Key is the cleanest example: nothing in your code wrote it, the SDK generated it, so no mock of your code could ever verify it is present. Only watching the wire can.

This single test is the chapter in miniature. Real Postgres through tx, real identity through signedInAs, the real Stripe SDK serializing a real request that MSW intercepts at the boundary, and a typed Result coming back: all of it exercised and all of it asserted, in one it. To see how the layers fire in order, scrub through the sequence below.

1 Arrange identity await signedInAs({ role: 'admin', plan: 'pro' }, tx) inserts user / org / membership / session inside tx, stubs the session seam
Identity exists, in the transaction. The session seam is primed to resolve this user.
2 Arrange the wire server.use(http.post('/v1/subscriptions', …)) registers a one-test Stripe handler that captures the request
MSW is now listening for the Stripe call this action will make.
3 Act await createSubscription({ priceId }, { db: tx }) call the exported action exactly as production does — input + the { db: tx } handle
Call the exported action exactly as production does — parsed input plus the { db: tx } handle.
4 Wrapper authorizes session resolved → pro admin → role gate cleared the wrapper reads the stubbed session, passes the role gate, the body gets to run
Session resolved, role gate cleared — the body gets to run.
5 Body writes the row db.insert(subscriptions).values(…) // db === tx the write lands in tx — visible to this test, invisible to the global db
The write lands in tx, visible to this test, invisible to the global db.
6 Body calls Stripe stripe.subscriptions.create(…) → fetch → MSW the real SDK builds the real request; MSW catches it at the network boundary
The real SDK builds the real request; MSW catches it at the network boundary and returns the canned subscription.
7 Return Result.ok return ok({ id: 'sub_123' }) three things proven from one call: the return, the bytes Stripe saw, the row in Postgres
Three things proven from one call: the return, the bytes Stripe saw, the row in Postgres.
8 Rollback withRollback throws its sentinel → transaction unwinds the DB is exactly as it was — but the Stripe call already happened, it does not roll back
The test ends, the transaction rolls back, the database is exactly as it was. The Stripe call already happened — it does not roll back, which is why you asserted on the intercepted request, not a real charge.

One thing to name while it is in front of you: a Server Action takes its database handle as an explicit { db: tx } argument, and that is correct here. You might remember the route-handler lesson reaching for an AsyncLocalStorage escape hatch to thread tx into a handler whose signature Next.js fixes. Do not reach for that here. An action’s signature is yours, so pass the handle explicitly. AsyncLocalStorage is the route-handler tool, for the one place you cannot add a parameter.

Step back from individual it blocks to the file as a unit. The shape is fixed and worth committing to memory: one action gets one test file, colocated with it, with one describe and one it per branch.

The file is createInvoice.int.test.ts, sitting next to createInvoice.ts. The .int.test.ts suffix does real work: it routes the file into the integration project, the one with the real test Postgres and the rollback wrapper, rather than the fast unit lane. Here is where it lives among the primitives it composes.

  • Directorysrc/
    • Directoryserver/
      • Directoryactions/
        • create-invoice.ts the action under test
        • create-invoice.int.test.ts the test file
    • Directorytest/
      • Directoryfixtures/
        • auth.ts signedInAs, anonymous
      • Directorydb/
        • with-rollback.ts withRollback, tx
      • Directorymsw/
        • server.ts the MSW server + handlers

The discipline that makes the file trustworthy is enumeration: given an action, list the branches it owes and make sure each one has its it. For createInvoice the checklist is short and complete: the happy path, validation failure, the unauthenticated redirect, insufficient role, and (only for an action that calls a third party) the outbound call. Six to ten it blocks is a healthy file. When you open an action test to review it, run that list against the describe block and look for the gap. A missing branch is a behavior nobody is watching, and finding it is the same audit habit you learned for unit tests in the previous chapter.

A handful of watch-outs live at this file level, each paired with the mistake it prevents:

  • Import the action directly, never through a barrel. A src/server/actions/index.ts re-export drags the whole Next.js runtime into the test bundle. Import from the action’s own file.
  • Every act is awaited. An un-awaited action call races the rollback: the transaction can unwind before the action’s promise settles, and the test passes or fails at random. The await is not optional.
  • Call signedInAs inside each it, never once for the file. Reusing a single context across two tests shares mutable user state, so one test’s mutation bleeds into the next. Use a fresh identity per test.
  • Thread tx, never mock db. Mocking the database defeats the entire point of an integration test, and worse, a query that runs on the global db instead of tx commits real rows that the rollback never touches. That is the silent-commit bug from the start of the chapter, and the { db: tx } handle is what avoids it.

Here is one closing technique, because actions rarely live alone. Sometimes the thing you want to test is a workflow: two actions in sequence, where the second depends on what the first wrote. Because both run against the same tx, the second sees the first’s uncommitted writes, and you can assert the combined end state.

invoice-workflow.int.test.ts
it('creates then pays an invoice in one transaction', withRollback(async ({ tx }) => {
const ctx = await signedInAs({ role: 'admin', plan: 'pro' }, tx);
const created = await createInvoice(
{ amount: 4200, currency: 'eur' },
{ db: tx },
);
const invoiceId = created.data.id;
const paid = await markInvoicePaid({ id: invoiceId }, { db: tx });
expect(paid).toBeOkResult();
const [row] = await tx
.select()
.from(invoices)
.where(eq(invoices.id, invoiceId));
expect(row).toMatchObject({ status: 'paid' });
}));

markInvoicePaid finds the invoice createInvoice just wrote and flips its status, and it only finds it because the two actions share the same transaction. This is how you test a multi-step flow without giving up any isolation: every write still lives in tx, and the whole sequence rolls back when the test ends. Keep these for genuine workflows; most of the time, one action and one test is the right grain.

Now write the assertions yourself. The transferable skill in this whole lesson is choosing the right assertion for each branch, and the branch that needs the most practice is the unauthenticated one, because it is the one your instinct gets wrong.

A real .int.test.ts cannot run in your browser, since there is no Vitest, no Postgres, and no Stripe in the sandbox. So this exercise runs against lightweight shims that mimic the wrapper’s behavior: a fake db you can read back, a stubbed signedInAs/anonymous returning canned contexts, and an in-memory createInvoice double that runs the same parse → authorize → body → return order the real wrapper does, including throwing a NEXT_REDIRECT error when there is no session. The shims are close enough to the real thing that the assertions you write here are the assertions you would write against it.

A happy-path test is written and passing. Fill in the three failure branches.

The happy-path test is written and green. Fill the three empty it blocks below it — one per failure branch. Assert on the Result code (result.error.code), never the user message, and check the fake db wrote zero rows. The unauthenticated path THROWS: use expect(() => createInvoice(...)).toThrow('NEXT_REDIRECT'), not an err assertion. resetDb() runs before each block, so db.rows starts empty every time.

    Reveal the three filled branches
    test('rejects invalid input', () => {
    resetDb();
    signedInAs({ role: 'admin', plan: 'pro' });
    const result = createInvoice({ amount: -1, currency: 'xyz' });
    expect(result.ok).toBe(false);
    expect(result.error.code).toBe('validation');
    expect(db.rows).toHaveLength(0);
    });
    test('redirects when signed out', () => {
    resetDb();
    anonymous();
    expect(() => createInvoice({ amount: 4200, currency: 'eur' }))
    .toThrow('NEXT_REDIRECT');
    expect(db.rows).toHaveLength(0);
    });
    test('refuses an underprivileged caller', () => {
    resetDb();
    signedInAs({ role: 'member', plan: 'pro' });
    const result = archiveReport({ amount: 4200, currency: 'eur' });
    expect(result.ok).toBe(false);
    expect(result.error.code).toBe('forbidden');
    expect(db.rows).toHaveLength(0);
    });
    • Validation asserts result.error.code === 'validation' (the code, never the localizable field message) and proves the short-circuit with zero rows, since the parse gate returned before the body ever ran.
    • Unauthenticated asserts the throw, not a Result: a session-less caller hits redirect(), which works by throwing NEXT_REDIRECT, so there is no err to return. Wrap the call in a function and use toThrow.
    • Forbidden asserts result.error.code === 'forbidden' returned in place: the authenticated-but-underprivileged caller gets a value the UI can render, and the row is never written.

    The action-test shape is now your default for the most common file in the integration suite: arrange identity with a fixture, act through the real wrapper against tx, and assert on three independent axes, namely the returned Result, the rows you read back through tx, and the cache spies. Keep one branch at the front of your mind, because instinct gets it wrong: the unauthenticated path throws a redirect rather than returning an error, so assert the throw.

    Every one of these tests leans on resets you have not yet had to think about: the mock history that clearAllMocks wipes, the MSW handlers that resetHandlers strips, and the transaction that rolls back. When one of those resets is missing, the suite goes flaky: it passes alone and fails in a crowd. The next lesson turns that into a taxonomy, where every flake has a named cause and a structural fix. The two seams these action tests don’t cover, the form component that calls the action through useActionState and the browser clicking through the whole flow end to end, are the subjects of the chapters just after, the other two sides of the same action.