Integration patterns
The integration layer is where ~70% of bugs hide. Modules wire together with real DI, but external services (DB, mailer, payment provider, S3) are faked. This is the layer with the highest signal-to-noise.
The base pattern
import { describe, beforeEach, afterEach, it, expect } from 'vitest';
import { createTestModule, type TestModule } from '@omnitron-dev/testing/titan';
import { AppModule } from '../src/app.module.js';
describe('user invite flow', () => {
let mod: TestModule;
beforeEach(() => {
mod = createTestModule({
modules: [AppModule],
providers: [
[MAILER_TOKEN, { useClass: FakeMailer }],
[PAYMENT_PROVIDER, { useClass: FakeProvider }],
],
});
});
afterEach(async () => {
await mod.cleanup();
});
it('happy path', async () => {
const container = mod.getContainer();
const users = container.resolve(UsersService);
const mailer = container.resolve(MAILER_TOKEN) as FakeMailer;
await users.invite({ email: 'a@b.c' });
expect(mailer.sent).toMatchObject([{ to: 'a@b.c', template: 'invite' }]);
});
});
Pieces:
createTestModuleassembles a container from your modules, provider overrides and mocks. CallcreateApplication()on it when a test needs the fullApplicationlifecycle rather than just the container.providerstakes[token, definition]pairs and replaces external-boundary services with fakes.mockstakes{ token, mock, spy? }entries when you want a mock object rather than a class.getContainer().resolve(...)pulls anything out, including the fakes, so a test can inspect what was called.cleanup()disposes the container and anything it started.
Transaction isolation is not part of this API — arrange it in your own
beforeEach/afterEach around the connection your module resolves, or
give each worker its own database (see below).
Fake patterns
Fake mailer
class FakeMailer implements IMailer {
public sent: SentMessage[] = [];
async send(msg: SentMessage) {
this.sent.push(msg);
}
}
Just records calls. Assert on sent after the action.
Fake clock
import { vi } from 'vitest';
beforeEach(() => vi.useFakeTimers());
afterEach(() => vi.useRealTimers());
it('respects backoff', async () => {
const promise = withRetry(() => failOnce());
await vi.advanceTimersByTimeAsync(500);
await vi.advanceTimersByTimeAsync(1_000);
expect(await promise).toBe('success');
});
Use fake timers for anything time-dependent — backoff, TTL, cron, debounce. Real clocks make tests slow and flaky.
Stubbed external HTTP
import { setupServer } from 'msw/node';
import { http, HttpResponse } from 'msw';
const server = setupServer(
http.get('https://api.partner.example/v1/users/:id', () =>
HttpResponse.json({ id: 'remote-1', email: 'partner@x.com' })),
);
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
msw intercepts at the network layer
without changing app code.
Transaction-rollback in detail
app = await createTestApp({
database: 'rollback', // implies wrapping
modules: [AppModule],
});
Behind the scenes:
// Pseudo:
beforeEach: await db.exec('BEGIN');
afterEach: await db.exec('ROLLBACK');
- Every test starts with a clean DB state.
- No
TRUNCATEbetween tests — orders of magnitude faster. - Test isolation is real (the transaction sees only the seed data, then its own writes).
Caveat: tests that span multiple connections won't see each other's writes (different transactions). Move those to E2E.
Lifecycle test
it('runs onStart hooks in dependency order', async () => {
const calls: string[] = [];
class AModule implements OnStart {
async onStart() { calls.push('A'); }
}
class BModule implements OnStart {
async onStart() { calls.push('B'); }
}
const app = await createTestApp({
modules: [
{ provide: 'A', useClass: AModule },
{ provide: 'B', useClass: BModule, dependencies: ['A'] },
],
});
await app.start();
expect(calls).toEqual(['A', 'B']);
await app.dispose();
});
Asserts lifecycle ordering, hook firing, error propagation.
Database integration
In-memory SQLite
app = await createTestApp({
database: 'memory', // sqlite :memory:
modules: [AppModule],
});
Use when:
- Tests are read-heavy and Postgres-specific features aren't exercised.
- Speed > realism.
Real Postgres
Use a real Postgres when RLS, triggers, advisory locks or jsonb queries matter, or when the schema is Postgres-specific. The shared test stack is reachable through the env helpers:
import { TEST_POSTGRES_URL } from '@omnitron-dev/testing/env';
mod = createTestModule({
modules: [AppModule],
providers: [[DATABASE_URL_TOKEN, { useValue: TEST_POSTGRES_URL }]],
});
Docker-managed test DB
When a suite needs its own instance rather than the shared stack:
import { DatabaseTestManager, type DockerContainer } from '@omnitron-dev/testing/docker';
let postgres: DockerContainer;
beforeAll(async () => {
postgres = await DatabaseTestManager.createPostgresContainer({ port: 'auto' });
}, 60_000);
afterAll(async () => {
await postgres.cleanup();
});
beforeEach(() => {
const port = postgres.ports.get(5432);
mod = createTestModule({
modules: [AppModule],
providers: [[DATABASE_URL_TOKEN, { useValue: `postgres://test:test@localhost:${port}/test` }]],
});
});
port: 'auto' asks the OS for a free port and is what allows several
suites to run in parallel — a fixed port makes them collide, and the
failure names a port the test never chose.
Netron integration tests
import { createTestModule } from '@omnitron-dev/testing/titan';
import { NetronClient } from '@omnitron-dev/netron-browser';
import type { Application } from '@omnitron-dev/titan';
describe('end-to-end users service', () => {
let mod: TestModule;
let app: Application;
let client: NetronClient;
beforeEach(async () => {
mod = createTestModule({ modules: [AppModule] });
app = await mod.createApplication();
await app.start();
// Register the transport server with `port: 0` and read back the port
// the OS assigned — the server reports the port it bound, not the one
// it was asked for.
const port = app.netron.transportServers.get('http')!.port!;
client = new NetronClient({ url: `http://localhost:${port}` });
await client.connect();
});
afterEach(async () => {
await client.disconnect();
await mod.cleanup();
});
it('returns a user over the wire', async () => {
const users = await client.service<UsersService>('users');
const u = await users.findById('u_42');
expect(u.email).toBe('a@b.c');
});
});
This is the highest-value integration test — actual wire format, actual serialisation, actual auth, actual middleware. Catches a huge class of bugs that mocked-RPC tests miss.
Event-driven assertions
import { waitForEvents } from '@omnitron-dev/testing';
it('fires user.created after invite', async () => {
const container = mod.getContainer();
const bus = container.resolve(EVENT_BUS_TOKEN);
const users = container.resolve(UsersService);
const eventPromise = waitForEvents(bus, ['user.created'], 2_000);
await users.invite({ email: 'a@b.c' });
const [user] = await eventPromise;
expect(user.email).toBe('a@b.c');
});
waitForEvents resolves with one payload per event name, in the order
requested. Set up the wait before triggering the action — race-free.
createEventSpy(bus, 'user.created') is the alternative when you want
to inspect everything that was emitted rather than wait for the first.
Custom routes
Custom routes alongside RPC need their own tests:
it('serves images via /render/image/*', async () => {
await app.start();
const port = app.netron.getPort('http');
const r = await fetch(`http://localhost:${port}/render/image/bucket-1/photo.jpg?width=200`);
expect(r.status).toBe(200);
expect(r.headers.get('content-type')).toBe('image/webp');
});
Hit the route with a plain fetch; assert on status, headers,
body.
Speed budget
For an integration suite to stay fast:
| Tier | Budget per test |
|---|---|
| Bare module | <50 ms |
| With in-memory DB | <100 ms |
| With Postgres rollback | <200 ms |
| With real HTTP roundtrip | <500 ms |
| With Docker bootstrap | excluded from common suite |
If you blow past those, you're probably testing too much in one — split the test or push to E2E.
CI considerations
- Parallelism: Vitest forks per-file. Tests within a file share a process; tests across files don't. Keep file-level state minimal.
- DB connections: limit pool size in test config; many parallel workers + large pool = connection exhaustion.
- Container reuse: in CI, prefer reusing Docker containers across runs (mark with a known name + don't auto-stop).
- Snapshot updates: gate behind explicit
pnpm test --update— never auto-update in CI.
Best practices
- One test, one behaviour. A failing test should point at a single change.
- Real DI in integration, not stubs. Stubs of DI mask wiring bugs.
- Real Application lifecycle, not mocked hooks.
- Override at boundaries: mailer, payment provider, third- party APIs. Don't override internal services.
- Fake the clock, never sleep.
Anti-patterns
- Asserting on log output. Logs change; assert on observable state.
- Asserting on private state. Use public APIs to introspect.
- Coupling test order. Each test must work standalone.
- Sharing app across tests without proper reset. State leaks across tests are insidious.
- Snapshot tests for non-deterministic output. Timestamps, IDs, hashes — exclude or sanitise.