Extensions
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().