Testing Zod Schemas: Unit Tests for Validators, Error Messages, and Transforms
Zod schemas are code — they contain business logic (required fields, format constraints, cross-field validation) that can break if changed carelessly. Testing Zod schemas means verifying that valid data passes, invalid data fails with the right error messages, and transforms produce the expected output types. This guide covers testing patterns for schemas from simple to complex.
Key Takeaways
Test valid AND invalid inputs. A schema test that only checks valid data catches half the cases. Always test what fails and why.
Use .safeParse() in tests, not .parse(). safeParse() returns { success, data, error } instead of throwing. It's easier to assert on the error structure without try/catch.
error.flatten() gives you a clean error map. The raw ZodError object is verbose. error.flatten().fieldErrors is a plain Record<string, string[]> — much easier to assert against.
Test refinements and superRefine separately. Custom validators (.refine(), .superRefine()) are the hardest part to test because they can have complex cross-field logic. Give them dedicated test cases.
Test transforms don't change the wrong fields. When a schema transforms input (e.g., string → Date, string → lowercase), test that untransformed fields are preserved exactly.
Why Test Zod Schemas?
Zod schemas encode business rules. A z.string().email() isn't just a type annotation — it's a validation gate that determines whether data enters your system. Schema changes break production data flows silently if untested.
Consider this schema:
const UserSchema = z.object({
email: z.string().email(),
age: z.number().int().min(18).max(120),
role: z.enum(['admin', 'editor', 'viewer']),
});If someone changes min(18) to min(0) to "fix" a bug, they've introduced a security issue. A test catches that.
Basic Schema Testing
Testing Valid Data
import { z } from 'zod';
import { UserSchema } from './schemas/user';
describe('UserSchema', () => {
describe('valid inputs', () => {
it('accepts a complete valid user', () => {
const result = UserSchema.safeParse({
email: 'alice@example.com',
age: 25,
role: 'admin',
});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.email).toBe('alice@example.com');
expect(result.data.age).toBe(25);
expect(result.data.role).toBe('admin');
}
});
});
});Testing Invalid Data
describe('invalid inputs', () => {
it('rejects invalid email format', () => {
const result = UserSchema.safeParse({
email: 'not-an-email',
age: 25,
role: 'admin',
});
expect(result.success).toBe(false);
if (!result.success) {
const errors = result.error.flatten().fieldErrors;
expect(errors.email).toBeDefined();
expect(errors.email?.[0]).toContain('email');
}
});
it('rejects age below 18', () => {
const result = UserSchema.safeParse({
email: 'alice@example.com',
age: 17,
role: 'admin',
});
expect(result.success).toBe(false);
if (!result.success) {
const errors = result.error.flatten().fieldErrors;
expect(errors.age).toBeDefined();
}
});
it('rejects unknown roles', () => {
const result = UserSchema.safeParse({
email: 'alice@example.com',
age: 25,
role: 'superadmin',
});
expect(result.success).toBe(false);
if (!result.success) {
const errors = result.error.flatten().fieldErrors;
expect(errors.role).toBeDefined();
}
});
});Using a Test Helper
Writing if (!result.success) checks repeatedly is verbose. A helper makes tests cleaner:
// test/helpers/zod.ts
import { z } from 'zod';
export function parseExpectSuccess<T extends z.ZodTypeAny>(
schema: T,
input: unknown
): z.output<T> {
const result = schema.safeParse(input);
if (!result.success) {
throw new Error(
`Expected parse to succeed, but got errors:\n${JSON.stringify(result.error.flatten(), null, 2)}`
);
}
return result.data;
}
export function parseExpectFailure<T extends z.ZodTypeAny>(
schema: T,
input: unknown
): z.ZodError {
const result = schema.safeParse(input);
if (result.success) {
throw new Error(
`Expected parse to fail, but got: ${JSON.stringify(result.data)}`
);
}
return result.error;
}Using the helpers:
import { parseExpectSuccess, parseExpectFailure } from '../helpers/zod';
it('accepts valid user', () => {
const user = parseExpectSuccess(UserSchema, {
email: 'alice@example.com',
age: 25,
role: 'admin',
});
expect(user.email).toBe('alice@example.com');
});
it('rejects invalid age', () => {
const error = parseExpectFailure(UserSchema, {
email: 'alice@example.com',
age: 15,
role: 'admin',
});
expect(error.flatten().fieldErrors.age).toBeDefined();
});Testing Custom Error Messages
Zod lets you customize error messages:
const ProductSchema = z.object({
sku: z.string().regex(/^[A-Z]{2}-\d{4}$/, {
message: 'SKU must be in format XX-0000 (two uppercase letters, dash, four digits)',
}),
price: z.number().positive({ message: 'Price must be greater than zero' }),
stock: z.number().int().min(0, { message: 'Stock cannot be negative' }),
});Test that your custom messages appear:
it('shows custom error for invalid SKU format', () => {
const result = ProductSchema.safeParse({
sku: 'invalid-sku',
price: 9.99,
stock: 10,
});
expect(result.success).toBe(false);
if (!result.success) {
const skuErrors = result.error.flatten().fieldErrors.sku;
expect(skuErrors?.[0]).toBe(
'SKU must be in format XX-0000 (two uppercase letters, dash, four digits)'
);
}
});Testing Refinements
.refine() adds custom validation logic:
const PasswordSchema = z.object({
password: z.string().min(8),
confirmPassword: z.string(),
}).refine(
(data) => data.password === data.confirmPassword,
{
message: 'Passwords must match',
path: ['confirmPassword'],
}
);Testing cross-field refinements:
describe('PasswordSchema', () => {
it('accepts matching passwords', () => {
const result = PasswordSchema.safeParse({
password: 'securePassword123',
confirmPassword: 'securePassword123',
});
expect(result.success).toBe(true);
});
it('rejects mismatched passwords', () => {
const result = PasswordSchema.safeParse({
password: 'securePassword123',
confirmPassword: 'differentPassword',
});
expect(result.success).toBe(false);
if (!result.success) {
// Refinement errors appear in fieldErrors for the specified path
const errors = result.error.flatten().fieldErrors;
expect(errors.confirmPassword?.[0]).toBe('Passwords must match');
}
});
it('still validates field-level constraints', () => {
const result = PasswordSchema.safeParse({
password: 'short', // too short
confirmPassword: 'short',
});
expect(result.success).toBe(false);
if (!result.success) {
const errors = result.error.flatten().fieldErrors;
expect(errors.password).toBeDefined();
}
});
});Testing Transforms
Transforms change the output type. Test both that the transform ran and that untransformed fields are unchanged:
const EventSchema = z.object({
name: z.string().trim().toLowerCase(),
startDate: z.string().datetime().transform(s => new Date(s)),
endDate: z.string().datetime().transform(s => new Date(s)),
tags: z.array(z.string()).transform(tags => tags.map(t => t.toLowerCase())),
});describe('EventSchema transforms', () => {
it('trims and lowercases the name', () => {
const result = EventSchema.safeParse({
name: ' Summer Conference ',
startDate: '2026-08-01T09:00:00.000Z',
endDate: '2026-08-03T17:00:00.000Z',
tags: ['JavaScript', 'TypeScript', 'Node.JS'],
});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.name).toBe('summer conference');
}
});
it('converts date strings to Date objects', () => {
const result = EventSchema.safeParse({
name: 'test event',
startDate: '2026-08-01T09:00:00.000Z',
endDate: '2026-08-03T17:00:00.000Z',
tags: [],
});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.startDate).toBeInstanceOf(Date);
expect(result.data.startDate.getFullYear()).toBe(2026);
}
});
it('lowercases all tags', () => {
const result = EventSchema.safeParse({
name: 'event',
startDate: '2026-08-01T09:00:00.000Z',
endDate: '2026-08-03T17:00:00.000Z',
tags: ['JavaScript', 'TypeScript'],
});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.tags).toEqual(['javascript', 'typescript']);
}
});
});Testing Discriminated Unions
Discriminated unions are common in API response schemas:
const ApiResponseSchema = z.discriminatedUnion('status', [
z.object({
status: z.literal('success'),
data: z.object({ id: z.number(), name: z.string() }),
}),
z.object({
status: z.literal('error'),
error: z.object({ code: z.string(), message: z.string() }),
}),
]);describe('ApiResponseSchema', () => {
it('parses a success response', () => {
const result = ApiResponseSchema.safeParse({
status: 'success',
data: { id: 1, name: 'Widget' },
});
expect(result.success).toBe(true);
if (result.success && result.data.status === 'success') {
expect(result.data.data.id).toBe(1);
}
});
it('parses an error response', () => {
const result = ApiResponseSchema.safeParse({
status: 'error',
error: { code: 'NOT_FOUND', message: 'Resource not found' },
});
expect(result.success).toBe(true);
if (result.success && result.data.status === 'error') {
expect(result.data.error.code).toBe('NOT_FOUND');
}
});
it('rejects an unknown status', () => {
const result = ApiResponseSchema.safeParse({
status: 'pending',
data: {},
});
expect(result.success).toBe(false);
});
});Schema Testing in API Boundaries
Your most important Zod schemas are the ones at API entry points — request body validators, query parameter parsers. Test these against realistic payloads:
// In an Express/Fastify route test
it('rejects malformed request body', async () => {
const response = await request(app)
.post('/api/users')
.send({ email: 'not-valid', age: 'not-a-number' })
.expect(400);
expect(response.body.errors).toMatchObject({
email: expect.arrayContaining([expect.stringContaining('email')]),
age: expect.arrayContaining([expect.any(String)]),
});
});Beyond Schema Tests
Schema tests verify the validator logic. They don't verify what your application does with validated data, or whether the deployed API actually rejects malformed requests in production.
HelpMeTest can test your live API endpoints with real HTTP requests — checking status codes, response shapes, and error handling against a running service. Pair schema unit tests with integration tests for full coverage.