Fastify TypeScript + Zod Testing: Type-Safe Route and Schema Validation

Fastify TypeScript + Zod Testing: Type-Safe Route and Schema Validation

Combining Fastify with TypeScript and Zod gives you end-to-end type safety from the HTTP layer down to your database. But that safety only holds if you test it. This post covers how to verify that Zod schemas reject malformed requests, how to assert on validation error shapes, and how to confirm that your OpenAPI spec output matches what consumers expect.

Key Takeaways

fastify-type-provider-zod wires Zod directly into Fastify's type system. Install it once, use withTypeProvider() per route, and TypeScript infers request/reply types from your Zod schemas automatically.

Test the rejection path as carefully as the happy path. A schema that accepts valid input is only half the story. Test that invalid bodies return 400, that the error message names the failing field, and that extra fields are stripped or rejected.

Zod's .safeParse() is useful in unit tests; inject() is better for integration tests. Use .safeParse() to unit test schema logic in isolation. Use inject() to test the full Fastify validation pipeline including error formatting.

OpenAPI spec output is a contract — test it. Use @fastify/swagger to generate a spec, then assert on the generated JSON. Consumers depend on this contract; breaking it silently is a regression.

strictObject vs. object matters for extra fields. Zod's z.object() strips unknown keys by default. z.strictObject() rejects them. Test which behavior your routes exhibit so it can't regress silently.

Why Test Validation, Not Just Types

TypeScript gives you compile-time guarantees. Zod gives you runtime guarantees. Neither gives you a guarantee that the two are wired together correctly in your Fastify app. A misconfigured type provider, a forgotten .body schema, or a serializer mismatch can silently allow invalid data through at runtime while TypeScript happily compiles.

Testing validation means running actual HTTP requests through your routes and asserting on what comes back when input is bad. It's the only way to be certain the runtime behavior matches the types.

Installing the Stack

npm install fastify @fastify/type-provider-zod zod
npm install @fastify/swagger @fastify/swagger-ui  # for OpenAPI
npm install -D vitest @types/node

The key package is @fastify/type-provider-zod (formerly fastify-type-provider-zod). It connects Zod schemas to Fastify's JSON Schema validation and TypeScript type inference in one step.

Setting Up a Typed Route

// src/routes/users.ts
import { FastifyInstance } from 'fastify'
import { ZodTypeProvider } from '@fastify/type-provider-zod'
import { z } from 'zod'

const CreateUserSchema = z.object({
  name: z.string().min(2).max(100),
  email: z.string().email(),
  age: z.number().int().min(18).max(120).optional(),
})

const UserResponseSchema = z.object({
  id: z.string().uuid(),
  name: z.string(),
  email: z.string().email(),
  createdAt: z.string().datetime(),
})

export async function userRoutes(app: FastifyInstance) {
  app.withTypeProvider<ZodTypeProvider>().post(
    '/users',
    {
      schema: {
        body: CreateUserSchema,
        response: {
          201: UserResponseSchema,
        },
      },
    },
    async (request, reply) => {
      // request.body is fully typed: { name: string; email: string; age?: number }
      const user = {
        id: crypto.randomUUID(),
        name: request.body.name,
        email: request.body.email,
        createdAt: new Date().toISOString(),
      }
      return reply.code(201).send(user)
    }
  )
}

Testing Valid Requests

// test/routes/users.test.ts
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
import Fastify, { FastifyInstance } from 'fastify'
import { ZodTypeProvider, serializerCompiler, validatorCompiler } from '@fastify/type-provider-zod'
import { userRoutes } from '../../src/routes/users'

async function buildApp(): Promise<FastifyInstance> {
  const app = Fastify({ logger: false })
  app.setValidatorCompiler(validatorCompiler)
  app.setSerializerCompiler(serializerCompiler)
  await app.register(userRoutes)
  await app.ready()
  return app
}

