How to unit-test a form and serverless handler with Vitest
A contact form has three moving parts worth testing: the validation schema, the React component, and the serverless handler that sends the email. I test all three with Vitest, and none of the tests touch the network or send a real email. Here’s how each layer is tested, using the actual tests from this repo.
The Vitest config
Vitest reads its config from the same vite.config.ts as the app, so aliases
and plugins are shared for free. The test block is what matters:
// vite.config.ts
test: {
environment: "jsdom",
globals: true,
setupFiles: ["./src/test/setup.ts"],
include: ["src/**/*.{test,spec}.{ts,tsx}", "api/**/*.{test,spec}.ts"],
css: false,
},environment: "jsdom"gives tests a fake browser DOM, so React components can render and be queried without a real browser.globals: truemakesdescribe,it,expect, andviavailable without importing them (though the files import them anyway for clarity).includetells Vitest to pick up bothsrc/tests and theapi/handler tests.css: falseskips CSS processing — irrelevant to logic tests and faster.
Testing the Zod schema
The schema is pure logic, so its tests are the simplest: feed it input, check
safeParse().success. (This is the same schema shared between the client and server.)
// src/lib/contact-schema.test.ts
import { contactSchema } from "./contact-schema"
const valid = {
name: "Ada Lovelace",
email: "ada@example.com",
message: "I'd love to talk about a full-stack role at our company.",
company: "",
}
it("accepts a valid submission", () => {
expect(contactSchema.safeParse(valid).success).toBe(true)
})
it("rejects a filled honeypot (company) field", () => {
const r = contactSchema.safeParse({ ...valid, company: "spam-bot" })
expect(r.success).toBe(false)
})
it("trims whitespace around fields", () => {
const r = contactSchema.safeParse({ ...valid, name: " Grace Hopper " })
expect(r.success && r.data.name).toBe("Grace Hopper")
})Notice the pattern: start from one valid object, then spread it with a single
bad field ({ ...valid, name: "A" }) to test one rule at a time. The trim test
reaches into r.data to confirm the transformed value, not just pass/fail.
Testing the React form
For the component I use Testing Library, which queries the DOM the way a user
would — by label text and button role — rather than by CSS class. I stub the
global fetch so no request leaves the test:
// src/components/portfolio/ContactForm.test.tsx
import { render, screen, waitFor } from "@testing-library/react"
import userEvent from "@testing-library/user-event"
it("submits valid data and shows a success state", async () => {
const fetchSpy = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ ok: true }),
})
vi.stubGlobal("fetch", fetchSpy)
const user = userEvent.setup()
render(<ContactForm />)
await user.type(screen.getByLabelText(/name/i), "Ada Lovelace")
await user.type(screen.getByLabelText(/email/i), "ada@example.com")
await user.type(
screen.getByLabelText(/message/i),
"I would love to discuss a full-stack opportunity.",
)
await user.click(screen.getByRole("button", { name: /send message/i }))
await waitFor(() => expect(fetchSpy).toHaveBeenCalledTimes(1))
const [url, init] = fetchSpy.mock.calls[0]
expect(url).toBe("/api/contact")
expect(init.method).toBe("POST")
expect(await screen.findByText(/message sent/i)).toBeInTheDocument()
})getByLabelText only works because the fields are properly labelled — the test
doubles as an accessibility check, which is exactly what building an accessible form with react-hook-form and Zod sets up. A companion test submits an empty form and
asserts that error alerts appear and that fetch was never called, proving
client validation blocks a bad submission before it hits the network.
Testing the serverless handler
The handler calls new Resend(apiKey) and sends a real email — obviously not
something a test should do. The fix is to mock the whole resend module. The
important detail: the mock must be a class, because the handler uses new:
// api/contact.test.ts
const sendMock = vi.fn().mockResolvedValue({ data: { id: "x" }, error: null })
vi.mock("resend", () => ({
// Must be a real constructor — the handler calls `new Resend(apiKey)`.
Resend: class {
emails = { send: sendMock }
},
}))
import handler from "./contact"There are no real req/res objects in a unit test, so I build fakes. The res
fake records whatever the handler passes it:
// api/contact.test.ts
function makeRes() {
const capture = {}
const res = {
setHeader: vi.fn(),
status(code) { capture.statusCode = code; return res },
json(payload) { capture.body = payload; return res },
}
return { res, capture }
}Both status and json return res so the real handler’s chained
res.status(200).json(...) works. Then the tests assert on the captured values:
// api/contact.test.ts
it("sends the email and returns 200 for valid input", async () => {
const { res, capture } = makeRes()
await handler(makeReq("POST", validBody), res)
expect(capture.statusCode).toBe(200)
expect(capture.body).toEqual({ ok: true })
expect(sendMock).toHaveBeenCalledTimes(1)
})
it("returns 400 on invalid input and does not send mail", async () => {
const { res, capture } = makeRes()
await handler(makeReq("POST", { name: "A" }), res)
expect(capture.statusCode).toBe(400)
expect(sendMock).not.toHaveBeenCalled()
})The env vars (RESEND_API_KEY, CONTACT_TO_EMAIL) are set in a beforeEach,
and one test deletes the key to prove the handler returns 500 when
misconfigured — without ever sending mail.
What to remember
- Configure Vitest with
environment: "jsdom"andglobals: true; pointincludeat bothsrc/andapi/. - Test the schema directly with
safeParse— spread one valid object and mutate one field per case. - Test the form with Testing Library queries (
getByLabelText,getByRole) and stubfetchso nothing leaves the test. - Mock
resendas a class since the handler usesnew Resend(...), and fakereq/resto capture status and body. - Assert both the happy path and the failure paths (400, 500, no mail sent).
Related reading: the “rejects a filled honeypot” case above comes from the trap described in stopping form spam with a honeypot field.