Testing Zod Schemas with Vitest

A Zod schema is often the single source of truth for a form’s rules: the client uses it for instant feedback and the server uses it as the final gate. That makes it the most valuable validation code to test, and the most commonly under-tested, because “it’s just a declaration” feels self-evidently correct. It is not. Coercion turns an empty string into 0, a missing checkbox key fails with a message nobody wrote, a superRefine attaches its issue to the wrong path, and a transform changes the output shape the server relies on. This guide builds a Vitest suite for a Zod form schema that feeds it the data the browser actually sends, asserts on per-field messages, covers refinements and cross-field rules, and runs unchanged for client and server. It builds on the approach in unit testing validation logic.

The failure this guide prevents: a schema that looks right in code review but accepts "" as quantity zero, or reports a password mismatch on the wrong field.

Anatomy of a schema test A test builds FormData from raw strings, converts it to an object, runs safeParse, flattens the errors and asserts on the exact message for a specific field. FormData fixture raw strings, missing keys Object. fromEntries as the handler does safeParse never throws flatten() field → messages Assert message exact text per field
Start from the wire format and end at the exact message — the two ends users and servers actually see.

Prerequisites

Requirement Minimum version Notes
Zod 3.23+ (or 4.x) safeParse, flatten, superRefine
Vitest 2.x+ it.each, expectTypeOf
TypeScript 5.0+ Typed fixtures
Environment node or jsdom FormData exists in Node 18+

The Schema Under Test

// schemas/order.ts
import { z } from "zod";

export const orderSchema = z
  .object({
    email: z.string().trim().min(1, "Enter your email address.").email("Enter an email address like name@example.com."),
    quantity: z
      .string()
      .trim()
      .min(1, "Enter a quantity.")
      .pipe(z.coerce.number().int("Enter a whole number.").min(1, "Order at least 1.").max(20, "You can order up to 20.")),
    giftWrap: z.literal("on").optional().transform((v) => v === "on"),
    deliveryDate: z.string().min(1, "Choose a delivery date."),
    promo: z.string().trim().toUpperCase().optional(),
  })
  .superRefine((d, ctx) => {
    if (d.promo === "GIFT" && !d.giftWrap) {
      ctx.addIssue({ code: "custom", path: ["giftWrap"], message: "The GIFT code needs gift wrapping." });
    }
  });

Note the min(1) before z.coerce.number(). Without it, z.coerce.number() turns "" into 0, and a blank quantity produces “Order at least 1.” instead of “Enter a quantity.” — the kind of subtle behaviour tests should pin down.

Step 1: Build Inputs the Way the Browser Does

// test/fd.ts
export function fromForm(entries: Array<[string, string]>): Record<string, FormDataEntryValue> {
  const f = new FormData();
  for (const [k, v] of entries) f.append(k, v);
  return Object.fromEntries(f);
}

export const validOrder: Array<[string, string]> = [
  ["email", "ada@example.com"],
  ["quantity", "2"],
  ["deliveryDate", "2026-10-01"],
];

Every value is a string, unchecked checkboxes are simply absent, and empty fields are "". Tests built from literal objects like { quantity: 2 } skip the coercion path entirely and pass while production fails.

Step 2: Assert Per-Field Messages

import { describe, it, expect } from "vitest";
import { orderSchema } from "../schemas/order";
import { fromForm, validOrder } from "./fd";

const errorsFor = (entries: Array<[string, string]>) => {
  const r = orderSchema.safeParse(fromForm(entries));
  return r.success ? {} : r.error.flatten().fieldErrors;
};

const withField = (name: string, value: string) =>
  [...validOrder.filter(([k]) => k !== name), [name, value]] as Array<[string, string]>;

describe("orderSchema quantity", () => {
  it.each([
    ["", "Enter a quantity."],
    ["   ", "Enter a quantity."],
    ["0", "Order at least 1."],
    ["21", "You can order up to 20."],
    ["2.5", "Enter a whole number."],
  ])("%j → %s", (value, message) => {
    expect(errorsFor(withField("quantity", value)).quantity).toEqual([message]);
  });

  it("accepts the boundaries", () => {
    expect(errorsFor(withField("quantity", "1")).quantity).toBeUndefined();
    expect(errorsFor(withField("quantity", "20")).quantity).toBeUndefined();
  });
});

The withField helper starts from a valid fixture and changes one field, so each test isolates one rule. toEqual([message]) also checks that exactly one message is reported, catching schemas that pile several issues onto one field.

Step 3: Test Refinements and Their Paths

describe("cross-field rules", () => {
  it("puts the GIFT error on giftWrap", () => {
    const errors = errorsFor([...validOrder, ["promo", "gift"]]);
    expect(errors.giftWrap).toEqual(["The GIFT code needs gift wrapping."]);
    expect(errors.promo).toBeUndefined();
  });

  it("accepts GIFT when wrapping is checked", () => {
    expect(errorsFor([...validOrder, ["promo", "GIFT"], ["giftWrap", "on"]])).toEqual({});
  });
});

Asserting the path matters because the path decides which field shows the message and receives aria-invalid. A refinement that reports on promo would put the error next to the wrong input. Note also that superRefine on the object only runs when every field passes, so a form with an invalid email will not show the GIFT error yet — test that interaction explicitly if your UI relies on seeing both.

How a refinement reaches the right field The test sends FormData to the schema, field parsers run first, the object-level refinement runs only if they pass, and its issue is attached to the giftWrap path which flatten reports under that field. Test Field parsers superRefine flatten email, quantity, promo=GIFT all fields valid issue at path giftWrap fieldErrors.giftWrap promo has no error
Object-level refinements run after field parsing succeeds, and the path you give the issue decides where the message appears.