describe('POST /users', () => {
  let app: FastifyInstance

  beforeEach(async () => { app = await buildApp() })
  afterEach(async () => { await app.close() })

  it('creates a user with valid payload', async () => {
    const response = await app.inject({
      method: 'POST',
      url: '/users',
      headers: { 'content-type': 'application/json' },
      payload: { name: 'Alice Smith', email: 'alice@example.com' },
    })

    expect(response.statusCode).toBe(201)
    const body = response.json()
    expect(body.id).toMatch(/^[0-9a-f-]{36}$/)
    expect(body.name).toBe('Alice Smith')
    expect(body.email).toBe('alice@example.com')
    expect(body.createdAt).toBeTruthy()
  })

  it('accepts optional age field when provided', async () => {
    const response = await app.inject({
      method: 'POST',
      url: '/users',
      headers: { 'content-type': 'application/json' },
      payload: { name: 'Bob Jones', email: 'bob@example.com', age: 25 },
    })

    expect(response.statusCode).toBe(201)
  })
})

Testing Validation Rejection

This is where the real value is. Every field in your schema should have at least one test that proves it's enforced at runtime.

describe('POST /users — validation errors', () => {
  let app: FastifyInstance

  beforeEach(async () => { app = await buildApp() })
  afterEach(async () => { await app.close() })

  it('rejects request with missing required fields', async () => {
    const response = await app.inject({
      method: 'POST',
      url: '/users',
      headers: { 'content-type': 'application/json' },
      payload: { name: 'Alice' }, // missing email
    })

    expect(response.statusCode).toBe(400)
  })

  it('rejects invalid email format', async () => {
    const response = await app.inject({
      method: 'POST',
      url: '/users',
      headers: { 'content-type': 'application/json' },
      payload: { name: 'Alice', email: 'not-an-email' },
    })

    expect(response.statusCode).toBe(400)
    const body = response.json()
    // Fastify wraps Zod errors in FST_ERR_VALIDATION
    expect(body.message).toBeTruthy()
  })

  it('rejects name shorter than 2 characters', async () => {
    const response = await app.inject({
      method: 'POST',
      url: '/users',
      headers: { 'content-type': 'application/json' },
      payload: { name: 'A', email: 'a@example.com' },
    })

    expect(response.statusCode).toBe(400)
  })

  it('rejects age below minimum', async () => {
    const response = await app.inject({
      method: 'POST',
      url: '/users',
      headers: { 'content-type': 'application/json' },
      payload: { name: 'Alice', email: 'alice@example.com', age: 16 },
    })

    expect(response.statusCode).toBe(400)
  })

  it('rejects non-integer age', async () => {
    const response = await app.inject({
      method: 'POST',
      url: '/users',
      headers: { 'content-type': 'application/json' },
      payload: { name: 'Alice', email: 'alice@example.com', age: 25.5 },
    })

    expect(response.statusCode).toBe(400)
  })

  it('returns 400 not 500 when body is not valid JSON', async () => {
    const response = await app.inject({
      method: 'POST',
      url: '/users',
      headers: { 'content-type': 'application/json' },
      body: 'not json at all',
    })

    expect(response.statusCode).toBe(400)
  })
})

Testing Validation Error Shape

If your API consumers parse error messages, you need to test the shape of validation errors, not just the status code.

// src/plugins/error-handler.plugin.ts
import fp from 'fastify-plugin'
import { FastifyInstance } from 'fastify'

export default fp(async function errorHandlerPlugin(app: FastifyInstance) {
  app.setErrorHandler((error, _request, reply) => {
    if (error.validation) {
      return reply.code(400).send({
        error: 'Validation Error',
        issues: error.validation.map((v) => ({
          field: v.instancePath.replace('/', '') || v.params?.missingProperty,
          message: v.message,
        })),
      })
    }
    return reply.send(error)
  })
})
describe('validation error shape', () => {
  let app: FastifyInstance

  beforeEach(async () => {
    app = await buildApp()
    await app.register(errorHandlerPlugin)
  })
  afterEach(async () => { await app.close() })

  it('returns structured issues array with field names', async () => {
    const response = await app.inject({
      method: 'POST',
      url: '/users',
      headers: { 'content-type': 'application/json' },
      payload: { name: 'A', email: 'bad' },
    })

    expect(response.statusCode).toBe(400)
    const body = response.json()
    expect(body.error).toBe('Validation Error')
    expect(Array.isArray(body.issues)).toBe(true)
    expect(body.issues.length).toBeGreaterThan(0)
    // Both name (too short) and email (invalid) should be flagged
    const fields = body.issues.map((i: { field: string }) => i.field)
    expect(fields).toContain('name')
    expect(fields).toContain('email')
  })
})

