Testing
A pragmatic guide to testing on this stack. Covers four levels — from unit tests of a single class to E2E tests driving a real React app through a real Netron client.
The pyramid
flowchart TB
E2E[E2E — Playwright<br/>real browser, real daemon, real DB]
Integration[Integration — Vitest<br/>real Application, in-memory infra]
Module[Module — Vitest<br/>real DI container, mocked deps]
Unit[Unit — Vitest<br/>plain classes, no DI]
Unit --> Module --> Integration --> E2E
style Unit fill:#e8f5e9,stroke:#43a047,color:#1b5e20
style Module fill:#fff8e1,stroke:#fb8c00,color:#e65100
style Integration fill:#f3e5f5,stroke:#8e24aa,color:#4a148c
style E2E fill:#fce4ec,stroke:#d81b60,color:#880e4f| Level | What | Tool | Speed | When |
| Unit | Single class / function | Vitest | μs–ms | Pure logic |
| Module | DI graph + mocks | Vitest + Application.create({ overrides }) | ms | Service interaction |
| Integration | Real app, in-memory infra | Vitest + Titan testing utilities | 10s of ms | Cross-module flow |
| E2E | Real daemon, real client | Vitest / Playwright | seconds | Full system |
Tooling
| Package | Role |
vitest | The runner. Native ESM, TypeScript, fast. |
@omnitron-dev/testing | Cross-runtime helpers + async primitives + Titan-specific test glue |
@playwright/test | E2E browser automation. Used by the webapp. |
@omnitron-dev/netron-react/test | TestNetronProvider for React components that call Netron |
| Test-fixture databases | Dedicated PostgreSQL databases with real application migrations; never the running daemon database |
Omnitron SQL integration suites create a database per suite on the test
PostgreSQL server and drop it after completion. pnpm test:ci owns the test
services for the full workspace run. A missing test server is reported as a
skip with a reason; it is not evidence that SQL behavior passed.
What lives where
Unit + module tests live next to the code:
src/foo.ts+src/__tests__/foo.test.ts.Integration tests live in
<package>/test/or<app>/test/.E2E tests live in
<app>/e2e/(Playwright by convention).Performance + load tests in
<package>/benchmark/.
The four pages
| Page | What it covers |
| Testing package reference | @omnitron-dev/testing API — async helpers, runtime adapters, mock primitives |
| Integration patterns | Application.create({ overrides }), in-memory DB, fake clocks, transaction rollback |
| Cross-runtime testing | Same test source running on Node + Bun + Deno |
| React component testing | TestNetronProvider, createMockService, Suspense, error boundaries |
Plus the per-module testing reference: Titan / Testing / Modules — specific mock recipes for each of the 14 modules.
Quick reference — one test per level
Unit
import { describe, it, expect } from 'vitest';
import { calculateInvoiceTotal } from './invoice.js';
describe('calculateInvoiceTotal', () => {
it('sums line items with tax', () => {
expect(calculateInvoiceTotal([
{ qty: 2, price: '10.00', taxRate: 0.08 },
{ qty: 1, price: '5.00', taxRate: 0.08 },
])).toBe('27.00');
});
});No DI, no async, no mocks — just functions and their expectations.
Module
import { describe, it, expect, vi } from 'vitest';
import { Container, Scope } from '@omnitron-dev/titan/nexus';
import { UsersService } from './users.service.js';
describe('UsersService', () => {
it('reads from the repo', async () => {
const container = new Container();
const fakeRepo = { findById: vi.fn().mockResolvedValue(MOCK_USER) };
// Container.register takes the token separately — the { provide, … }
// form is for a module's `providers` array, not for this call.
container.register(UserRepo, { useValue: fakeRepo });
container.register(UsersService); // a bare class is its own token
const service = await container.resolveAsync(UsersService);
const user = await service.findById('u_42');
expect(user).toEqual(MOCK_USER);
expect(fakeRepo.findById).toHaveBeenCalledWith('u_42');
});
});The DI container is real; dependencies are mocks via the override mechanism.
Integration
import { describe, it, expect } from 'vitest';
import { Application } from '@omnitron-dev/titan';
import { AppModule } from './app.module.js';
import { FakeMailer } from './test/fakes.js';
import { MAILER_TOKEN } from './tokens.js';
describe('user invite flow', () => {
it('creates a user and queues a welcome email', async () => {
const app = await Application.create({
modules: [AppModule],
overrides: [{ provide: MAILER_TOKEN, useClass: FakeMailer }],
disableGracefulShutdown: true,
});
await app.start();
try {
const users = await app.resolve(UsersService);
const mailer = await app.resolve(MAILER_TOKEN) as FakeMailer;
await users.invite({ email: 'a@b.c', tier: 'pro' });
expect(mailer.sent).toEqual([
{ to: 'a@b.c', template: 'welcome', data: { tier: 'pro' } },
]);
} finally {
await app.stop();
}
});
});Real Application, real DI, real cross-module flow — only the
outermost integration (mailer) is faked.
E2E
// e2e/sign-in.spec.ts (Playwright)
import { test, expect } from '@playwright/test';
test('user signs in', async ({ page }) => {
await page.goto('http://localhost:5173');
await page.getByLabel('Email').fill('a@b.c');
await page.getByLabel('Password').fill('correct-horse-battery');
await page.getByRole('button', { name: 'Sign in' }).click();
await expect(page).toHaveURL('http://localhost:5173/');
await expect(page.getByText('a@b.c')).toBeVisible();
});Real daemon, real React app, real browser. Slowest layer; run sparingly.
Per-module mocking
For each of the 14 Titan modules, the cheapest path to a mock is in Titan / Testing / Modules. Some quick links:
| Module | Pattern |
ConfigModule | useValue ConfigService with get: () => ... |
LoggerModule | createNullLogger() from @omnitron-dev/titan/module/logger |
TitanDatabaseModule | In-memory SQLite or transaction-rollback Postgres |
TitanCacheModule | In-memory L1-only |
TitanRedisModule | ioredis-mock |
TitanAuthModule | useValue JWT service that returns canned claims |
TitanMetricsModule | 'memory' storage; assert via recordTyped spy |
TitanHealthModule | One indicator returning 'healthy' |
Each module ships a "Smallest mock" snippet on its page.
Best practices
Test at the lowest level that proves the behaviour. A unit test beats a module test beats an integration test for the same assertion — at 1000× speed.
One assertion per behavioural intent. Multiple
expects fine; multiple intents per test makes failures hard to read.Real DI for module tests. Mocking the container masks wiring bugs that production will hit.
Real Application for integration tests. Mocking lifecycle masks ordering bugs.
Transaction-rollback for DB tests. Faster + isolated. No cleanup boilerplate.
disableGracefulShutdown: trueon testApplication.createto skip signal handler installation.Run tests in parallel. Vitest does this by default — keep tests independent (no shared module state, no shared DB rows).
Anti-patterns
Mocking the framework. Mocking
ApplicationorContainermeans you're not testing your code — you're testing the mock.Sleeping for async. Use Vitest fake timers or
await someEvent—await delay(100)is a flake factory.End-to-end-only. E2E catches integration bugs but slows the dev loop. Push assertions down the pyramid.
Test files alongside
dist/. Tests should run from source; compiled tests drift.
See also
Testing package reference —
@omnitron-dev/testingAPITitan / Testing — framework-level guide
Titan / Testing / Modules — per-module mock recipes
Netron React testing —
TestNetronProvider