Extensions

Authenticated requests, recorded spans, a clock the test controls, an in-memory queue and storage

Extensions plug other modules into createTestApp(). Each module ships its own from a /testing entry, so @vercube/testing never depends on them.

Auth

testAuth() from @vercube/testing/auth replaces the AuthProvider. A request sent with .as(user) is authenticated as that user, and @User() receives it.

import { testAuth } from '@vercube/testing/auth';

const app = await createTestApp({
  controllers: [AdminController],
  extensions: [testAuth()],
});

await app.get('/admin').expect(401);
await app
  .get('/admin')
  .as({ id: '1', roles: ['admin'] })
  .expect(200);
await app
  .get('/admin')
  .as({ id: '2', roles: ['user'] })
  .expect(401);

Roles required by @Auth({ roles }) are checked against user.roles. Read them elsewhere with testAuth({ roles: (user) => user.groups }). Both a missing user and a missing role answer 401, as @Auth() does.

The user travels with the Request object the builder creates, never in a header. A request built by hand with the same headers is not authenticated, so the provider authenticates nobody even if it were shipped by mistake.

Telemetry

testTelemetry() from @vercube/testing/telemetry registers in-memory OpenTelemetry providers, installs TelemetryPlugin, and shuts the providers down when the app closes.

import { testTelemetry } from '@vercube/testing/telemetry';

const telemetry = testTelemetry();
const app = await createTestApp({
  controllers: [UserController],
  extensions: [telemetry],
});

await app.get('/users/1');
await telemetry.settle();

expect(
  telemetry.span('GET /users/:id')?.attributes['http.response.status_code'],
).toBe(200);

The providers are global to the process, so do not run two apps with this extension at the same time in one test file.

Scheduler

withManualClock() from @vercube/scheduler/testing runs the scheduler on a ManualClock. Time only moves when the test moves it, and advance() waits for the runs it started.

import { withManualClock } from '@vercube/scheduler/testing';

const time = withManualClock('2026-01-01T00:00:00Z');
const app = await createTestApp({ providers: [ReportJobs], extensions: [time] });

await time.clock.advance('1h');
expect(app.resolve(ReportJobs).generated).toBe(1);

await time.scheduler.trigger('reports.hourly');

Cluster jobs get a MemoryStore unless the second argument mounts another store.

Queue

withMemoryQueue() from @vercube/queue/testing mounts the in-memory strategy as default. drain() waits until every published job has been processed, retries included, and events() lists the attempts.

import { withMemoryQueue } from '@vercube/queue/testing';

const queue = withMemoryQueue();
const app = await createTestApp({
  controllers: [SignupController],
  providers: [EmailConsumer],
  extensions: [queue],
});

await app.post('/signup').json({ email: '[email protected]' }).expect(201);
await queue.drain();

expect(queue.events()).toMatchObject([
  { queue: 'emails', job: 'welcome', status: 'completed' },
]);

Storage

Storage needs no extension: mount MemoryStorage once the app has booted.

import { StorageManager } from '@vercube/storage';
import { MemoryStorage } from '@vercube/storage/drivers/MemoryStorage';

const app = await createTestApp({
  controllers: [UploadController],
  providers: [StorageManager],
});
await app
  .resolve(StorageManager)
  .mount({ name: 'uploads', storage: MemoryStorage });

Writing an extension

An extension is an object with a name and any of four hooks: setup(app) before plugins are applied, beforeInitialize(app) once they have been, ready(testApp) once the app has booted, and teardown() when it closes. Teardown runs last extension first.

import type { TestExtension } from '@vercube/testing';

export function withFakeMailer(): TestExtension & { sent: Mail[] } {
  const sent: Mail[] = [];

  return {
    name: 'withFakeMailer',
    sent,
    beforeInitialize: (app) => {
      app.container.bindMock(Mailer, {
        send: async (mail: Mail) => void sent.push(mail),
      });
    },
  };
}

An extension can support .as(user) too: call testApp.useImpersonator((request, user) => ...) from ready().