@omnitron-dev/testing
pnpm add -D @omnitron-dev/testingCross-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
async/— Promise + timer + event helpers for testsdocker/— Helpers to spin up Postgres / Redis in testshelpers/— Generic test helpersperformance/— Benchmarks & latency utilitiesruntime/— Runtime adapters (vitest / jest / bun / deno)titan/— Titan-specific test utilities (Application, DI overrides)env.ts— Test environment helperserrors.ts— Error-matching helpersglobals.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> instancedefer() 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
Use the database and Redis helpers when an integration test needs a real service. Each factory returns a DockerContainer with its assigned ports and a cleanup() method.
import { RedisTestManager, DatabaseTestManager, type DockerContainer } 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();
});With port: 'auto', read the assigned loopback port from container.ports.get(6379). PostgreSQL and MySQL readiness probes use TCP so a temporary initialization socket cannot mark the database ready. A custom waitFor that specifies both port and healthcheck waits for both conditions.
Choose distinct names for parallel tests. A name collision fails before the helper can touch the existing container. Cleanup addresses the Docker ID that the manager created. You can attach tests to an existing network by name. The network stays with its owner.
In the Omnitron repository, pnpm test:ci starts a dedicated Compose project, runs the workspace suite, and removes that project's test resources. A failed startup, suite or teardown returns a nonzero exit status. When running two suites on the same host, assign different test ports as described in the root Compose file.
RedisTestManager also builds multi-node topologies —
createRedisCluster() and createRedisSentinel() — and
DockerTestManager is the lower-level driver the other two use.
For container commands, pass an array to preserve literal arguments, including
empty strings: command: ['redis-server', '--save', '']. A string deliberately
runs through sh -c; never interpolate untrusted values into that form. Docker
starts run asynchronously so one node cannot block another node's readiness
checks. If cluster creation fails, the helper waits for every attempted creation,
cleans all returned nodes, and removes its own network. Startup and cleanup
failures remain visible together.
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__LUMEN_COLON__15432/test
testRedisUrl,
testPostgresUrl,
} from '@omnitron-dev/testing/env';
const url = testRedisUrl(5); // …:16379/5
const dbUrl = testPostgresUrl('other'); // …:15432/otherEvery value is overridable through the matching environment variable
(TEST_REDIS_HOST, TEST_REDIS_PORT, TEST_POSTGRES_*).
Use the shared TEST_POSTGRES_URL in database tests instead of repeating a
fixed port. An explicit TEST_POSTGRES_URL takes precedence; the existing
TEST_DATABASE_URL CI variable remains accepted by the shared resolver.
testPostgresUrl(name) changes only the database name and preserves the selected
server, credentials, and query options. Omnitron's PostgreSQL integration probes
fail CI when that test service is unavailable. Local runs report the affected
suites as skipped and explain how to start the test infrastructure.
Workspace tests use Turbo's strict environment mode. The declared TEST_*,
OMNITRON_TEST_*, OMNITRON_HOME and USE_MOCK_REDIS settings reach test
processes and affect their cache keys. Docker connection settings and temporary
directories pass through explicitly. A custom test port therefore selects the
same endpoint in a direct package test and in the full workspace command.
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
maxWorkers: 2, // bounded concurrency
testTimeout: 10_000,
hookTimeout: 30_000,
globals: false, // explicit imports
},
});When tests enable globals, declare "types": ["node", "vitest/globals"] in
their TypeScript configuration. Package resolution supports both hoisted and
isolated dependency layouts; an explicit node_modules/vitest/globals.d.ts
include assumes one layout. Omnitron shares these options through
tsconfig.vitest.json. Keep the test tsBuildInfoFile separate from the build
cache so incremental checks cannot reuse diagnostics from a different file set.
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 # DenoSame source, three runtimes, identical assertions.
Best practices
Use
createTestAppfor 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).waitForEventfor async assertions; avoidsetTimeout.Use
expectThrowsAsyncinstead oftry/catch + expect.fail— more readable, captures more info.
Anti-patterns
Sleeping for promises to resolve. Use
flushPromisesoreventually.Shared
Applicationacross 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
Testing overview — the pyramid + when to use what
Integration patterns —
Application.createrecipesCross-runtime testing — Node + Bun + Deno
React component testing —
TestNetronProviderpatternscommon — promise helpers — building blocks
testingbuilds on