Testing H3 and Nitro Server Routes: Event Handlers, Middleware, and Plugins

Testing H3 and Nitro Server Routes: Event Handlers, Middleware, and Plugins

H3 is the HTTP framework that powers Nitro, which in turn powers Nuxt 3's server engine. If you write server/api/*.ts or server/routes/*.ts files in a Nuxt project, you are writing H3 event handlers. Testing these handlers in isolation — without starting a full Nuxt or Nitro server — dramatically speeds up the feedback loop and makes your server-side code significantly more reliable.

Understanding the Architecture

The stack from top to bottom:

  • Nuxt 3 — meta-framework for Vue; delegates server handling to Nitro
  • Nitro — build-time bundler + runtime for the server; compiles H3 routes, handles file-system routing, plugins, and deployment adapters
  • H3 — the actual HTTP framework; defines defineEventHandler, readBody, setHeader, middleware composition

When you test an H3 event handler, you call the handler function directly with a synthetic event. No network, no port binding, no Nitro startup — just pure function calls.

Installation

For a standalone H3 project:

npm install h3
npm install -D vitest

For a Nuxt 3 project (h3 is already a dependency; add test tooling):

npm install -D vitest @nuxt/test-utils

Testing H3 Event Handlers Directly

The h3 package exports a createEvent utility and the createApp helper that let you dispatch synthetic requests:

// server/api/users/[id].get.ts
import { defineEventHandler, getRouterParam, createError } from 'h3'

const users = new Map([
  ['1', { id: '1', name: 'Alice', email: 'alice@example.com' }],
  ['2', { id: '2', name: 'Bob', email: 'bob@example.com' }],
])

export default defineEventHandler((event) => {
  const id = getRouterParam(event, 'id')
  const user = users.get(id!)

  if (!user) {
    throw createError({ statusCode: 404, statusMessage: 'User not found' })
  }

  return user
})

Test this handler using h3's built-in test utilities:

// server/api/users/[id].get.test.ts
import { describe, it, expect } from 'vitest'
import { createApp, toWebHandler } from 'h3'
import handler from './[id].get'

// Wrap the handler in a minimal H3 app for testing
function createTestApp() {
  const app = createApp()
  app.use('/api/users/:id', handler)
  return toWebHandler(app)
}

describe('GET /api/users/:id', () => {
  const fetch = createTestApp()

  it('returns user for valid id', async () => {
    const response = await fetch(new Request('http://localhost/api/users/1'))
    expect(response.status).toBe(200)
    const body = await response.json()
    expect(body).toEqual({ id: '1', name: 'Alice', email: 'alice@example.com' })
  })

  it('returns 404 for unknown id', async () => {
    const response = await fetch(new Request('http://localhost/api/users/999'))
    expect(response.status).toBe(404)
    const body = await response.json()
    expect(body.statusMessage).toBe('User not found')
  })
})

toWebHandler converts an H3 app into a standard Web API (Request) => Promise<Response> function. This pattern requires no port, no network, and works in any test runner.

Testing POST Handlers with Body Parsing

// server/api/users/index.post.ts
import { defineEventHandler, readBody, createError } from 'h3'

interface CreateUserBody {
  name: string
  email: string
}

export default defineEventHandler(async (event) => {
  const body = await readBody<CreateUserBody>(event)

  if (!body.name || !body.email) {
    throw createError({ statusCode: 400, statusMessage: 'name and email are required' })
  }

  if (!body.email.includes('@')) {
    throw createError({ statusCode: 422, statusMessage: 'Invalid email format' })
  }

  const user = { id: crypto.randomUUID(), ...body, createdAt: new Date().toISOString() }
  return user
})
// server/api/users/index.post.test.ts
import { describe, it, expect } from 'vitest'
import { createApp, toWebHandler } from 'h3'
import handler from './index.post'

const fetch = toWebHandler(createApp().use('/api/users', handler))

function postUser(body: unknown) {
  return fetch(new Request('http://localhost/api/users', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(body),
  }))
}

