Skip to main content

Testing

RapidREST projects are tested with Vitest, against a real (if ephemeral) running Server instance rather than mocked request/response objects. A test makes an actual HTTP or WebSocket call and asserts on the real response. @rapidrest/service-core/test provides two supertest-style helpers, request and requestws, to make that convenient.

Setup

RapidREST's decorators (@Route, @Get, @Entity, ...) rely on experimentalDecorators and emitDecoratorMetadata. Your project's tsconfig.json needs those enabled for tsc and your editor; extend the framework's base config rather than setting them by hand:

// tsconfig.json
{
"extends": "@rapidrest/service-core/tsconfig",
"compilerOptions": {
"rootDir": ".",
"outDir": "dist"
},
"include": ["src"]
}

That covers tsc and your editor, but Vitest doesn't invoke tsc. It uses its own transform pipeline, which doesn't apply tsconfig.json's decorator settings on its own. Wire in the unplugin-swc plugin so decorators compile the same way under test as they do everywhere else:

// vitest.config.ts
import {defineConfig} from 'vitest/config';
import swc from 'unplugin-swc';

export default defineConfig({
plugins: [
swc.vite({
jsc: {
parser: {syntax: 'typescript', decorators: true},
transform: {decoratorMetadata: true, legacyDecorator: true},
target: 'es2020',
},
}),
],
test: {
globals: true,
environment: 'node',
include: ['test/**/*.test.ts'],
},
});

If your project was scaffolded with rapidrest generate server, unplugin-swc is already a dependency. This config is the piece that actually puts it to work.

Testing HTTP routes

request(app) returns a chainable, supertest-compatible builder. It accepts either a running Server instance or a raw port number.

import {Server, ObjectFactory} from '@rapidrest/service-core';
import {request} from '@rapidrest/service-core/test';
import {Logger} from '@rapidrest/core';

describe('PetRoute', () => {
const logger = Logger();
const objectFactory = new ObjectFactory(config, logger);
const server = new Server({config, basePath: './src', logger, objectFactory});

beforeAll(async () => {
await server.start();
});

afterAll(async () => {
await server.stop();
await objectFactory.destroy();
});

it('creates a pet', async () => {
const result = await request(server.getApplication())
.post('/pets')
.set('Authorization', 'jwt ' + adminToken)
.send({name: 'Rex', species: 'Dog'});

expect(result.status).toBe(200);
expect(result.body.name).toBe('Rex');
});

it('finds a pet by id', async () => {
const result = await request(server.getApplication()).get('/pets/' + petId);

expect(result.status).toBe(200);
expect(result.body.name).toBe('Rex');
});
});

.get/.post/.put/.patch/.delete/.head/.options each start a chain; .set(header, value) adds a header, .send(body) attaches a JSON body (the content-type header is set automatically for plain objects). Awaiting the chain resolves to a response object:

FieldWhat it is
status / statusCodeThe HTTP status code.
bodyThe response body, parsed as JSON if possible, otherwise the raw string.
textThe raw response body, always as a string.
typeThe MIME type from the content-type header, without the charset.
headersThe full response headers.
oktrue for any 2xx status.

Authenticated requests

Build a JWT the same way the server would verify one, using the project's own auth config, and set it as the Authorization header:

import {JWTUtils} from '@rapidrest/core';

const token = JWTUtils.createTokenSync(config.get('auth'), {uid: 'test-user'});

const result = await request(server.getApplication())
.get('/pets/me')
.set('Authorization', 'jwt ' + token);

Stubbing a dependency

There's no way to hand the DI container a pre-built fake in place of an @Inject-resolved dependency before construction, RapidREST always constructs the real class. What works instead: let the real singleton get constructed as normal, retrieve that same instance from the ObjectFactory, and stub just the method you don't want actually running:

import {vi} from 'vitest';
import {MessagingUtils} from '@rapidrest/core';

