Skip to main content

Testing

Titan is designed to be tested at three layers, each with a distinct purpose:

LayerWhat it testsSpeed
UnitOne class with mocked dependencies<1 ms / test
IntegrationSeveral classes through the container, real fakes10 ms / test
End-to-endThe whole app over a real transport, real backend100 ms / test

The pyramid: many unit tests, fewer integration, fewer still e2e.

This page is the entry point. Detail in:

  • DI Overrides — patterns for mocking through the container.
  • Integration — partial app boots, real ConfigModule and LoggerModule, fake everything else.

The unit test pattern

A @Service is just a class. Test it like one:

import { describe, it, expect, vi } from 'vitest';

describe('UsersService', () => {
it('returns the user when found', async () => {
const repo = { findById: vi.fn().mockResolvedValue({ id: 'u_1', email: 'x' }) };
const logger = { info: vi.fn() };

const svc = new UsersService(repo as any, logger as any);

const user = await svc.findById('u_1');
expect(user).toEqual({ id: 'u_1', email: 'x' });
expect(repo.findById).toHaveBeenCalledWith('u_1');
});

it('throws NotFoundError when missing', async () => {
const repo = { findById: vi.fn().mockResolvedValue(null) };
const svc = new UsersService(repo as any, { info: vi.fn() } as any);

await expect(svc.findById('missing')).rejects.toBeInstanceOf(NotFoundError);
});
});

No container, no application, no Netron. Just the class and its dependencies.

This is the right level for business logic tests. They run in microseconds. You can have thousands.

When to use the container

Use the container (and a real Application) when:

  • You're testing wiring — does this module actually export the right providers?
  • You're testing lifecycle — does my onInit run after the database connects?
  • You're testing decorators — does @Cache actually short- circuit on a hit?

For all of these, see DI Overrides and Integration.

When to go end-to-end

End-to-end tests boot a real Application, bind a real transport, and drive it with a second Netron acting as the client. Use them sparingly — they catch wire-format and transport bugs that unit tests miss, but they're slow.

(NetronClient from @omnitron-dev/netron-browser is the browser client. It works against the same server, but a Node test does not need it and would have to take the extra dependency.)

Pattern:

import { Application } from '@omnitron-dev/titan';
import { Netron } from '@omnitron-dev/titan/netron';
import { HttpTransport } from '@omnitron-dev/titan/netron/transport/http';

let app: Application;
let client: Netron;

beforeAll(async () => {
app = await Application.create(AppModule, { disableGracefulShutdown: true });

// Register the transport BEFORE app.start(): starting the application
// starts Netron, and a second netron.start() throws "Netron already
// started". There is no `netron` option on Application.create that binds
// a server for you — the options bag has an index signature, so anything
// you invent there is accepted and ignored.
const netron = app.netron!;
netron.registerTransport('http', () => new HttpTransport());
netron.registerTransportServer('http', {
name: 'http',
options: { host: '127.0.0.1', port: 0 }, // 0 = pick a free port
});
await app.start();

const port = netron.transportServers.get('http')!.port;

client = new Netron(logger, {});
client.registerTransport('http', () => new HttpTransport());
await client.start();
});

afterAll(async () => {
await client.stop();
await app.stop();
});

it('end-to-end: create + find', async () => {
const port = app.netron!.transportServers.get('http')!.port;
const peer = await client.connect(`http://127.0.0.1:${port}`);
const users = await peer.queryInterface<UsersService>('users@1.0.0');

const user = await users.create({ email: 'ada@x.com' });
const found = await users.findById(user.id);
expect(found).toEqual(user);
});

Two details worth knowing before you debug them:

  • transportServers.get('http')!.port is the bound port — with port: 0 it is the one the OS picked. The server also has an address property, but it is a string holding the host, not a net.Server-style address() method; calling it throws.
  • The registration order is load-bearing. app.start() starts Netron, so a transport registered afterwards is never bound and netron.start() reports Netron already started.

The right ratio

A typical Titan service ends up with:

  • 80% unit tests — fast, focused, business-logic-only.
  • 15% integration tests — wiring, lifecycle, module composition.
  • 5% e2e tests — happy paths over the wire.

If you find yourself writing many integration tests for one service, the service is doing too much. Split it.

→ Read on: DI Overrides, Integration.