A simple and easy-to-use testing toolkit for Nitro servers, built on top of Vitest. Use it to write tests for your API routes and event handlers.
- π Automatic Nitro build (development or production mode)
- β‘ In-process request dispatch (no HTTP listener)
- βͺοΈ Reruns tests whenever Nitro source files change
- π₯ Run Nitro per test suite or globally
- β Seamless integration with Vitest
- πͺ Conditional code execution based on test mode (
import.meta.test) - βοΈ Cloudflare Workers support with local bindings emulation (KV, D1, R2, β¦)
- π‘ Typed
$fetchRawhelper with route-level responses inherited from Nitro'sInternalApi - πΊοΈ Introspect registered routes with
listRoutes
Add nitro-test-utils as well as nitro and vitest to your project with your favorite package manager:
# pnpm
pnpm add -D nitro-test-utils nitro vitest
# npm
npm install -D nitro-test-utils nitro vitest
# yarn
yarn add -D nitro-test-utils nitro vitestImportant
Requires Nitro v3 and Vitest v4 or later.
Looking for Nitro v2 support? Use v0.11 (nitro-test-utils@^0.11).
There are two ways to set up the test environment: globally (one Nitro server for all tests) or per test suite (different servers per test file).
Note
If you are using Nitro as a Vite plugin (nitro/vite), no additional configuration is needed. Since nitro.config.ts is required even in Vite projects, nitro-test-utils loads it directly and creates a standalone Nitro server for testing.
Getting started with the global setup is as simple as creating a vitest.config.ts in your project root. Pass { global: true } as the second argument to enable a shared Nitro server for all tests:
import { defineConfig } from 'nitro-test-utils/config'
export default defineConfig({}, {
global: true,
})You can also pass an options object to global with additional options like rootDir, mode, and preset:
import { defineConfig } from 'nitro-test-utils/config'
export default defineConfig({}, {
global: {
rootDir: 'backend',
mode: 'production',
},
})Now, write your tests in a dedicated directory. You can use the $fetchRaw function to make requests to the Nitro server that is started by the test environment. A simple test case could look like this:
import { $fetchRaw } from 'nitro-test-utils/e2e'
import { describe, expect, it } from 'vitest'
describe('api', () => {
it('responds successfully', async () => {
const { data, status } = await $fetchRaw('/api/health')
expect(status).toBe(200)
expect(data).toMatchSnapshot()
})
})Tip
The global setup is recommended for most use cases. The Nitro app is built once and reused across every test file during Vitest watch mode, so you can develop and test at the same time. Whenever Nitro rebuilds, tests rerun automatically.
If you have multiple Nitro servers as part of your project, you can set up the test environment per test suite instead. The Vitest config needs no nitro options:
import { defineConfig } from 'nitro-test-utils/config'
export default defineConfig()Contrary to the global setup, the Nitro server is not started automatically. Instead, call the setup function in each test suite to start a Nitro server. After each test suite, the server is shut down:
import { resolve } from 'node:path'
import { $fetchRaw, setup } from 'nitro-test-utils/e2e'
import { describe, expect, it } from 'vitest'
describe('api', async () => {
await setup({
rootDir: resolve(import.meta.dirname, 'fixture'),
mode: 'production',
})
it('responds successfully', async () => {
const { data, status } = await $fetchRaw('/api/health')
expect(status).toBe(200)
expect(data).toMatchSnapshot()
})
})You can set custom environment variables for your tests by creating a .env.test file in your Nitro project root. The variables will be loaded automatically when the Nitro server starts:
# .env.test
FOO=barBy default, nitro-test-utils uses Node.js-compatible presets (nitro-dev for development, node-middleware for production). If your application targets a different deployment platform, you can set the preset option to match your deployment target.
Note
Non-Node presets like cloudflare-module only work in development mode, since Vitest runs inside a Node.js process.
To test Cloudflare-specific features like KV, D1, or R2 bindings locally, set the preset to cloudflare-module. Nitro automatically resolves this to the cloudflare-dev preset in development mode, which emulates Cloudflare bindings locally via wrangler's getPlatformProxy().
Make sure wrangler is installed as a dev dependency and a wrangler.json (or wrangler.toml) with your bindings configuration exists in your Nitro project root.
await setup({
rootDir: resolve(import.meta.dirname, 'fixture'),
preset: 'cloudflare-module',
})Inside your Nitro handlers, access Cloudflare bindings through event.req.runtime.cloudflare.env:
import { defineHandler } from 'nitro/h3'
export default defineHandler((event) => {
const { env } = (event.req as any).runtime.cloudflare
return env.KV.get('my-key')
})You can detect whether your code is running in a Nitro build during tests by checking the import.meta.test property. This is useful if you want to conditionally run code only in tests, but not in production:
import { defineHandler } from 'nitro/h3'
export default defineHandler(async () => {
if (import.meta.test) {
return { foo: 'bar' }
}
const db = await connectToDatabase()
return db.query()
})To get proper TypeScript support for import.meta.test, add a triple-slash reference in your env.d.ts (or any .d.ts file included by your tsconfig.json):
/// <reference types="nitro-test-utils/env" />Nitro runtime primitives like useRuntimeConfig() and useStorage() only hydrate inside the built server bundle. Calling them directly from a test file returns an empty stub.
Expose them through a dev-only route and read the values via $fetchRaw. Nitro's .dev env suffix keeps the handler out of production builds at routing init:
// routes/_test/config.get.dev.ts
import { defineHandler } from 'nitro/h3'
import { useRuntimeConfig } from 'nitro/runtime-config'
export default defineHandler(() => useRuntimeConfig())import { $fetchRaw } from 'nitro-test-utils/e2e'
import { expect, it } from 'vitest'
it('exposes runtime config', async () => {
const { data } = await $fetchRaw('/_test/config')
expect(data.databaseUrl).toBe('postgres://localhost:5432/app')
})Note
Production-mode test runs (setup({ mode: 'production' })) also filter out .dev handlers. Fall back to import.meta.test gating if you need the route in both modes.
Configures Vitest for Nitro testing. Accepts an optional Vite/Vitest config as the first argument and Nitro test options as the second.
import { defineConfig } from 'nitro-test-utils/config'
export default defineConfig(
// Vite/Vitest config (optional)
{ test: { dir: './tests' } },
// Nitro test options (optional)
{ global: true }
)Type Declaration:
function defineConfig(
userConfig?: UserConfig,
testConfig?: NitroTestConfig
): Promise<UserConfig>
interface NitroTestConfig {
/** Watch Nitro source files and rerun tests on changes. Default: `true`. */
rerunOnSourceChanges?: boolean
/** Enable a global Nitro server for all tests. Set to `true` for defaults, or pass options. */
global?: boolean | NitroTestOptions
}
interface NitroTestOptions {
/** Path to the Nitro project root. Default: Vitest working directory. */
rootDir?: string
/** `'development'` (default) or `'production'`. */
mode?: 'development' | 'production'
/** Nitro deployment preset. */
preset?: string
}Starts a Nitro server for the current test suite. Used with the per-suite setup. The server is automatically stopped after the suite completes.
import { setup } from 'nitro-test-utils/e2e'
await setup({ rootDir: './fixture' })Type Declaration:
function setup(options?: NitroTestOptions): Promise<void>See NitroTestOptions for available options.
A simple wrapper around the custom ofetch instance created by createNitroFetch. It simplifies requesting data from your Nitro server during testing and will dynamically use the base URL of the active test server.
$fetchRaw returns a promise that resolves with the raw response from ofetch.raw. This is useful because it allows you to access the response status code, headers, and body, even if the response failed.
import { $fetchRaw } from 'nitro-test-utils/e2e'
import { describe, expect, it } from 'vitest'
describe('api', () => {
it('responds with data', async () => {
// Use `data` instead of `body` for the parsed response body
const { data, status, headers } = await $fetchRaw('/api/hello')
expect(status).toBe(200)
expect(data).toMatchSnapshot()
})
})Tip
All additional options set in createNitroFetch apply here as well, such as ignoreResponseError set to true to prevent the function from throwing an error when the response status code is not in the range of 200-299, and retry: 0 to disable retries.
Note
The name $fetchRaw is deliberate β it avoids shadowing ofetch's $fetch with different defaults and return shape. See #10 for the rationale.
$fetchRaw inherits route-level typing from Nitro's InternalApi augmentation. Nitro regenerates these types at node_modules/.nitro/types/nitro-routes.d.ts on every build β when your tsconfig.json extends nitro/tsconfig, response data narrows automatically to the matching handler's return type:
// api/users/[id].get.ts returns `{ id: string, name: string }`
const { data } = await $fetchRaw('/api/users/42')
// `data` is typed as `{ id: string, name: string } | undefined`Unknown routes and explicit generic overrides both fall back gracefully:
// Falls back to `unknown` for routes not present in `InternalApi`
const { data } = await $fetchRaw('/api/not-declared')
// Explicit override when you want to pin the response shape yourself
const { data } = await $fetchRaw<{ custom: string }>('/api/health')Type Declaration:
interface NitroFetchResponse<T> extends FetchResponse<T> {
/** Alias for `response._data` */
data?: T
}
function $fetchRaw(
request: string,
options?: FetchOptions
): Promise<NitroFetchResponse<unknown>>Creates a custom ofetch instance wired to dispatch requests in-process against the active Nitro test app. No HTTP listener is involved β each call builds a Web Request and hands it to injectNitroFetch.
Note
Requests use http://nitro.test as a synthetic base URL. The .test TLD is reserved and never resolves in DNS, so if you see it in an error message or stack trace it's the in-process sentinel, not a real endpoint.
Tip
The following additional fetch options have been set as defaults:
ignoreResponseError: trueto prevent throwing errors on non-2xx responses.redirect: 'manual'to prevent automatic redirects.retry: 0to disable retries, preventing masked failures and slow test suites.headers: { accept: 'application/json' }to force a JSON error response when Nitro returns an error.
Use createNitroFetch to get a $fetch instance pre-configured for your Nitro test app β no extra setup needed:
import { createNitroFetch } from 'nitro-test-utils/e2e'
import { describe, expect, it } from 'vitest'
describe('api', () => {
const $fetch = createNitroFetch()
it('responds with data', async () => {
const data = await $fetch('/api/health')
expect(data).toEqual({ ok: true })
})
})Type Declaration:
function createNitroFetch(options?: FetchHooks): $FetchYou can pass ofetch interceptors (onRequest, onResponse, onRequestError, onResponseError) to customize request/response handling.
Creates a session-aware fetch instance that persists cookies across requests. Useful for testing authentication flows.
import { createNitroSession } from 'nitro-test-utils/e2e'
import { describe, expect, it } from 'vitest'
describe('auth', () => {
it('persists session cookies', async () => {
const session = createNitroSession()
// Login sets a session cookie
await session.$fetch('/api/login', { method: 'POST' })
// Subsequent requests include the cookie automatically
const profile = await session.$fetch('/api/profile')
expect(profile).toEqual({ user: 'authenticated' })
// Inspect cookies directly
expect(session.cookies.get('session')).toBeDefined()
// Clear cookies to simulate logout
session.clearCookies()
})
})Type Declaration:
interface NitroSession {
$fetch: $Fetch
cookies: Map<string, string>
clearCookies: () => void
}
function createNitroSession(): NitroSessionReturns the raw in-process request dispatcher for the active Nitro test app β a function that takes a Web Request and returns the app's Response without going through a real HTTP listener.
This is the low-level primitive that createNitroFetch builds on. Reach for it when you want to construct a Request yourself, or when you need to hand the dispatcher to a different layer.
import { injectNitroFetch } from 'nitro-test-utils/e2e'
import { describe, expect, it } from 'vitest'
describe('api', () => {
it('dispatches a raw Request', async () => {
const nitroFetch = injectNitroFetch()
const response = await nitroFetch(new Request('http://nitro.test/api/health'))
expect(response.status).toBe(200)
await expect(response.json()).resolves.toEqual({ ok: true })
})
})If you need a real HTTP server on top of the test app β for driving a browser, a shell script, or any other process that speaks TCP β stand one up with srvx and point it at injectNitroFetch():
import { injectNitroFetch } from 'nitro-test-utils/e2e'
import { serve } from 'srvx/node'
const server = serve({ fetch: injectNitroFetch() })
console.log(`Test app listening at ${server.url}`)Each incoming HTTP request is dispatched in-process against the same Nitro app your tests use.
Type Declaration:
function injectNitroFetch(): (request: Request) => Response | Promise<Response>Returns every route registered with the active Nitro test server, sourced from Nitro's scanned handlers. Internal routes prefixed with /_ or /api/_ are filtered out.
Useful for sanity-checking that expected handlers are loaded, or for driving parameterized tests over every API endpoint. Works in both per-suite and global setup modes, and is safe to call any time after setup() has resolved.
import { listRoutes } from 'nitro-test-utils/e2e'
import { describe, expect, it } from 'vitest'
describe('api', () => {
it('registers the expected routes', () => {
const routes = listRoutes()
expect(routes).toContainEqual({ route: '/api/health', method: 'get' })
expect(routes).toContainEqual({ route: '/api/login', method: 'post' })
})
})Type Declaration:
interface NitroRouteInfo {
/** HTTP pathname pattern (e.g. `/api/users`, `/api/users/:id`). */
route: string
/** HTTP method, or `undefined` when the handler matches any method. */
method?: string
}
function listRoutes(): NitroRouteInfo[]v3 stops starting a real HTTP listener for your tests. Instead, the Nitro app is dispatched in-process β each request is handed directly to the app's Web Request handler, the same code path every cloud preset invokes at runtime. No ports, no sockets, no localhost round-trip.
This removes injectServerUrl. Replace it with injectNitroFetch, which returns the raw request dispatcher:
-import { injectServerUrl } from 'nitro-test-utils/e2e'
-const url = injectServerUrl()
+import { injectNitroFetch } from 'nitro-test-utils/e2e'
+const nitroFetch = injectNitroFetch()
+const response = await nitroFetch(new Request('http://nitro.test/api/health'))$fetchRaw now inherits route-level typing from Nitro's InternalApi augmentation. Its first generic default changed from T = any to T = unknown, so call sites that dereference data without narrowing will fail to type-check.
The preferred fix is to set up Nitro's type augmentation so $fetchRaw picks up handler return types automatically β see Route-Level Response Types. Where that isn't practical, pass an explicit generic:
-const { data } = await $fetchRaw('/api/users')
-expect(data.id).toBe(1)
+const { data } = await $fetchRaw<{ id: number }>('/api/users')
+expect(data?.id).toBe(1)Note
The second generic on $fetchRaw previously accepted ofetch's ResponseType ('json', 'text', β¦). It now represents the request route. This only affects code that passed an explicit second generic β a rarely-used call shape.
The nitro key on Vite's UserConfig has been replaced with a second argument to defineConfig. This resolves a type collision with Nitro's own Vite plugin (nitro/vite), which claims the same nitro key.
import { defineConfig } from 'nitro-test-utils/config'
-export default defineConfig({
- nitro: {
- global: true,
- },
-})
+export default defineConfig({}, {
+ global: true,
+})With custom options:
-export default defineConfig({
- nitro: {
- global: {
- rootDir: 'backend',
- mode: 'production',
- },
- rerunOnSourceChanges: false,
- },
-})
+export default defineConfig({}, {
+ global: {
+ rootDir: 'backend',
+ mode: 'production',
+ },
+ rerunOnSourceChanges: false,
+})If you are upgrading from an earlier version of nitro-test-utils that targeted Nitro v2 (nitropack), the following breaking changes apply:
- Peer dependency:
nitropackreplaced bynitro(v3). - Renamed types:
TestOptionsβNitroTestOptions,TestContextβNitroTestContext,TestFetchResponseβNitroFetchResponse.
For Nitro v3 API changes, see the official Nitro v3 migration guide.
MIT License (c) 2024-PRESENT Johann Schopplich