Server Actions Are Plain Async Functions: Call One with a FormData Object
A route handler test still constructs a Request. A server action needs less than that: it is an async function whose argument is a FormData — a standard global in Node 18+ — and whose return value must be serializable across the React server boundary. Nothing about that signature requires React, a browser, or a running server, so the test is almost anticlimactic:
// app/items/actions.ts
'use server'
import { z } from 'zod'
const schema = z.object({
title: z.string().trim().min(3, 'Title is too short'),
qty: z.coerce.number().int().positive(),
})
export type AddState = { ok: boolean; id?: string; errors?: Record<string, string> }
export async function addItem(_prev: AddState, formData: FormData): Promise<AddState> {
const parsed = schema.safeParse(Object.fromEntries(formData))
if (!parsed.success) {
return { ok: false, errors: Object.fromEntries(
parsed.error.issues.map((i) => [i.path[0], i.message]),
) }
}
const id = await createItem(parsed.data)
revalidatePath('/items')
return { ok: true, id }
}
import { it, expect, vi } from 'vitest'
import { addItem } from './actions'
it('creates an item and returns its id', async () => {
vi.mocked(createItem).mockResolvedValue('item_9')
const fd = new FormData()
fd.set('title', ' Slate Mug ')
fd.set('qty', '3')
expect(await addItem({ ok: false }, fd)).toEqual({ ok: true, id: 'item_9' })
})
Why this works is worth stating precisely, because it explains both the power and the trap. The 'use server' directive is a build-time marker: the bundler extracts the export into an RPC endpoint for the browser to call. During vitest run no bundler runs, so the export is exactly what it looks like — an async function you can import and await. The directive changes how production consumes the module, not what the module is.
That also reveals the trap. Serialization is enforced at the RPC boundary, not inside your test process. An action that returns { ok: true, createdAt: new Date() } passes a direct test and then throws in production, because Date crosses the wire as {} — or the reverse, depending on the path. A direct test cannot see that; you cover it once at a higher layer (see End-to-End Tests with Playwright) and, better, keep the returned state to strings, numbers, booleans, and plain objects by habit.
FormData mirrors what a real form posts, including the details people forget:
const fd = new FormData()
fd.set('title', 'Slate Mug') // text fields — strings, even numeric ones
fd.set('qty', '3') // the server must coerce '3' → 3
fd.append('tags', 'ceramic') // repeated keys need append(), not set()
fd.append('tags', 'gift')
Because inputs are always strings on the wire, z.coerce.number() in the schema is not optional — it is the difference between the test passing and the deployed action rejecting qty: '3'. Object.fromEntries(formData) collapses repeated keys, so multi-value fields must be read with formData.getAll('tags'); a test with a single tag will not surface that.
Two escape hatches behave exactly as they do in route handlers: redirect('/items') and notFound() work by throwing, so they appear in a test as a rejection, not a returned state:
await expect(publishItem(validForm()))
.rejects.toMatchObject({ digest: expect.stringContaining('NEXT_REDIRECT') })
Keep the two categories separate in your head — thrown control flow (redirect, notFound) versus returned data (validation errors, success payloads) — because only the second shows up in the state you assert, and mixing them up is why an action test suddenly reports an unhandled error.
State-first actions (the useActionState shape, _prev plus formData) take one extra line of setup: pass a previous state explicitly. Call the previous-state argument what it is — the initial state the form would render with — and the test reads like the reducer it is driving.
Stubbing the Database and Session to Cover Every Action Branch
The returned state is the action's public API, so the tests should walk every branch a form can produce. Four branches exist in almost every create-style action, and each one maps to input you can construct:
| Branch | Input | Expected state | Side effect |
|---|---|---|---|
| Valid, first-time | title: 'Slate Mug', qty: '3' |
{ ok: true, id } |
insert called once, path revalidated |
| Validation failure | title: 'x' (too short) |
{ ok: false, errors.title } |
insert not called |
| Unauthenticated | valid fields, no session | { ok: false, code: 'unauthorized' } |
insert not called |
| Conflict | valid fields, duplicate title | { ok: false, errors.title } |
maps the DB's unique violation |
Each row needs exactly two doubles: the database module and the session lookup.
const { createItem, getSession } = vi.hoisted(() => ({
createItem: vi.fn(),
getSession: vi.fn(),
}))
vi.mock('@/lib/db', () => ({ createItem }))
vi.mock('@/lib/auth', () => ({ getSession }))
vi.mock('next/cache', () => ({ revalidatePath: vi.fn() }))
import { revalidatePath } from 'next/cache'
import { addItem } from './actions'
afterEach(() => vi.clearAllMocks())
it('rejects a title under three characters without touching the database', async () => {
getSession.mockResolvedValue({ user: { id: 'u1' } })
const fd = new FormData()
fd.set('title', 'x')
fd.set('qty', '1')
const state = await addItem({ ok: false }, fd)
expect(state.errors).toEqual({ title: 'Title is too short' })
expect(createItem).not.toHaveBeenCalled()
expect(revalidatePath).not.toHaveBeenCalled()
})
it('rejects anonymous callers before any insert', async () => {
getSession.mockResolvedValue(null)
const state = await addItem({ ok: false }, validForm())
expect(state).toMatchObject({ code: 'unauthorized' })
expect(createItem).not.toHaveBeenCalled()
})
Three details carry the value. The negative assertions — expect(createItem).not.toHaveBeenCalled() — are what make the validation test meaningful: without them, an action that skips validation and fails later at the database would still "pass" a test that only checked the returned error shape. The vi.hoisted factory exists because vi.mock runs above your imports; a top-level const referenced by the factory throws Cannot access 'createItem' before initialization. And mocking next/cache lets you assert revalidatePath('/items') — revalidation is observable behavior, not an implementation detail, and forgetting it is the classic "list doesn't refresh after add" bug.
Where do you stop stubbing? At the I/O edge only: the DB client, the session lookup, the mailer. Mocking your own validation helpers or the schema would assert the mock's behavior instead of the action's — the same rule from Testing Route Handlers, one layer over. The schema must run for real, because schema-plus-branch is precisely what this test exists to protect.
One failure branch deserves special handling: database errors that are not validation errors. A unique-constraint violation arrives as an exception (P2002 from Prisma, a 23505 code from Postgres) and your action catches it to produce the friendly conflict message. Test it by rejecting the mock:
it('maps a unique-violation to a field error', async () => {
createItem.mockRejectedValue(Object.assign(new Error('duplicate'), { code: 'P2002' }))
const state = await addItem({ ok: false }, validForm())
expect(state.errors.title).toMatch(/already exists/i)
})
Branches like this one — exception → user-facing message — never appear in a happy-path test, and a direct invocation makes them a five-line case instead of a staging reproduction.
One assertion ties the form to the storage layer together: the payload handed to the database.
expect(createItem).toHaveBeenCalledWith({ title: 'Slate Mug', qty: 3 })
That single line proves three transformations at once — the trim() ran, the string '3' was coerced to the number 3, and stray form fields never reached the insert. The returned state says nothing about any of them: { ok: true, id } looks identical whether the row was written with padded whitespace or the wrong type, which is exactly how a "works in the UI, wrong in the database" bug survives every shallow test.
What Direct Invocation Misses: Form Wiring, Serialization, and Origin Checks
Direct invocation proves the action's logic. It says nothing about the machinery around it, and the machinery is where several production failures live. Being explicit about the gap is what keeps the test suite honest.
| Risk | Direct invocation | Where it gets covered |
|---|---|---|
| Validation and branching | ✓ | here |
| DB/session stubs, error mapping | ✓ | here |
| Returned state shape | ✓ (partially — see serialization) | here + one E2E |
<form action={...}> / useActionState wiring |
✗ | component test |
| Pending/disabled UI while the action runs | ✗ | component test |
| Serialization across the RSC boundary | ✗ | E2E |
| Middleware auth before the action runs | ✗ | E2E |
| Origin/host check rejecting cross-site posts | ✗ | E2E or an HTTP-level test |
Wiring. A common production bug: the action is correct, but the form never passes formData — someone wrote onClick={() => addItem()} instead of binding the form action, so every submit posts an empty FormData and validation fails. A direct test with a hand-built FormData cannot see it. The component-level version closes the gap while keeping the action mocked:
const action = vi.fn().mockResolvedValue({ ok: false, errors: { title: 'too short' } })
render(
<ActionStateForm action={action} />, // <form action={formAction}> via useActionState
)
await user.type(screen.getByLabelText('Title'), 'x')
await user.click(screen.getByRole('button', { name: 'Add item' }))
expect(action).toHaveBeenCalledWith(expect.anything(), expect.any(FormData))
expect(await screen.findByRole('alert')).toHaveTextContent(/too short/i)
That asserts the plumbing — formData arrives, errors from the returned state render into the alert, the button disables while pending — all without a server. If the disabled state comes from useFormStatus, drive it the same way the component layer pins loading states: an action stub that never resolves holds the form in its pending branch deterministically.
Progressive enhancement. Next wires server actions into real HTML form submissions: with JavaScript disabled, the form still posts and the action still runs on the server. A jsdom component test never exercises that path, and neither does a direct invocation. One Playwright test does:
test('adding an item works with JavaScript disabled', async ({ browser }) => {
const context = await browser.newContext({
javaScriptEnabled: false,
storageState: '.auth/user.json',
})
const page = await context.newPage()
await page.goto('/items/new')
await page.getByLabel('Title').fill('Slate Mug')
await page.getByRole('button', { name: 'Add item' }).click()
await expect(page.getByText('Slate Mug')).toBeVisible()
await context.close()
})
That protects the failure mode where a onClick handler quietly replaces the form action and the no-JS path dies without anyone noticing.
Serialization. The direct test runs in plain Node, so new Date(), Map, and class instances sail through where the RPC boundary would reject them. The cheapest insurance is a single test that round-trips the state through structuredClone, the same mechanism React uses:
const state = await addItem({ ok: false }, validForm())
expect(() => structuredClone(state)).not.toThrow()
It does not replace one real end-to-end submit, but it catches the Date-in-state mistake at test speed.
Auth and origin checks. Server Actions in production verify the request's Origin against the Host header and reject cross-site posts; middleware may gate the route before the action is ever dispatched. Neither layer exists in a direct call — by design, since the action is just a function there. That is the argument for one end-to-end test that starts at the browser: log in, submit the real form over the wire, and observe the revalidated list. End-to-End Tests with Playwright builds exactly that, webServer config and session reuse included.
The practical allocation: direct tests for every branch (fast, dozens of them), a component test for the wiring, and one E2E per critical action for serialization, origin checks, and middleware. Teams that skip the middle layer end up debugging useActionState regressions in staging; teams that skip the last one discover the origin check when a customer's embedded iframe breaks checkout.
Quiz
1. A test imports an action from a `'use server'` file and calls it directly with `new FormData()`. Why does this work even though the action is exposed to the browser as an RPC endpoint?
2. Your direct action test passes, but the deployed action throws when returning its result. What is the most likely cause?
3. An action's validation branch test asserts the returned error object. Why should the test also assert `expect(createItem).not.toHaveBeenCalled()`?
4. Which of these is NOT observable from a direct invocation of a server action?
Flashcards
Question
What is a server action at test time, and what does 'use server' actually do to it?
Click to reveal answer
Answer
At test time it is an ordinary async function taking FormData (and a previous state for useActionState-shaped actions) and returning a serializable value. 'use server' is a build-time directive that makes the bundler extract the export as an RPC endpoint — during vitest run that extraction never happens, so you can import and await it directly.
Question
How must FormData be built to mirror what a browser actually posts?
Click to reveal answer
Answer
Every value is a string (`fd.set('qty', '3')`), so the schema must coerce numeric fields; repeated keys use `fd.append()` and must be read with `formData.getAll()`, since `Object.fromEntries` keeps only the last. This mirrors the wire format exactly, which is why coercion is part of the tested contract.
Question
Which modules should an action test stub, and which must stay real?
Click to reveal answer
Answer
Stub only the I/O edge: the database client, session lookup, and next/cache's revalidatePath (assert it was called with the right path). Keep the schema, normalization, and branching real — mocking those would assert the mock instead of the action. Create doubles with vi.hoisted because vi.mock factories are hoisted above imports.
Question
Name two failure modes a direct action test can never see.
Click to reveal answer
Answer
Serialization across the RSC boundary (a Date in the returned state passes locally, throws in production RPC — catch it with a structuredClone round-trip) and the HTTP-layer guards: the origin/host check rejecting cross-site posts and middleware gating the route. Both require the real pipeline, i.e. an end-to-end test.
Question
What is the right test allocation for one critical server action?
Click to reveal answer
Answer
Direct tests for every branch (success, validation failure, unauthenticated, conflict, thrown DB error — dozens, milliseconds each); one component test wiring the action to the form to prove formData flows and pending/error UI works; one Playwright test per critical action covering serialization, origin checks, and middleware over the real wire.
Revision Notes
Key Takeaways
- 1. Server actions are importable async functions — build FormData like a browser would (strings, append for repeats), pass the previous state, and await the returned state object
- 2. 'use server' is build-time only: your test never runs the bundler's RPC extraction, which is also why serialization bugs (Date, Map, class instances) slip past direct tests
- 3. Cover every branch with table-driven cases: valid → ok+id, invalid → field errors with no insert, anonymous → unauthorized, duplicate → mapped conflict, thrown DB error → friendly message
- 4. Stub only the I/O edge — db, session, revalidatePath — using vi.hoisted factories; assert negative calls (`not.toHaveBeenCalled()`) so a skipped validation cannot pass by accident
- 5. Direct invocation misses form wiring, RSC serialization, middleware, and origin checks: component tests cover the plumbing, one E2E per critical action covers the wire
Interview Tips
- • "How do you test a server action?" — it is a plain async function: import it, build FormData, await the result, assert the returned state; then name what that skips (serialization, origin check) so you show you know the boundary of the technique
- • "Why can a passing action test still fail in production?" — the RPC boundary enforces serializability and runs behind middleware and an origin check that a direct call never traverses; your answer names Date/Map as the usual culprits and structuredClone as the cheap guard
- • "Show me how you'd test validation" — the happy state plus `expect(createItem).not.toHaveBeenCalled()`; explaining why the negative assertion is the point (the branch, not the message) separates senior answers from junior ones
- • "Where do you stop mocking?" — at the I/O edge; schema and branch logic run for real. Mock your own helpers and the test starts asserting the mock, which is the fastest way to build a suite that passes while the app is broken
Cheat Sheet
Testing Server Actions in Isolation — Cheat Sheet
Direct invocation
const fd = new FormData()
fd.set('title', 'Slate Mug') // always strings on the wire
fd.set('qty', '3') // schema must coerce
fd.append('tags', 'ceramic') // repeats need append + getAll
const state = await addItem({ ok: false }, fd) // useActionState shape: (prev, formData)
expect(state).toEqual({ ok: true, id: 'item_9' })
'use server' = build-time RPC extraction — absent in vitest run, so the export is a normal async function.
Branch table to cover
| Input | Expected state | Also assert |
|---|---|---|
| valid fields | { ok: true, id } |
insert once, revalidatePath('/items') |
| too-short title | { errors.title } |
createItem not called |
| no session | { code: 'unauthorized' } |
createItem not called |
duplicate row (mock rejects P2002) |
friendly field error | no stack in state |
Stubbing rules
const { createItem, getSession } = vi.hoisted(() => ({ createItem: vi.fn(), getSession: vi.fn() }))
vi.mock('@/lib/db', () => ({ createItem }))
vi.mock('@/lib/auth', () => ({ getSession }))
vi.mock('next/cache', () => ({ revalidatePath: vi.fn() }))
afterEach(() => vi.clearAllMocks())
- Stub the I/O edge only — schema, normalization, branching run real.
vi.hoistedis mandatory: mock factories execute above your imports.
Gaps of direct invocation → where they're covered
| Gap | Layer |
|---|---|
| form wiring, pending/error UI | component test (action mocked) |
| non-serializable returned state | structuredClone(state) + one E2E |
| middleware, origin/host check, revalidate over the wire | End-to-End Tests with Playwright |