Nuxt 3 Server Route and API Testing
Nuxt 3's server directory provides a full Node.js server powered by H3, Nitro, and unjs utilities. Server routes in /server/api/, middleware in /server/middleware/, and utilities in /server/utils/ are testable as regular HTTP handlers — you don't need to spin up a browser to verify them.
This guide covers testing Nuxt 3 server routes using @nuxt/test-utils, direct H3 handler testing, and integration tests with real HTTP calls.
What to Test in Nuxt Server Routes
Nuxt 3 server routes handle:
- Input validation: Required fields, data types, value ranges
- Authentication: Token validation, session checks, role-based access
- Business logic: Data transformation, calculations, state changes
- Error handling: 400/401/403/404/500 responses
- Database operations: Queries, mutations, transactions
- External API calls: Third-party service integrations
Unit tests cover validation and business logic. Integration tests verify the full HTTP request/response cycle.
Project Structure
server/
├── api/
│ ├── products/
│ │ ├── index.get.ts # GET /api/products
│ │ ├── index.post.ts # POST /api/products
│ │ └── [id].ts # GET/PUT/DELETE /api/products/:id
│ └── auth/
│ ├── login.post.ts
│ └── logout.post.ts
├── middleware/
│ └── auth.ts # Runs on every request
└── utils/
└── db.ts # Shared database utilitiesSetup with @nuxt/test-utils
npm install -D @nuxt/test-utils vitest// vitest.config.ts
import { defineVitestConfig } from '@nuxt/test-utils/config';
export default defineVitestConfig({
test: {
environment: 'nuxt',
globals: true,
},
});Integration Testing with $fetch
@nuxt/test-utils/e2e provides a $fetch helper that makes requests to a real Nuxt server:
// server/api/products/index.get.ts
import { defineEventHandler, getQuery } from 'h3';
export default defineEventHandler(async (event) => {
const query = getQuery(event);
const { page = '1', limit = '10', category } = query;
const products = await db.products.findMany({
where: category ? { category: String(category) } : undefined,
skip: (Number(page) - 1) * Number(limit),
take: Number(limit),
});
return { products, page: Number(page), limit: Number(limit) };
});// tests/server/api/products.test.ts
import { describe, it, expect } from 'vitest';
import { setup, $fetch } from '@nuxt/test-utils/e2e';
await setup({
server: true,
browser: false,
});
describe('GET /api/products', () => {
it('returns paginated products', async () => {
const response = await $fetch('/api/products');
expect(response).toMatchObject({
products: expect.any(Array),
page: 1,
limit: 10,
});
});
it('respects page parameter', async () => {
const page1 = await $fetch('/api/products?page=1&limit=5');
const page2 = await $fetch('/api/products?page=2&limit=5');
expect(page1.products).toHaveLength(5);
expect(page2.products).toHaveLength(5);
// Pages should have different products
const page1Ids = page1.products.map((p: any) => p.id);
const page2Ids = page2.products.map((p: any) => p.id);
expect(page1Ids).not.toEqual(page2Ids);
});
it('filters by category', async () => {
const response = await $fetch('/api/products?category=electronics');
expect(response.products.every((p: any) => p.category === 'electronics')).toBe(true);
});
});Testing POST Routes with Request Body
// server/api/products/index.post.ts
import { defineEventHandler, readBody, createError } from 'h3';
export default defineEventHandler(async (event) => {
const body = await readBody(event);
// Validate required fields
if (!body.name || typeof body.name !== 'string') {
throw createError({
statusCode: 400,
message: 'Product name is required and must be a string',
});
}
if (!body.price || typeof body.price !== 'number' || body.price <= 0) {
throw createError({
statusCode: 400,
message: 'Product price must be a positive number',
});
}
const product = await db.products.create({
data: {
name: body.name,
price: body.price,
category: body.category || 'uncategorized',
},
});
setResponseStatus(event, 201);
return product;
});// tests/server/api/products-create.test.ts
import { describe, it, expect } from 'vitest';
import { setup, $fetch } from '@nuxt/test-utils/e2e';
import { FetchError } from 'ofetch';
await setup({ server: true, browser: false });
describe('POST /api/products', () => {
it('creates product with valid data', async () => {
const product = await $fetch('/api/products', {
method: 'POST',
body: { name: 'Test Widget', price: 29.99, category: 'tools' },
});
expect(product).toMatchObject({
id: expect.any(Number),
name: 'Test Widget',
price: 29.99,
category: 'tools',
});
});
it('returns 400 when name is missing', async () => {
await expect(
$fetch('/api/products', {
method: 'POST',
body: { price: 29.99 },
})
).rejects.toThrow();
// Check the status code
try {
await $fetch('/api/products', {
method: 'POST',
body: { price: 29.99 },
});
} catch (e) {
if (e instanceof FetchError) {
expect(e.response?.status).toBe(400);
expect(e.data?.message).toContain('name is required');
}
}
});
it('returns 400 when price is negative', async () => {
try {
await $fetch('/api/products', {
method: 'POST',
body: { name: 'Widget', price: -5 },
});
expect.fail('Should have thrown');
} catch (e) {
if (e instanceof FetchError) {
expect(e.response?.status).toBe(400);
}
}
});
it('defaults category to uncategorized', async () => {
const product = await $fetch('/api/products', {
method: 'POST',
body: { name: 'No Category Widget', price: 9.99 },
});
expect(product.category).toBe('uncategorized');
});
});Testing Authentication Middleware
// server/middleware/auth.ts
import { defineEventHandler, getHeader, createError } from 'h3';
import { verifyToken } from '~/server/utils/auth';
export default defineEventHandler(async (event) => {
// Skip public routes
const publicPaths = ['/api/auth/login', '/api/auth/register', '/api/health'];
if (publicPaths.some((path) => event.path?.startsWith(path))) {
return;
}
const authHeader = getHeader(event, 'authorization');
if (!authHeader?.startsWith('Bearer ')) {
throw createError({ statusCode: 401, message: 'Authorization required' });
}
const token = authHeader.slice(7);
const payload = await verifyToken(token);
if (!payload) {
throw createError({ statusCode: 401, message: 'Invalid or expired token' });
}
event.context.user = payload;
});// tests/server/middleware/auth.test.ts
import { describe, it, expect } from 'vitest';
import { setup, $fetch } from '@nuxt/test-utils/e2e';
import { FetchError } from 'ofetch';
import { generateTestToken } from '~/server/utils/auth';
await setup({ server: true, browser: false });
describe('Auth middleware', () => {
it('allows access to public routes without token', async () => {
const response = await $fetch('/api/auth/login', {
method: 'POST',
body: { email: 'test@example.com', password: 'wrong' },
}).catch((e) => e);
// Should not get 401 (may get 400 for wrong credentials, but not auth error)
if (response instanceof FetchError) {
expect(response.response?.status).not.toBe(401);
}
});
it('returns 401 for protected routes without token', async () => {
try {
await $fetch('/api/products', { method: 'POST', body: {} });
expect.fail('Should have thrown');
} catch (e) {
if (e instanceof FetchError) {
expect(e.response?.status).toBe(401);
}
}
});
it('allows access with valid token', async () => {
const token = await generateTestToken({ id: 'user-1', role: 'user' });
const response = await $fetch('/api/products', {
headers: { Authorization: `Bearer ${token}` },
});
expect(response).toBeDefined();
});
it('returns 401 for malformed token', async () => {
try {
await $fetch('/api/products', {
headers: { Authorization: 'Bearer invalid-token-data' },
});
expect.fail('Should have thrown');
} catch (e) {
if (e instanceof FetchError) {
expect(e.response?.status).toBe(401);
}
}
});
});Unit Testing H3 Handlers Directly
For faster tests without starting a full server, test H3 handlers by creating mock events:
// server/utils/products.ts
export function validateProductData(body: unknown): { name: string; price: number; category?: string } {
if (!body || typeof body !== 'object') {
throw new Error('Request body must be an object');
}
const { name, price, category } = body as Record<string, unknown>;
if (!name || typeof name !== 'string' || name.trim().length === 0) {
throw new Error('Product name is required');
}
if (typeof price !== 'number' || price <= 0) {
throw new Error('Price must be a positive number');
}
return { name: name.trim(), price, category: category as string | undefined };
}// tests/server/utils/products.test.ts
import { describe, it, expect } from 'vitest';
import { validateProductData } from '~/server/utils/products';
describe('validateProductData', () => {
it('validates complete valid input', () => {
const result = validateProductData({ name: 'Widget', price: 9.99, category: 'tools' });
expect(result).toEqual({ name: 'Widget', price: 9.99, category: 'tools' });
});
it('trims whitespace from name', () => {
const result = validateProductData({ name: ' Widget ', price: 9.99 });
expect(result.name).toBe('Widget');
});
it('rejects null body', () => {
expect(() => validateProductData(null)).toThrow('must be an object');
});
it('rejects empty name', () => {
expect(() => validateProductData({ name: '', price: 9.99 })).toThrow('name is required');
});
it('rejects zero price', () => {
expect(() => validateProductData({ name: 'Widget', price: 0 })).toThrow('positive number');
});
it('rejects negative price', () => {
expect(() => validateProductData({ name: 'Widget', price: -5 })).toThrow('positive number');
});
it('rejects string price', () => {
expect(() => validateProductData({ name: 'Widget', price: '9.99' })).toThrow('positive number');
});
});Testing Database Operations with In-Memory DB
For routes that interact with a database, use an in-memory database to keep tests fast and isolated:
// tests/setup/db.ts
import { beforeAll, afterAll, beforeEach } from 'vitest';
import { PrismaClient } from '@prisma/client';
export const testDb = new PrismaClient({
datasources: { db: { url: 'file:./test.db?mode=memory&cache=shared' } },
});
beforeAll(async () => {
await testDb.$connect();
// Run migrations
await testDb.$executeRaw`PRAGMA journal_mode=WAL`;
});
beforeEach(async () => {
// Clean all tables
await testDb.product.deleteMany();
await testDb.user.deleteMany();
});
afterAll(async () => {
await testDb.$disconnect();
});Testing Response Headers and Status Codes
Don't just test response bodies — test status codes and headers too:
it('returns 201 on resource creation', async () => {
const response = await fetch('http://localhost:3000/api/products', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
body: JSON.stringify({ name: 'Widget', price: 9.99 }),
});
expect(response.status).toBe(201);
const body = await response.json();
expect(body.id).toBeDefined();
});
it('sets correct Content-Type header', async () => {
const response = await fetch('http://localhost:3000/api/products');
expect(response.headers.get('content-type')).toContain('application/json');
});CI Integration
name: Server API Tests
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: Setup test database
run: npx prisma migrate deploy
env:
DATABASE_URL: file:./test.db
- name: Run server tests
run: npx vitest --reporter=verbose
env:
DATABASE_URL: file:./test.db
NODE_ENV: testSummary
Nuxt 3 server route testing strategy:
- Use
@nuxt/test-utils/e2ewith$fetchfor integration tests against a real server - Test all validation paths: missing fields, wrong types, boundary values
- Test middleware separately: auth, rate limiting, logging
- Unit test utilities directly: validation functions, data transformers — no HTTP overhead
- Use in-memory databases: fast, isolated, consistent across CI runs
- Check status codes and headers, not just response bodies
Nuxt 3's server is a first-class Node.js application — treat its tests with the same rigor as any backend service. The /server directory deserves its own test suite, not just coverage from E2E tests.