NextRush
Guides

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:

  1. Unit tests — route handlers and services in isolation
  2. Middleware tests — middleware behavior with mock contexts
  3. Integration tests — full request flow through app.callback()
  4. 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:

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:

src/test/helpers.ts
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:

src/handlers/__tests__/users.test.ts
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:

src/services/__tests__/user.service.test.ts
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.

src/middleware/__tests__/auth.test.ts
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:

src/__tests__/integration/users.test.ts
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:

src/__tests__/e2e/api.test.ts
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() (not listen()) — serve() accepts an options object with { port }. listen() takes a port number directly and logs a startup banner.
  • Pass port: 0 to let the OS assign a free port, avoiding conflicts in CI.
  • server.close() returns a Promise — always await it.

Step 7 — Test Controllers with DI

Class-based controllers use the DI container. Reset it between tests to prevent state leaks:

src/controllers/__tests__/users.controller.test.ts
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 test

Run with coverage to verify thresholds:

pnpm test:coverage

The project enforces these coverage thresholds:

MetricMinimum
Lines90%
Functions90%
Branches85%
Statements90%

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', () => {});
});

What's Next?

On this page