describe('POST /api/users', () => {
  it('creates a user with valid data', async () => {
    const response = await postUser({ name: 'Alice', email: 'alice@example.com' })
    expect(response.status).toBe(200)
    const user = await response.json()
    expect(user.name).toBe('Alice')
    expect(user.email).toBe('alice@example.com')
    expect(user.id).toMatch(/^[0-9a-f-]{36}$/)
  })

  it('returns 400 when name is missing', async () => {
    const response = await postUser({ email: 'test@example.com' })
    expect(response.status).toBe(400)
  })

  it('returns 422 for invalid email', async () => {
    const response = await postUser({ name: 'Test', email: 'not-an-email' })
    expect(response.status).toBe(422)
  })

  it('returns 400 when body is empty', async () => {
    const response = await postUser({})
    expect(response.status).toBe(400)
  })
})

Testing Middleware

H3 middleware runs before event handlers and can modify the event, add headers, or short-circuit the request. Test middleware by composing it with a downstream handler:

// server/middleware/auth.ts
import { defineEventHandler, getHeader, createError } from 'h3'

export default defineEventHandler((event) => {
  const token = getHeader(event, 'authorization')

  if (!token || !token.startsWith('Bearer ')) {
    throw createError({ statusCode: 401, statusMessage: 'Unauthorized' })
  }

  // Attach the token payload to the event context
  event.context.auth = { token: token.replace('Bearer ', '') }
})
// server/middleware/auth.test.ts
import { describe, it, expect } from 'vitest'
import { createApp, toWebHandler, defineEventHandler } from 'h3'
import authMiddleware from './auth'

// Create an app that applies auth middleware, then returns the context
function createProtectedApp() {
  const app = createApp()

  // Apply middleware
  app.use(authMiddleware)

  // Protected route that returns auth context
  app.use('/protected', defineEventHandler((event) => {
    return { auth: event.context.auth }
  }))

  return toWebHandler(app)
}

describe('Auth middleware', () => {
  const fetch = createProtectedApp()

  it('passes with valid Bearer token', async () => {
    const response = await fetch(new Request('http://localhost/protected', {
      headers: { Authorization: 'Bearer my-token-123' },
    }))
    expect(response.status).toBe(200)
    const body = await response.json()
    expect(body.auth.token).toBe('my-token-123')
  })

  it('returns 401 without token', async () => {
    const response = await fetch(new Request('http://localhost/protected'))
    expect(response.status).toBe(401)
  })

  it('returns 401 with non-Bearer scheme', async () => {
    const response = await fetch(new Request('http://localhost/protected', {
      headers: { Authorization: 'Basic dXNlcjpwYXNz' },
    }))
    expect(response.status).toBe(401)
  })
})

Testing Plugins

Nitro plugins run at server startup and are used for database connections, cache warming, and similar initialization. Unit-test a plugin by calling its setup function with a mock Nitro context:

// server/plugins/db.ts
import { defineCacheEventHandler } from 'nitropack/runtime'

let dbConnection: { query: (sql: string) => Promise<unknown[]> } | null = null

export default defineNitroPlugin(async (nitro) => {
  // Initialize DB connection
  dbConnection = {
    query: async (sql: string) => {
      // In production: connect to real database
      return []
    },
  }

  nitro.hooks.hookOnce('close', () => {
    dbConnection = null
  })
})

export function getDb() {
  if (!dbConnection) throw new Error('Database not initialized')
  return dbConnection
}
// server/plugins/db.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { getDb } from './db'

describe('Database plugin', () => {
  beforeEach(() => {
    vi.resetModules()
  })

  it('throws when db is not initialized', async () => {
    // Import fresh module to get uninitialized state
    const { getDb } = await import('./db')
    expect(() => getDb()).toThrow('Database not initialized')
  })
})

For plugins with complex initialization, prefer integration tests that start a test Nitro instance.

Integration Testing with Nitro's Test Client

Nitro ships a $fetch test utility that starts the server and makes real HTTP requests:

// nitro.test.ts
import { describe, it, expect, beforeAll, afterAll } from 'vitest'
import { setup, $fetch, createPage } from '@nuxt/test-utils/e2e'

describe('Nuxt server routes', async () => {
  await setup({
    // Points to your Nuxt project root
    rootDir: '.',
    server: true,
  })

  it('GET /api/users/1 returns user', async () => {
    const user = await $fetch('/api/users/1')
    expect(user.name).toBeDefined()
  })

  it('POST /api/users creates user', async () => {
    const user = await $fetch('/api/users', {
      method: 'POST',
      body: { name: 'Test User', email: 'test@example.com' },
    })
    expect(user.id).toBeDefined()
  })
})

@nuxt/test-utils handles server startup, port allocation, and teardown. Use this for routes that depend on Nuxt's runtime (plugins, useFetch composables, nitro storage).

