Overview

Test a Vercube app the way you write it, with real controllers, real DI and any dependency swapped for a fake

The Testing module boots your app for a test: real controllers, real dependency injection, real middleware and validation. Any dependency can be replaced with a fake. Requests go through app.fetch(), so no port is opened, and the project's vercube.config.ts and .env are not read unless you ask for them.

It does not depend on a test runner. Assertions on the status and the headers are built in, everything else is your runner's expect, and mocks are whatever you already use: vi.fn(), mock() from bun:test, or plain functions.

Installation

The package is only needed by your tests, so install it as a dev dependency.

$ pnpm add -D @vercube/testing

A first test

test/users.test.ts
import { createTestApp } from '@vercube/testing';
import { afterAll, beforeAll, expect, it } from 'vitest';
import { UserController } from '../src/controllers/UserController';
import { UserRepository } from '../src/repositories/UserRepository';
import { UserService } from '../src/services/UserService';
import type { TestApp } from '@vercube/testing';

let app: TestApp;

beforeAll(async () => {
  app = await createTestApp({
    controllers: [UserController],
    providers: [UserService],
    overrides: [[UserRepository, { findById: async (id) => ({ id, name: 'Ada' }) }]],
  });
});

afterAll(() => app.close());

it('returns a user', async () => {
  const res = await app.get('/users/1').expect(200);

  expect(await res.json()).toEqual({ id: '1', name: 'Ada' });
});

app.close() closes the app the way production does: onClose() callbacks run, then every @Destroy() method, and their promises are awaited. With await using the app closes at the end of the block:

it('returns a user', async () => {
  await using app = await createTestApp({
    controllers: [UserController],
    providers: [UserService],
  });

  await app.get('/users/1').expect(200);
});

Other runners

The same test runs under Bun and Deno, only the expect import changes.

import { expect, test } from 'bun:test';
import { createTestApp } from '@vercube/testing';

test('returns a user', async () => {
  await using app = await createTestApp({
    controllers: [UserController],
    providers: [UserService],
  });

  expect(await (await app.get('/users/1').expect(200)).json()).toEqual({
    id: '1',
    name: 'Ada',
  });
});

Decorators still need a transpiler that supports experimentalDecorators. Vitest, Bun and Deno handle it with your tsconfig.json, Node's type stripping does not.

Isolation

Every createTestApp() gets its own container, router and configuration, so test files and apps within one file do not see each other's overrides. The configuration starts from the defaults with requestLogging: false and logLevel: 'error', and config is laid over it:

await createTestApp({ config: { runtime: { featureFlags: { checkout: true } } } });

To boot the project as it is configured, for an end-to-end smoke test, load its config file and .env explicitly:

await createTestApp({ configFile: { cwd: process.cwd() } });

What is shared is what lives in the process: OpenTelemetry's global providers, and anything your own code keeps in module scope.

Where to go next

  • Requests: the request builder, expectations, responses, server-sent events and cookies.
  • Dependencies: overrides, stub(), createTestContainer() and the test logger.
  • Extensions: auth, telemetry, the scheduler clock, the queue and storage.
  • API: every option and method.