Testing
Unit, integration, and end-to-end testing for NextRush applications with Vitest
What You Will Build
A testing setup for a NextRush application covering four layers:
- Unit tests — route handlers and services in isolation
- Middleware tests — middleware behavior with mock contexts
- Integration tests — full request flow through
app.callback() - E2E tests — real HTTP requests via
serve()
Prerequisites
- A NextRush application (functional or class-based)
- Node.js 22+
- pnpm
Step 1 — Configure Vitest
$ pnpm add -D vitest @vitest/coverage-v8
Add scripts to your package.json:
{
"scripts": {
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage"
}
}Create vitest.config.ts:
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globals: true,
environment: 'node',
include: ['**/*.test.ts', '**/*.spec.ts'],
exclude: ['**/node_modules/**', '**/dist/**'],
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html'],
thresholds: {
lines: 90,
functions: 90,
branches: 85,
statements: 90,
},
},
},
});Global Test APIs
Setting globals: true makes describe, it, expect, and vi available without imports. You
can still import them explicitly if you prefer.
Step 2 — Create a Mock Context Helper
Every test that calls a handler or middleware needs a Context object. Build a reusable factory:
import type { Context } from '@nextrush/types';
import { vi } from 'vitest';
export function createMockContext(overrides: Partial<Context> = {}): Context {
return {
method: 'GET',
url: '/test',
path: '/test',
query: {},
headers: {},
params: {},
body: undefined,
status: 200,
state: {},
ip: '127.0.0.1',
// Response methods
json: vi.fn(),
send: vi.fn(),
html: vi.fn(),
redirect: vi.fn(),
// Header methods
get: vi.fn((name: string) => overrides.headers?.[name.toLowerCase()]),
set: vi.fn(),
// Middleware
next: vi.fn().mockResolvedValue(undefined),
// Raw request/response
raw: {
req: {} as never,
res: {} as never,
},
...overrides,
} as Context;
}The as Context cast is intentional — test mocks omit adapter-specific properties (runtime, bodySource) that only real adapters provide. This matches the pattern used across the NextRush test suite.
Step 3 — Unit Test Handlers
Test each handler in isolation using the mock context:
import { describe, it, expect } from 'vitest';
import { createMockContext } from '../../test/helpers';
import { getUserHandler, createUserHandler } from '../users';
describe('User Handlers', () => {
it('should return user by ID', async () => {
const ctx = createMockContext({
params: { id: '123' },
});
await getUserHandler(ctx);
expect(ctx.json).toHaveBeenCalledWith({
data: expect.objectContaining({ id: '123' }),
});
});
it('should throw NotFoundError for missing user', async () => {
const ctx = createMockContext({
params: { id: 'non-existent' },
});
await expect(getUserHandler(ctx)).rejects.toThrow('User not found');
});
it('should create user with valid input', async () => {
const ctx = createMockContext({
method: 'POST',
body: { name: 'Alice', email: 'alice@example.com' },
});
await createUserHandler(ctx);
expect(ctx.status).toBe(201);
expect(ctx.json).toHaveBeenCalledWith({
data: expect.objectContaining({
name: 'Alice',
email: 'alice@example.com',
}),
});
});
});Testing Services
Services with no framework dependency need no mock context:
import { describe, it, expect, beforeEach } from 'vitest';
import { UserService } from '../user.service';
describe('UserService', () => {
let service: UserService;
beforeEach(() => {
service = new UserService();
});
it('should create a user with valid data', () => {
const user = service.create({
name: 'Alice',
email: 'alice@example.com',
});
expect(user.id).toBeDefined();
expect(user.name).toBe('Alice');
});
it('should return undefined for missing user', () => {
expect(service.findById('non-existent')).toBeUndefined();
});
});Step 4 — Test Middleware
Middleware tests verify three behaviors: calling next(), modifying context state, and rejecting requests.
import { describe, it, expect, vi } from 'vitest';
import { createMockContext } from '../../test/helpers';
import { authMiddleware } from '../auth';
describe('Auth Middleware', () => {
const middleware = authMiddleware({
exclude: ['/api/health'],
});
it('should skip excluded paths', async () => {
const ctx = createMockContext({ path: '/api/health' });
await middleware(ctx);
expect(ctx.next).toHaveBeenCalled();
});
it('should reject requests without a token', async () => {
const ctx = createMockContext({ path: '/api/users' });
await expect(middleware(ctx)).rejects.toThrow('Authentication required');
});
it('should set user in state with valid token', async () => {
const ctx = createMockContext({
path: '/api/users',
headers: { authorization: 'Bearer valid-token' },
get: vi.fn((name: string) => {
if (name === 'authorization') return 'Bearer valid-token';
return undefined;
}),
});
await middleware(ctx);
expect(ctx.next).toHaveBeenCalled();
expect(ctx.state).toHaveProperty('user');
});
});vi.mock() is hoisted to the top of the file regardless of where you call it. Never place
vi.mock() inside an it() block — define module mocks at file scope.
Step 5 — Integration Tests with app.callback()
app.callback() returns the composed middleware handler without starting an HTTP server. Pass a mock context to test the full request flow:
import { describe, it, expect, beforeEach } from 'vitest';
import { createApp } from '@nextrush/core';
import { createRouter } from '@nextrush/router';
import { errorHandler } from '@nextrush/errors';
import { createMockContext } from '../../test/helpers';
function createTestApp() {
const app = createApp({ env: 'test' });
const router = createRouter();
app.use(errorHandler());
router.get('/users', (ctx) => {
ctx.json({ data: [] });
});
router.post('/users', (ctx) => {
const { name, email } = ctx.body as { name: string; email: string };
ctx.status = 201;
ctx.json({ data: { id: '1', name, email } });
});
app.route('/', router);
return app;
}
describe('Users API', () => {
let handler: (ctx: unknown) => Promise<void>;
beforeEach(() => {
handler = createTestApp().callback();
});
it('GET /users returns empty array', async () => {
const ctx = createMockContext({
method: 'GET',
path: '/users',
});
await handler(ctx);
expect(ctx.json).toHaveBeenCalledWith({ data: [] });
});
it('POST /users creates a user', async () => {
const ctx = createMockContext({
method: 'POST',
path: '/users',
body: { name: 'Alice', email: 'alice@example.com' },
});
await handler(ctx);
expect(ctx.status).toBe(201);
expect(ctx.json).toHaveBeenCalledWith({
data: { id: '1', name: 'Alice', email: 'alice@example.com' },
});
});
});Step 6 — E2E Tests with Real HTTP
For tests that need actual TCP connections, use serve() from the adapter:
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { createApp } from '@nextrush/core';
import { serve, type ServerInstance } from '@nextrush/adapter-node';
describe('API E2E', () => {
let server: ServerInstance;
let baseUrl: string;
beforeAll(async () => {
const app = createApp();
app.use(async (ctx) => {
ctx.json({ status: 'ok' });
});
server = await serve(app, { port: 0 });
baseUrl = `http://localhost:${server.port}`;
});
afterAll(async () => {
await server.close();
});
it('should return health check', async () => {
const response = await fetch(`${baseUrl}/`);
const data = await response.json();
expect(response.status).toBe(200);
expect(data).toEqual({ status: 'ok' });
});
});Key details:
- Use
serve()(notlisten()) —serve()accepts an options object with{ port }.listen()takes a port number directly and logs a startup banner. - Pass
port: 0to let the OS assign a free port, avoiding conflicts in CI. server.close()returns aPromise— alwaysawaitit.
Step 7 — Test Controllers with DI
Class-based controllers use the DI container. Reset it between tests to prevent state leaks:
import 'reflect-metadata'; // Required in test files (no nextrush meta-package import)
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { container } from '@nextrush/di';
import { UsersController } from '../users.controller';
import { UserService } from '../../services/user.service';
describe('UsersController', () => {
let controller: UsersController;
let mockUserService: Partial<UserService>;
beforeEach(() => {
container.reset();
mockUserService = {
findAll: vi.fn().mockReturnValue([]),
findById: vi.fn(),
create: vi.fn(),
};
container.register(UserService, {
useValue: mockUserService as UserService,
});
controller = container.resolve(UsersController);
});
it('should return all users', async () => {
const users = [{ id: '1', name: 'Alice' }];
vi.mocked(mockUserService.findAll!).mockReturnValue(users);
const result = await controller.findAll();
expect(result).toEqual({ data: users });
});
it('should create user', async () => {
const input = { name: 'Bob', email: 'bob@test.com' };
const created = { id: '2', ...input };
vi.mocked(mockUserService.create!).mockReturnValue(created);
const result = await controller.create(input);
expect(mockUserService.create).toHaveBeenCalledWith(input);
expect(result).toEqual({ data: created });
});
});DI Container Isolation
Always call container.reset() in beforeEach. The global container shares state across tests —
without reset, mock registrations from one test leak into the next.
Step 8 — Test Error Scenarios
Use the error classes from @nextrush/errors to verify error behavior:
import { describe, it, expect } from 'vitest';
import { NotFoundError, BadRequestError } from '@nextrush/errors';
import { createMockContext } from '../test/helpers';
describe('Error Handling', () => {
it('should throw NotFoundError for missing resource', async () => {
const ctx = createMockContext({ params: { id: 'missing' } });
await expect(getUserHandler(ctx)).rejects.toThrow(NotFoundError);
});
it('should throw BadRequestError for invalid input', async () => {
const ctx = createMockContext({
method: 'POST',
body: { name: '' },
});
try {
await createUserHandler(ctx);
expect.fail('Should have thrown');
} catch (error) {
expect(error).toBeInstanceOf(BadRequestError);
expect((error as BadRequestError).status).toBe(400);
expect((error as BadRequestError).code).toBe('BAD_REQUEST');
}
});
});Verification
Run your test suite:
pnpm testRun with coverage to verify thresholds:
pnpm test:coverageThe project enforces these coverage thresholds:
| Metric | Minimum |
|---|---|
| Lines | 90% |
| Functions | 90% |
| Branches | 85% |
| Statements | 90% |
Best Practices
Test behavior, not implementation:
// ❌ Asserts internal call
expect(service.findById).toHaveBeenCalledWith('123');
// ✅ Asserts observable output
const user = service.findById('123');
expect(user.id).toBe('123');Use descriptive test names:
// ❌ Vague
it('should work', () => {});
// ✅ States the expected outcome
it('should return 404 when user does not exist', () => {});Isolate every test:
beforeEach(() => {
vi.clearAllMocks();
container.reset(); // if using DI
});Test edge cases explicitly:
describe('UserService', () => {
it('rejects empty name', () => {});
it('rejects duplicate email', () => {});
it('handles special characters in email', () => {});
});