ehsan.blog
~/blog/how-to-unit-test-a-form-and-serverless-handler-with-vitest — zsh
cat how-to-unit-test-a-form-and-serverless-handler-with-vitest.md

How to unit-test a form and serverless handler with Vitest

·5 min read

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:

ts
// 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: true makes describe, it, expect, and vi available without importing them (though the files import them anyway for clarity).
  • include tells Vitest to pick up both src/ tests and the api/ handler tests.
  • css: false skips 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.)

ts
// 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:

tsx
// 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:

ts
// 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:

ts
// 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:

ts
// 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" and globals: true; point include at both src/ and api/.
  • 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 stub fetch so nothing leaves the test.
  • Mock resend as a class since the handler uses new Resend(...), and fake req/res to 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.

ls ./related
cat ./comments

Comments are not configured yet. Enable GitHub Discussions and paste the giscus repo-id / category-id into src/consts.ts.