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) for describe, it, test, expect, the four hooks and fakeTimers — a surface test in the package fails if the three adapters drift apart.

Mocking is the exception: it is vi on Node and Bun, mockFn on Deno. If your test mocks, that part is not portable as written.

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 — @omnitron-dev/testing/async

Also re-exported from the package root.

waitFor(condition, options?) / waitForCondition(condition, timeout?, interval?)

Poll until a predicate is true, or throw at the deadline:

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

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

Defaults: 5 s timeout, 50 ms interval, message 'Condition not met'. waitForCondition(fn, timeout, interval) is the same check with positional arguments.

waitForEvents(target, events, timeout?)

Wait for several events on an emitter at once, resolving with their payloads in the order requested:

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

const [created, indexed] = await waitForEvents(bus, ['user.created', 'user.indexed'], 2_000);
expect(created.email).toBe('a@b.c');

createEventSpy(target, event) and EventCollector

createEventSpy records every payload for one event and hands back { events, clear }. EventCollector does the same for several events and adds assertions:

import { EventCollector, createEventSpy } from '@omnitron-dev/testing';

const spy = createEventSpy(bus, 'user.created');
await service.invite({ email: 'a@b.c' });
expect(spy.events).toHaveLength(1);

const collector = new EventCollector(bus);
collector.collect('user.created').collect('user.deleted');
// …
collector.assertEmitted('user.created', 1);
collector.assertNotEmitted('user.deleted');
collector.stop();

EventListenerTracker

Registers listeners and removes all of them in one call — the usual cause of a leaking test suite is a listener nobody detached:

const tracker = new EventListenerTracker();
tracker.on(emitter, 'data', handler);
afterEach(() => tracker.cleanup());

flushPromises()

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

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

withTimeout(promise, ms) / retry(fn, options?) / delay(ms) / nextTick()

const result = await withTimeout(longRunning(), 3_000); // throws TimeoutError
const value = await retry(() => flakyCall(), { retries: 3, delay: 100, backoff: 2 });

RetryOptions is { retries?, delay?, backoff?, onRetry? }. retries defaults to 3, so a call that passes an unrecognised key still runs three times and looks like it worked — this page previously documented { attempts: 3 }, which is not read at all.

onRetry(error, attempt) fires between attempts, which is the hook to use when a flaky test needs to say WHY it retried rather than only that it passed.

createDeferred() / defer() — a promise resolved from outside

Two shapes of the same idea, and the difference is what you get back:

const d = defer<string>(); // { promise, resolve, reject } — a plain object
emitter.once('ready', () => d.resolve('ok'));
await d.promise;

const p = createDeferred<string>(); // a DeferredPromise<T> instance

defer() returns a bare Deferred<T>{ promise, resolve, reject }, no class. createDeferred() returns a DeferredPromise<T>, which adds one thing: the isSettled getter.

That is the whole difference, and it is worth stating plainly because the obvious guess is wrong. A second resolve is ignored by BOTH — that is ordinary Promise semantics, not something the class adds. What the class adds is the ability to ASK whether the promise has settled without awaiting it, which a test needs when it has to assert that something has NOT happened yet.

Use defer() unless you need that question answered.

Either is the right answer when a test must wait for something a callback signals, and the wrong answer when a condition can simply be polled — waitFor exists for that and does not leave a promise dangling if the event never arrives.

collectEvents(target, event, condition, timeout?)

Gathers emissions of one event until condition(events) returns true, then resolves with them. waitForEvents above waits for one of EACH named event; this waits for as many of ONE as the condition asks for.

createMockTimer() / MockTimerController

Drive time forward deterministically instead of sleeping.

Errors — errors.ts

The package exports its own error types, thrown by the helpers above:

import { TestingError, TimeoutError, NotFoundError, RetryError } from '@omnitron-dev/testing';

await expect(withTimeout(hangs(), 100)).rejects.toBeInstanceOf(TimeoutError);

For asserting that a call rejects, use assertRejects from @omnitron-dev/testing/helpers:

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

