Nuxt 3 Server Route and API Testing

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 utilities

Setup 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: test

Summary

Nuxt 3 server route testing strategy:

  • Use @nuxt/test-utils/e2e with $fetch for 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.

Read more