Step 4: Test the Output Shape

import { expectTypeOf } from "vitest";

it("produces the shape the server expects", () => {
  const r = orderSchema.parse(fromForm([...validOrder, ["giftWrap", "on"], ["promo", " spring "]]));
  expect(r).toEqual({
    email: "ada@example.com",
    quantity: 2,
    giftWrap: true,
    deliveryDate: "2026-10-01",
    promo: "SPRING",
  });
  expectTypeOf(r.quantity).toEqualTypeOf<number>();
});

it("defaults giftWrap to false when the box is unchecked", () => {
  expect(orderSchema.parse(fromForm(validOrder)).giftWrap).toBe(false);
});

Transforms are part of the contract. The server handler trusts that quantity is a number and promo is upper-cased; tests on the output shape catch a refactor that drops a transform. expectTypeOf adds a compile-time check that the inferred type has not drifted.

Step 5: Run the Same Suite for Client and Server

If the schema lives in a shared package, keep its tests in that package and run them once — they cover both sides because it is the same code. What differs per side is the wiring: the client maps fieldErrors onto inputs, the server returns them in a response. Test those adapters separately with the flattened error object as input. The architecture is covered in shared client–server schemas.

Edge Cases Worth a Test

  • Whitespace-only values. " " should behave like empty. .trim() before .min(1) handles it; test it.
  • Repeated keys. Object.fromEntries keeps only the last value of a multi-select or checkbox group. If a field can repeat, build the object with getAll and test that path.
  • Unicode e-mail and names. Zod’s email check rejects some internationalised addresses. Decide whether that is acceptable and pin the decision with a fixture.
  • Unknown keys. By default Zod strips unknown keys. If the server depends on rejecting them, use .strict() and test that an extra field fails.
  • Zod version upgrades. Default messages and some behaviour changed between Zod 3 and 4. Explicit messages everywhere plus message assertions make the upgrade a visible diff instead of silent change.
Weak versus strong schema tests Two columns contrasting weak schema test habits with the stronger equivalents this guide uses. Weak • literal objects with numbers • expect(success).toBe(false) • one big invalid fixture • ignores transformed output Strong ✓ FormData fixtures with strings ✓ exact message per field ✓ one field changed per test ✓ asserts output shape and types
The strong column costs little extra and catches coercion, path and message regressions the weak one misses.

Common Mistakes

Testing with already-typed data.

// Before: skips coercion entirely
orderSchema.safeParse({ email: "a@b.co", quantity: 2, deliveryDate: "2026-10-01" });
// After: the wire format
orderSchema.safeParse(fromForm([["email", "a@b.co"], ["quantity", "2"], ["deliveryDate", "2026-10-01"]]));

Using parse in failure tests. parse throws, so a test that expects failure has to catch and inspect a ZodError. safeParse returns a result object and keeps the assertion simple.

Snapshotting the whole error. A snapshot of error.issues includes internal codes and paths that change between Zod versions, producing noisy diffs that get approved without reading. Assert on the flattened messages you actually render.

Testing Async Refinements

Schemas sometimes include async refinements — “this username is not taken” — which require safeParseAsync. Inject the lookup into a schema factory so tests can supply a fake:

export const makeSignupSchema = (isTaken: (u: string) => Promise<boolean>) =>
  z.object({ username: z.string().min(3, "Use at least 3 characters.") }).superRefine(async (d, ctx) => {
    if (await isTaken(d.username)) ctx.addIssue({ code: "custom", path: ["username"], message: "That username is taken." });
  });

it("reports a taken username", async () => {
  const schema = makeSignupSchema(async (u) => u === "ada");
  const r = await schema.safeParseAsync(fromForm([["username", "ada"]]));
  expect(!r.success && r.error.flatten().fieldErrors.username).toEqual(["That username is taken."]);
});

Calling safeParse (synchronous) on a schema with async refinements throws, which is a useful test in itself: it guards against a caller forgetting the await.

Keeping Fixtures in Step with the UI

The field names in fixtures must match the name attributes in the form, or tests pass against a schema the form never feeds. A small test that renders the form (or parses its template) and compares its field names to the schema’s keys — Object.keys(orderSchema._def.schema.shape) for a refined object, or keep an exported list of names — closes that gap. Rather than reaching into Zod internals to find the keys of a refined object, define the base object separately, export it, and build the refined schema from it; the test then reads Object.keys(orderBase.shape). It fails the moment someone renames quantity to qty in the markup without updating the schema.

export const orderBase = z.object({ /* fields as above */ });
export const orderSchema = orderBase.superRefine(/* cross-field rules */);

it("form fields match the schema", () => {
  document.body.innerHTML = renderOrderForm();
  const names = [...document.querySelectorAll<HTMLInputElement>("form [name]")].map((el) => el.name);
  expect(new Set(names)).toEqual(new Set(Object.keys(orderBase.shape)));
});

Run this test in the jsdom environment (// @vitest-environment jsdom at the top of the file) and keep the schema tests themselves in Node, where they run faster.

Frequently Asked Questions

Should I test Zod schemas at all if Zod is already tested?

Yes. Zod tests its primitives; your tests cover how you composed them — coercion order, messages, refinement paths and transforms — which is where form bugs actually occur.

Why build fixtures from FormData?

Because that is what the schema receives in production: strings, empty strings and missing keys. Typed literal objects skip coercion and hide the bugs coercion causes.

How do I test cross-field rules in Zod?

Start from a valid fixture, change the fields involved, and assert both the message and the path it is reported on, since the path decides which input shows the error.

Can the same tests cover the server?

If the schema is shared, yes — the schema tests run once for both sides. Test the client and server adapters that consume the flattened errors separately.

← Back to Testing & Accessibility