await assertRejects(service.load('missing'), /not found/);
await assertRejects(service.load('missing'), NotFoundError);

It accepts a string, a RegExp, or an error constructor.

Generic helpers — @omnitron-dev/testing/helpers

import { createTempDir, cleanupTempDir, suppressConsole, withFixture } from '@omnitron-dev/testing/helpers';

const dir = await createTempDir();
afterEach(() => cleanupTempDir(dir));

const restore = suppressConsole(); // silence console.* for one test
restore();

await withFixture(myFixture, async (instance) => { /* … */ });

Titan-specific glue — @omnitron-dev/testing/titan

createTestModule(options) / testModule()

Builds a container with your modules, providers and mocks, and can create an Application from it:

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

describe('users service', () => {
let mod: TestModule;

beforeEach(() => {
mod = createTestModule({
modules: [AppModule],
providers: [[MAILER_TOKEN, { useClass: FakeMailer }]],
mocks: [{ token: CLOCK_TOKEN, mock: fixedClock, spy: true }],
config: { name: 'test-app' },
});
});

afterEach(async () => {
await mod.cleanup();
});

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

resetMocks(), clearMocks() and restore() manage mock state between tests; createApplication() returns a started Application when the test needs the full lifecycle.

testModule() is the same thing with a fluent builder:

const mod = testModule()
.withModule(AppModule)
.withConfig({ name: 'test-app' })
.withAutoMock()
.build();

TestApplication

A thinner wrapper when you want the Application itself:

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

const app = new TestApplication({ name: 'test-app' });
await app.bootstrap(AppModule);

const users = app.get(UsersService);
await app.close();

Fixtures

TestSchemas, TestConfigs, TestRedisConfigs, TestModules, TestData and TestTiming are ready-made fixtures for the common shapes — see packages/testing/src/titan/test-fixtures.ts.

Docker helpers — @omnitron-dev/testing/docker

For integration tests that need a real Postgres or Redis. The API is three manager classes with static factory methods, each returning a DockerContainer you clean up yourself:

import { RedisTestManager, DatabaseTestManager } from '@omnitron-dev/testing/docker';

let redis: DockerContainer;
let postgres: DockerContainer;

beforeAll(async () => {
redis = await RedisTestManager.createRedisContainer({ port: 'auto' });
postgres = await DatabaseTestManager.createPostgresContainer({ port: 'auto' });
}, 60_000);

afterAll(async () => {
await redis.cleanup();
await postgres.cleanup();
});

port: 'auto' asks the OS for a free port, which is what lets several suites run in parallel; read the assigned port from container.ports.get(6379).

RedisTestManager also builds multi-node topologies — createRedisCluster() and createRedisSentinel() — and DockerTestManager is the lower-level driver the other two use.

Performance helpers — @omnitron-dev/testing/performance

import { PerfTimer, MemoryLeakDetector } from '@omnitron-dev/testing/performance';

const timer = new PerfTimer();
timer.mark('start');
await parse(LARGE_INPUT);
timer.mark('end');

expect(timer.measure('parse', 'start', 'end')).toBeLessThan(50);
// Repeated runs: timer.getAverage('parse'), timer.getPercentile('parse', 95)

A percentile over repeated runs is the useful shape here: a single wall-clock measurement compared against a flat millisecond bound measures the machine as much as the code.

Env helpers — @omnitron-dev/testing/env

The shared endpoints for the test stack. Ports deliberately differ from the defaults so a test run cannot reach a developer's own Redis or Postgres:

import {
TEST_REDIS_URL, // redis://localhost:16379
TEST_POSTGRES_URL, // postgresql://test:test@localhost:15432/test
testRedisUrl,
testPostgresUrl,
} from '@omnitron-dev/testing/env';

const url = testRedisUrl(5); // …:16379/5
const dbUrl = testPostgresUrl('other'); // …:15432/other

Every value is overridable through the matching environment variable (TEST_REDIS_HOST, TEST_REDIS_PORT, TEST_POSTGRES_*).

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