Testing Schema Unit Logic with Zod

For complex schema logic, unit-test the Zod schema directly — faster and more precise than inject():

// test/schemas/user.schema.test.ts
import { describe, it, expect } from 'vitest'
import { z } from 'zod'

const PasswordSchema = z
  .string()
  .min(8, 'Password must be at least 8 characters')
  .regex(/[A-Z]/, 'Must contain at least one uppercase letter')
  .regex(/[0-9]/, 'Must contain at least one digit')

describe('PasswordSchema', () => {
  it('accepts a valid password', () => {
    const result = PasswordSchema.safeParse('Secure1234')
    expect(result.success).toBe(true)
  })

  it('rejects passwords shorter than 8 characters', () => {
    const result = PasswordSchema.safeParse('Ab1')
    expect(result.success).toBe(false)
    expect(result.error?.issues[0].message).toBe('Password must be at least 8 characters')
  })

  it('rejects passwords without uppercase letters', () => {
    const result = PasswordSchema.safeParse('alllower1')
    expect(result.success).toBe(false)
    expect(result.error?.issues[0].message).toMatch(/uppercase/)
  })
})

Testing OpenAPI Spec Output

When your API has consumers, the OpenAPI spec is a contract. Test it to catch silent regressions.

// test/openapi.test.ts
import { describe, it, expect, beforeAll, afterAll } from 'vitest'
import Fastify, { FastifyInstance } from 'fastify'
import swagger from '@fastify/swagger'
import { ZodTypeProvider, serializerCompiler, validatorCompiler, jsonSchemaTransform } from '@fastify/type-provider-zod'
import { userRoutes } from '../../src/routes/users'

describe('OpenAPI spec', () => {
  let app: FastifyInstance

  beforeAll(async () => {
    app = Fastify({ logger: false })
    app.setValidatorCompiler(validatorCompiler)
    app.setSerializerCompiler(serializerCompiler)

    await app.register(swagger, {
      openapi: {
        info: { title: 'Test API', version: '1.0.0' },
      },
      transform: jsonSchemaTransform,
    })

    await app.register(userRoutes)
    await app.ready()
  })

  afterAll(async () => { await app.close() })

  it('includes POST /users in the spec', () => {
    const spec = app.swagger()
    expect(spec.paths['/users']?.post).toBeDefined()
  })

  it('documents the request body schema', () => {
    const spec = app.swagger()
    const requestBody = spec.paths['/users']?.post?.requestBody
    expect(requestBody).toBeDefined()
    // The body schema should reference or inline the user creation schema
    const content = (requestBody as any)?.content?.['application/json']
    expect(content?.schema).toBeDefined()
  })

  it('documents the 201 response', () => {
    const spec = app.swagger()
    const responses = spec.paths['/users']?.post?.responses
    expect(responses?.['201']).toBeDefined()
  })

  it('marks email as required in request schema', () => {
    const spec = app.swagger()
    const bodySchema = (spec.paths['/users']?.post?.requestBody as any)
      ?.content?.['application/json']?.schema
    expect(bodySchema?.required).toContain('email')
    expect(bodySchema?.required).toContain('name')
    expect(bodySchema?.required).not.toContain('age')
  })
})

What to Test vs. What to Skip

Test:

  • That invalid request bodies return 400, not 500
  • That each required field is actually enforced at runtime
  • That constraint violations (min length, valid email, integer range) are caught
  • The shape and field names in validation error responses your consumers depend on
  • OpenAPI spec output for routes that are part of a public contract
  • The difference between z.object() (strips extra) and z.strictObject() (rejects extra)

Skip:

  • Testing that Zod itself validates correctly — that's Zod's test suite
  • Testing TypeScript compile-time types — those are checked by tsc, not by test runs
  • Generating OpenAPI schema for internal-only routes that have no consumers
  • Testing every possible invalid value for a field — pick representative cases (one below min, one wrong type, one missing entirely)

The goal is confidence that the runtime matches the types. Focus tests on the boundary where HTTP input enters your application and the boundary where response output leaves it.

Read more