it('sends a welcome email without actually sending one', async () => {
const messagingUtils = objectFactory.getInstance(MessagingUtils);
vi.spyOn(messagingUtils, 'sendEmail').mockResolvedValue(undefined as any);

await request(server.getApplication())
.post('/users')
.send({email: 'rex@example.com'});

expect(messagingUtils.sendEmail).toHaveBeenCalled();
});

objectFactory.getInstance(...) returns the exact same object every other class in the running server has injected, vi.spyOn replacing a method on it affects every one of them for the rest of the test, which is usually exactly what you want, one real request exercising real routing, real validation, and real ACL checks, with only the one genuinely external side effect (an actual email going out) replaced.

Testing WebSocket routes

requestws(app) mirrors superwstest's fluent, action-queue style: chain sendText/sendJson/expectText/expectJson/close, then await the whole chain.

import {requestws} from '@rapidrest/service-core/test';

it('echoes a message', async () => {
await requestws(server.getApplication())
.ws('/connect')
.sendText('hello')
.expectText('echo hello')
.close();
});

Each queued action runs in order once the connection opens; the chain resolves once every action has completed, or rejects on the first mismatch (or after a 10-second timeout with no response).

Testing background jobs

A background job is a plain class. There's no HTTP surface to hit, so test it by instantiating and calling its lifecycle methods directly rather than going through request:

import {MetricsCollector} from '../src/jobs/MetricsCollector.js';

it('runs without error', async () => {
const job = new MetricsCollector();
await job.start();
await job.run();
await job.stop();
});

Testing @Transactional methods

A @Transactional method is only meaningfully tested against a real (if in-memory, per the section below) datastore, the point of the decorator is the real session/transaction semantics of MongoDB or TypeORM, which a mocked repository can't reproduce. The most useful thing to assert is that a failure partway through leaves no partial writes behind:

import { vi } from 'vitest';
import { AdoptionService } from '../src/services/AdoptionService.js';

it('rolls back both writes when the second one fails', async () => {
const created = await request(server.getApplication())
.post('/pets')
.send({name: 'Rex', species: 'Dog'});

// Force the second half of a @Transactional method to fail after the first half has run.
vi.spyOn(AdoptionService.prototype, 'reserveSupplies').mockRejectedValueOnce(new Error('boom'));

await request(server.getApplication())
.post(`/pets/${created.body.uid}/adopt`)
.expect(500);

// The `adopted` write happened inside the same transaction, so it must not have been committed either.
const pet = await request(server.getApplication()).get(`/pets/${created.body.uid}`);
expect(pet.body.adopted).toBe(false);
});

If the method under test also calls registerRollbackHook() to compensate for a write it already committed on a different connection, assert that the compensating action actually ran once the transaction fails, the same way as above but asserting on whatever that hook undoes.

Testing event listeners

@OnEvent only attaches metadata to a method, registering it with EventListenerManager doesn't wrap or otherwise change how it's called, so the most direct way to test a handler is to instantiate the listener class and call the method yourself, no running Server required:

import { PetEventHandler } from '../src/events/PetEventHandler.js';

it('marks the pet as processed', async () => {
const handler = new PetEventHandler();
await handler.onPetCreated({ type: 'pet.created', data: { uid: 'abc' } } as any);
// assert whatever side effect onPetCreated performs
});

This covers the handler logic itself. To test the full path, that an event published on the configured Redis channel actually reaches an @EventListener()-decorated class, start the Server against a real (or fake) Redis connection, publish to the same channel configured under events:channels, and assert on the resulting side effect; see Events for how the channel/type routing is configured.

In-memory datastores

For tests that touch a real datastore, use an in-memory server instead of a shared test database. mongodb-memory-server is what rapidrest dev itself uses for local development (see Getting Started), and it works the same way in tests:

import {MongoMemoryServer} from 'mongodb-memory-server';

const mongod = new MongoMemoryServer();

beforeAll(async () => {
await mongod.start();
await server.start();
});

afterAll(async () => {
await server.stop();
await mongod.stop();
});

Point datastores:mongo:port in your test config at the port mongod starts on, so the server under test connects to the ephemeral instance rather than a real one.