Dependencies

Replace services with fakes, stub only what a test needs, test services without HTTP, and assert on logs

Overrides

overrides replaces a dependency with an object or a class: [key, replacement].

const sent: string[] = [];

const app = await createTestApp({
  controllers: [SignupController],
  providers: [UserService, Mailer],
  overrides: [
    [UserRepository, { save: async (user) => ({ ...user, id: '1' }) }],
    [Mailer, { send: async (to: string) => void sent.push(to) }],
    [Clock, FixedClock],
  ],
});

An object is bound as the instance itself, a class is bound as a singleton and gets its own @Inject()s. Overrides are applied once every plugin has been applied, so they win over a binding a plugin makes.

They have to be declared when the app boots. A service that already exists holds the dependency it was given, so replacing a binding afterwards would reach some services and not others. app.override() exists only to say so, and always throws.

stub()

A partial object is fine as long as the code under test only calls what it defines. When it calls something else, the failure is usually undefined is not a function somewhere far from the cause. stub() names it instead:

import { stub } from '@vercube/testing';

const repo = stub<UserRepository>({ findById: async () => user }, 'UserRepository');

await repo.save(user); // TestingError: UserRepository.save is not stubbed

strictOverrides: true wraps every object override with stub(), named after its key. It is off by default.

A stub is not a promise, so await stub() resolves to the stub. Members it does not define read as a function that throws, so check an optional property with 'name' in stub.

Services without HTTP

createTestContainer() builds a bare container for unit tests of services. @Init() methods run, as they would in an app.

import { createTestContainer } from '@vercube/testing';

const { container, logs } = createTestContainer({
  providers: [PriceService],
  overrides: [[ExchangeRates, { rate: () => 4.3 }]],
});

expect(container.get(PriceService).toPln(10)).toBe(43);

Logs

createTestApp() binds a TestLogger as the Logger and exposes it as app.logs. It keeps every call instead of writing it out, including what the framework itself logs, such as an unhandled error in a handler.

await app.get('/orders/404').expect(500);

expect(app.logs.find('error', /order not found/)).toBeDefined();
expect(app.logs.filter('warn')).toHaveLength(0);

find() and filter() take a level and a match: a substring of the message, a RegExp, or a predicate over the record. Each record has level, args, message, context and at. Children made with logger.child() write to the same records with their context merged in, and emit() is recorded with the level wide-event.

Set app.logs.print = true to see the logs while debugging a test, and pass logger: false to keep the logger your configuration binds.