Skip to main content

@omnitron-dev/testing

pnpm add -D @omnitron-dev/testing

Cross-runtime testing helpers. Same test source runs on Vitest (Node + Bun) and Deno test. Provides typed mock primitives, async helpers, runtime detection, and Titan-specific test glue.

Verified against packages/testing/src/.

What's inside

packages/testing/src/
├── async/ # Promise + timer + event helpers for tests
├── docker/ # Helpers to spin up Postgres / Redis in tests
├── helpers/ # Generic test helpers
├── performance/ # Benchmarks & latency utilities
├── runtime/ # Runtime adapters (vitest / jest / bun / deno)
├── titan/ # Titan-specific test utilities (Application, DI overrides)
├── env.ts # Test environment helpers
├── errors.ts # Error-matching helpers
└── globals.d.ts # Vitest / Jest globals (no import)

Runtime detection

import { RUNTIME, loadRuntimeAdapter } from '@omnitron-dev/testing';

RUNTIME; // 'node' | 'bun' | 'deno'

const adapter = await loadRuntimeAdapter();
adapter.test('my test', () => { /* ... */ });
adapter.expect(actual).toBe(expected);

The same test code path runs unchanged on Node + Bun (via Vitest) and Deno (via Deno's native Deno.test).

Typed mock function

import type { MockFunction } from '@omnitron-dev/testing';

const fetchMock: MockFunction<typeof fetch> = vi.fn();

fetchMock.mockResolvedValue(new Response('hello'));
await someCode(fetchMock);

expect(fetchMock).toHaveBeenCalledWith('/api/foo');
expect(fetchMock.mock.calls).toHaveLength(1);
expect(fetchMock.mock.lastCall()).toEqual(['/api/foo']);

MockFunction<T> preserves T's type so mock.calls[0] is the inferred parameter tuple, not any[].

Async helpers — async/

eventually(predicate, opts?)

Poll until a condition is true (or timeout):

import { eventually } from '@omnitron-dev/testing';

await eventually(() => queue.size === 0, {
timeout: 5_000,
interval: 50,
message: 'queue did not drain',
});

Default timeout 5 s, default poll interval 50 ms. Throws with the message if the deadline passes.

waitForEvent(emitter, event, opts?)

import { waitForEvent } from '@omnitron-dev/testing';

const [user] = await waitForEvent(bus, 'user.created', { timeout: 2_000 });
expect(user.email).toBe('a@b.c');

Resolves with the event args when fired; rejects on timeout.

flushPromises()

import { flushPromises } from '@omnitron-dev/testing';

doSomethingThatScheduledMicrotasks();
await flushPromises(); // microtask queue drained
expect(someState).toBe(...);

Useful between synchronous-trigger and async-effect when you need everything queued to run.

withTimeout(promise, ms)

const result = await withTimeout(longRunning(), 3_000);
// → throws TimeoutError if longRunning takes >3s

Error helpers — errors.ts

import { expectThrows, expectThrowsAsync } from '@omnitron-dev/testing';

expectThrows(() => parse(bad), ValidationError, /invalid email/);
await expectThrowsAsync(() => service.do(), {
type: TitanError,
code: 'NOT_FOUND',
message: /user not found/,
});

More expressive than try/catch + expect.fail boilerplate.

Titan-specific glue — titan/

createTestApp(options)

import { createTestApp } from '@omnitron-dev/testing/titan';
import { AppModule } from '../src/app.module.js';

describe('users service', () => {
let app: TestApp;

beforeEach(async () => {
app = await createTestApp({
modules: [AppModule],
overrides: [{ provide: MAILER_TOKEN, useClass: FakeMailer }],
database: 'memory', // or 'rollback' or { url: '...' }
logger: 'null', // or 'console' or your own
});
});

afterEach(async () => {
await app.dispose(); // cleans DB, stops the Application
});

it('invites a user', async () => {
const users = await app.resolve(UsersService);
await users.invite({ email: 'a@b.c' });
// ...
});
});

createTestApp wraps Application.create with sensible test defaults:

  • disableGracefulShutdown: true
  • in-memory DB by default
  • null logger by default
  • transaction-rollback wrapper if database: 'rollback'

transactionRollback

import { transactionRollback } from '@omnitron-dev/testing/titan';

it('writes a user',
transactionRollback(async (db) => {
await db.insertInto('users').values({...}).execute();
const u = await db.selectFrom('users').selectAll().executeTakeFirst();
expect(u.email).toBe('a@b.c');
// Rolled back automatically; next test sees clean DB.
}),
);

Wraps the test body in BEGIN ... ROLLBACK. Faster than truncate + reseed.

Docker helpers — docker/

For integration tests that need real Postgres / Redis without manual setup:

import { startPostgres, startRedis, stopAll } from '@omnitron-dev/testing/docker';

beforeAll(async () => {
await startPostgres({ port: 5433, database: 'test' });
await startRedis({ port: 6380 });
});

afterAll(async () => {
await stopAll();
});

Uses Docker behind the scenes; reuses long-lived containers across test runs in dev. Skips on CI environments that already provide services.

Performance helpers — performance/

import { measure, expectFasterThan, bench } from '@omnitron-dev/testing/performance';

it('parser is fast', async () => {
const result = await measure(() => parse(LARGE_INPUT));
expect(result.durationMs).toBeLessThan(50);
});

it('beats baseline', async () => {
await expectFasterThan(() => myImpl(), () => referenceImpl(), { runs: 100 });
});

// Standalone bench:
bench('parser variants', {
v1: () => parseV1(input),
v2: () => parseV2(input),
v3: () => parseV3(input),
}, { runs: 1_000 });

Inline microbenchmarks alongside tests — not a replacement for a real benchmark suite, but useful for regression catches.

Env helpers — env.ts

import { withEnv, mockProcessEnv } from '@omnitron-dev/testing';

withEnv({ NODE_ENV: 'test', DATABASE_URL: 'postgres://test' }, async () => {
// process.env temporarily mutated; restored on return
});

const restore = mockProcessEnv({ JWT_SECRET: 'test' });
// ... test ...
restore();

Avoids leaking env changes across tests.

Vitest configuration baseline

// vitest.config.ts
import { defineConfig } from 'vitest/config';

export default defineConfig({
test: {
environment: 'node',
setupFiles: ['./test/setup.ts'],
include: ['src/**/*.test.ts', 'test/**/*.test.ts'],
coverage: { reporter: ['text', 'html', 'lcov'] },
pool: 'forks', // crash isolation
poolOptions: {
forks: { singleFork: false }, // parallel
},
testTimeout: 10_000,
hookTimeout: 30_000,
globals: false, // explicit imports
},
});

Cross-runtime tests — Node + Bun + Deno

// test/cross.test.ts (same file)
import { loadRuntimeAdapter } from '@omnitron-dev/testing';

const t = await loadRuntimeAdapter();

t.test('runs everywhere', () => {
t.expect(1 + 1).toBe(2);
});

Run with:

vitest run test/cross.test.ts # Node
bun test test/cross.test.ts # Bun
deno test test/cross.test.ts # Deno

Same source, three runtimes, identical assertions.

Best practices

  • Use createTestApp for any test that touches multiple modules — it does the lifecycle right.
  • Override at boundaries, not in the middle. Override the mailer (external boundary), don't override an internal service.
  • database: 'memory' for unit-y module tests; 'rollback' when behaviour depends on real Postgres semantics (RLS, advisory locks, jsonb queries).
  • waitForEvent for async assertions; avoid setTimeout.
  • Use expectThrowsAsync instead of try/catch + expect.fail — more readable, captures more info.

Anti-patterns

  • Sleeping for promises to resolve. Use flushPromises or eventually.
  • Shared Application across tests without proper reset. Mutated DI state leaks.
  • Real network in unit tests. Use mocks; reserve real for integration / E2E.
  • Tests that depend on order. Vitest parallelises; an order-dependent test is a future flake.

See also