Testing Query Parameters and Headers

// server/api/search.get.ts
import { defineEventHandler, getQuery, getHeader } from 'h3'

export default defineEventHandler(async (event) => {
  const { q, limit = '10', page = '1' } = getQuery(event) as {
    q?: string
    limit?: string
    page?: string
  }

  const acceptLanguage = getHeader(event, 'accept-language') ?? 'en'

  return {
    results: [],
    query: q,
    limit: parseInt(limit),
    page: parseInt(page),
    locale: acceptLanguage.split(',')[0],
  }
})
// server/api/search.get.test.ts
import { describe, it, expect } from 'vitest'
import { createApp, toWebHandler } from 'h3'
import handler from './search.get'

const fetch = toWebHandler(createApp().use('/api/search', handler))

describe('GET /api/search', () => {
  it('parses query parameters', async () => {
    const response = await fetch(
      new Request('http://localhost/api/search?q=testing&limit=5&page=2')
    )
    const body = await response.json()
    expect(body.query).toBe('testing')
    expect(body.limit).toBe(5)
    expect(body.page).toBe(2)
  })

  it('uses defaults when parameters are missing', async () => {
    const response = await fetch(new Request('http://localhost/api/search'))
    const body = await response.json()
    expect(body.limit).toBe(10)
    expect(body.page).toBe(1)
  })

  it('reads accept-language header', async () => {
    const response = await fetch(new Request('http://localhost/api/search', {
      headers: { 'Accept-Language': 'fr-FR,fr;q=0.9,en;q=0.8' },
    }))
    const body = await response.json()
    expect(body.locale).toBe('fr-FR')
  })
})

Mocking External Services

H3 event handlers often call external APIs. Use vi.mock to isolate them:

// server/api/weather.get.ts
import { defineEventHandler, getQuery } from 'h3'

async function fetchWeather(city: string) {
  const response = await fetch(`https://api.weather.example.com/v1/${city}`)
  return response.json()
}

export default defineEventHandler(async (event) => {
  const { city } = getQuery(event) as { city: string }
  if (!city) return { error: 'city is required' }
  return fetchWeather(city)
})
// server/api/weather.get.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { createApp, toWebHandler } from 'h3'

// Mock globalThis.fetch
beforeEach(() => {
  vi.stubGlobal('fetch', vi.fn())
})

const fetch = toWebHandler(
  createApp().use('/api/weather', (await import('./weather.get')).default)
)

describe('GET /api/weather', () => {
  it('returns weather data for valid city', async () => {
    vi.mocked(globalThis.fetch).mockResolvedValue({
      json: async () => ({ city: 'London', temp: 15, condition: 'cloudy' }),
    } as Response)

    const response = await fetch(
      new Request('http://localhost/api/weather?city=London')
    )
    const body = await response.json()
    expect(body.city).toBe('London')
    expect(body.temp).toBe(15)
  })

  it('returns error when city is missing', async () => {
    const response = await fetch(new Request('http://localhost/api/weather'))
    const body = await response.json()
    expect(body.error).toBe('city is required')
  })
})

CI Configuration

# .github/workflows/ci.yml
name: CI

on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'

      - run: npm ci

      - name: Unit tests (H3 handlers)
        run: npx vitest run --reporter=verbose

      - name: Type check
        run: npx nuxi typecheck

Practical Tips

Test at the H3 layer for speed, at the Nitro layer for confidence. Pure H3 unit tests run in milliseconds and cover handler logic exhaustively. Nitro integration tests (via @nuxt/test-utils) are slower but catch plugin initialization and routing bugs that unit tests miss.

Use createError consistently. Unhandled exceptions in event handlers return 500. Using createError with explicit status codes makes your tests predictable — you can assert on both the status code and the error message.

Test error responses, not just happy paths. A handler that returns correct data in the success case but throws a generic 500 on validation failures makes debugging production issues extremely difficult.

Reset global state between tests. H3 handlers that rely on module-level variables (like the users Map in the first example) can bleed state between tests. Use beforeEach to reinitialize, or restructure handlers to accept dependencies as parameters.

H3 and Nitro's architecture — event handlers as pure functions, middleware as composable functions — makes server code naturally testable. The toWebHandler utility in h3 v1.x is the key: it bridges the V8 runtime's Web API (Request/Response) with H3's event model, giving you a testing interface that matches exactly what the network receives.

Read more