Testing
Titan is designed to be tested at three layers, each with a distinct purpose:
| Layer | What it tests | Speed |
|---|---|---|
| Unit | One class with mocked dependencies | <1 ms / test |
| Integration | Several classes through the container, real fakes | 10 ms / test |
| End-to-end | The whole app over a real transport, real backend | 100 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
onInitrun after the database connects? - You're testing decorators — does
@Cacheactually 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')!.portis the bound port — withport: 0it is the one the OS picked. The server also has anaddressproperty, but it is a string holding the host, not anet.Server-styleaddress()method; calling it throws.- The registration order is load-bearing.
app.start()starts Netron, so a transport registered afterwards is never bound andnetron.start()reportsNetron 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.