Skip to content

Testing ​

OneBun provides a set of testing utilities for unit and integration testing of services, controllers, and full application modules.

All testing utilities are exported from @onebun/core/testing:

typescript
import {
  createTestService,
  createTestController,
  createTestMiddleware,
  TestingModule,
  useFakeTimers,
  createMockConfig,
  createMockLogger,
  makeMockLoggerLayer,
  createMockSyncLogger,
  createRedisContainer,
  createNatsContainer,
  createPostgresContainer,
} from '@onebun/core/testing';

Installation ​

@onebun/core/testing requires testcontainers (version 10 or later) as a peer dependency:

bash
bun add -d testcontainers

This is deliberate, and it is not optional. OneBun treats integration tests against real dependencies as the default way to test a service — not as an advanced option — and ships the container helpers to make that the path of least resistance. A framework that made them conditional would be inviting the mock-everything alternative it exists to avoid.

Install it in devDependencies. The subpath keeps it out of @onebun/core's own entry point, so a production install (bun install --production) never pulls a Docker client, and nothing in your runtime bundle references it.

Running the container helpers additionally needs a Docker daemon (or a Podman socket) on the machine executing the tests. The rest of @onebun/core/testing — createTestService, TestingModule, useFakeTimers, the mock helpers — does not.

The helpers remove a container whose start failed only with testcontainers 10.3.0 or later. With 10.0–10.2 a failed start leaves its container behind; the labels option works on every testcontainers version. See Container labels and cleanup.

Unit Testing — createTestService / createTestController / createTestMiddleware ​

For isolated unit testing of services, controllers and middleware without starting an HTTP server.

createTestService ​

Creates a service instance with a mock logger and mock config, initialised the way the framework initialises one: the ambient init context is set before the constructor runs, so this.logger and this.config are available inside the constructor (right after super()) as well as in every method.

typescript
import { createTestService } from '@onebun/core/testing';
import type { Mock } from 'bun:test';
import type { SyncLogger } from '@onebun/logger';

const { instance, logger, config } = createTestService(UserService);

// Use the service
const result = instance.findById('123');

// Logger methods are bun:test mocks at runtime, but `logger` is declared as a plain
// SyncLogger — without the cast, `.mock` is a type error
expect((logger.info as Mock<SyncLogger['info']>).mock.calls).toHaveLength(1);

With config and dependencies ​

typescript
const { instance } = createTestService(UserService, {
  config: { 'database.url': 'postgres://localhost/test' },
  deps: [mockRepository, mockCacheService],
});

Options:

  • config — Record<string, unknown> — values returned by config.get(path)
  • deps — unknown[] — constructor arguments (injected dependencies)

Return type: TestInstanceResult<T>

  • instance: T — the created service instance
  • logger: SyncLogger — mock logger whose methods are bun:test mock() functions; the declared type is plain SyncLogger, so cast (logger.info as Mock<SyncLogger['info']>) before asserting on .mock.calls
  • config: IConfig — mock config

createTestController ​

Same API as createTestService, for a controller.

typescript
import { createTestController } from '@onebun/core/testing';

const { instance, logger, config } = createTestController(UserController, {
  deps: [mockUserService],
});

createTestMiddleware ​

Same API again, for a @Middleware(). Call use(req, next) on the instance directly — the middleware sees the config you passed and logs to the logger you get back.

typescript
import { createTestMiddleware } from '@onebun/core/testing';

const { instance, logger } = createTestMiddleware(AdminAuthMiddleware, {
  deps: [mockAuthService],
  config: { 'admin.token': 'secret' },
});

const next = async () => new Response('ok');
const response = await instance.use(request, next);

All three helpers are the same builder: each one initialises whichever framework base the class extends — service, controller, middleware, interceptor or WebSocket gateway. The three names exist so a test reads as what it builds.

A config option handed to a class that extends none of those and exposes no initialize* method throws: configuration that went nowhere would leave a middleware suite asserting fallback behaviour while this.config is undefined.

Integration Testing — TestingModule ​

For full integration testing with a real HTTP server, middleware pipeline, and DI.

Basic Usage ​

typescript
import { describe, it, expect, afterEach } from 'bun:test';
import { TestingModule, type CompiledTestingModule } from '@onebun/core/testing';

describe('UserController', () => {
  let module: CompiledTestingModule;

  afterEach(async () => {
    await module.close();
  });

  it('returns users', async () => {
    module = await TestingModule
      .create({
        controllers: [UserController],
        providers: [UserService],
      })
      .compile();

    const response = await module.inject('GET', '/users');
    expect(response.status).toBe(200);
  });
});

API ​

TestingModule.create(options) ​

Creates a new TestingModule builder.

  • controllers — controller classes to include
  • providers — service/provider classes to include
  • imports — pre-decorated @Module() classes to import

.overrideProvider(ServiceClass) ​

Replaces a service with a mock. Returns an override builder:

typescript
// Replace with a plain object
module = await TestingModule
  .create({ controllers: [UserController], providers: [UserService] })
  .overrideProvider(UserService).useValue({ findById: () => mockUser })
  .compile();

// Replace with another class
module = await TestingModule
  .create({ controllers: [UserController], providers: [UserService] })
  .overrideProvider(UserService).useClass(MockUserService)
  .compile();

The override is applied to every module before any provider is constructed, so it reaches controllers, services that inject the overridden class, and imported modules — including an imported module that provides the overridden class itself. The real provider is then not constructed at all.

.setOptions(options) ​

Sets additional application options (basePath, envSchema, cors, etc.):

typescript
module = await TestingModule
  .create({ controllers: [UserController], providers: [UserService] })
  .setOptions({ basePath: '/api', envSchema: myEnvSchema })
  .compile();

envSchema is required by any service that reads this.config in its constructor

Without it the application config is the not-initialized stub, and config.get() throws Configuration not initialized. Provide envSchema in ApplicationOptions. from inside the constructor. Module init only re-throws errors whose name starts with OneBun; anything else is logged — to TestingModule's silent mock logger, where you never see it — and the service is left unregistered. What surfaces is the dependent's failure instead:

DependencyResolutionError: Could not resolve dependency UserService for controller UserController.
  - Ensure UserService is decorated with @Service() and listed in a module's providers.

The service is decorated and is in providers; the missing envSchema is the real cause. A service that reads this.config only inside a method is unaffected at boot and throws into a 500 at request time instead.

.captureLogs() ​

Keeps what the framework logged during boot, instead of dropping it. module.logs then holds every line:

typescript
const module = await TestingModule
  .create({ imports: [AppModule] })
  .captureLogs()
  .compile();

const warnings = module.logs.filter((record) => record.level === 'warn');
expect(warnings.map((record) => record.message)).toEqual([]);

Each record is a RecordedLog — { level, message, args, context } — where context is the accumulated child() context. The module logger names itself { className: 'OneBunModule:AppModule' }, so a line can be attributed to the module that reported it.

This is how a startup diagnostic becomes something a test can assert: compiling the real AppModule through TestingModule and checking that no constructor parameter went unresolved, or that no global module was re-seeded. Without it the harness installs a silent logger, and a boot that prints nothing because nothing can print reads exactly like a boot with nothing to say.

module.logs is empty when captureLogs() was not called, and that emptiness means nothing — it is the silent logger's output. The opt-in exists so the two cannot be confused.

For an application constructed directly rather than through TestingModule, the same recorder is available on its own:

typescript
import { makeRecordingLoggerLayer } from '@onebun/core/testing';

const recorder = makeRecordingLoggerLayer();
const app = new OneBunApplication(AppModule, { loggerLayer: recorder.layer });
await app.start();

expect(recorder.messages('warn')).toEqual([]);
await app.stop();

child() on the recorder returns a logger writing to the same array with its context merged in. That is the part a hand-rolled double usually gets wrong: createMockLogger().child() returns the original object, so spreading it and overriding one method loses the override the moment the framework calls .child() — which it does for every module and every service.

.compile() ​

Starts the application on a random free port. Returns a CompiledTestingModule.

module.inject(method, path, options?) ​

Sends a real HTTP request to the test server:

typescript
const response = await module.inject('POST', '/users', {
  body: { name: 'Alice' },
  headers: { 'Authorization': 'Bearer token' },
  query: { include: 'profile' },
});

TIP

module here is the CompiledTestingModule from the Basic Usage example above. Do not name your own variable module outside a describe scope that declares it — at module top level it resolves to Node's global module, and module.inject is then a type error rather than a call.

module.get(ServiceClass) ​

Retrieves a service instance by class:

typescript
const service = module.get(UserService);
expect(service).toBeInstanceOf(UserService);

module.getApp() ​

Returns the underlying OneBunApplication instance.

module.getPort() ​

Returns the port the test server is listening on.

module.getConfig() ​

Returns the application config. Requires envSchema to be set via setOptions().

module.logs ​

Every line the framework logged, when .captureLogs() asked for it — otherwise empty. See .captureLogs().

module.close() ​

Stops the test server and releases resources. Always call this in afterEach or afterAll.

Testcontainers — createRedisContainer / createNatsContainer / createPostgresContainer ​

Helpers for spinning up Redis, NATS and PostgreSQL containers in tests. Requires Docker.

Each helper owns its container from the moment it is created: if the start fails anywhere after Docker create, the helper removes that container before rethrowing, and every helper accepts labels to mark what your suite starts — see Container labels and cleanup.

createRedisContainer ​

typescript
import { createRedisContainer, type TestContainer } from '@onebun/core/testing';

let redis: TestContainer;

beforeAll(async () => {
  redis = await createRedisContainer();
  // redis.url → 'redis://localhost:55123'
  // redis.host → 'localhost'
  // redis.port → 55123
});

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

Options (RedisContainerOptions):

  • image — Docker image (default: redis:7-alpine)
  • startupTimeout — timeout in ms (default: 30000, 60000 under CI)
  • labels — labels to put on the container, applied before it is created

createNatsContainer ​

typescript
import { createNatsContainer, type TestContainer } from '@onebun/core/testing';

let nats: TestContainer;

beforeAll(async () => {
  nats = await createNatsContainer({ enableJetStream: true });
  // nats.url → 'nats://localhost:55124'
});

afterAll(async () => {
  await nats.stop();
});

Options (NatsContainerOptions):

  • image — Docker image (default: nats:2.10-alpine)
  • startupTimeout — timeout in ms (default: 30000, 60000 under CI)
  • enableJetStream — enable JetStream (default: false)
  • labels — labels to put on the container, applied before it is created

createPostgresContainer ​

typescript
import { createPostgresContainer, type TestContainer } from '@onebun/core/testing';

let postgres: TestContainer;

beforeAll(async () => {
  postgres = await createPostgresContainer();
  // postgres.url → 'postgresql://onebun:onebun@localhost:55231/onebun_test'
});

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

Options (PostgresContainerOptions):

  • image — Docker image (default: postgres:16-alpine)
  • startupTimeout — timeout in ms (default: 30000, 60000 under CI)
  • database — database to create (default: onebun_test)
  • username — role to create (default: onebun)
  • password — its password (default: onebun)
  • labels — labels to put on the container, applied before it is created

It waits for the second "ready" line, deliberately

The postgres image starts a temporary server to run its initialisation scripts and logs database system is ready to accept connections for it, then shuts it down and starts the real one. Waiting for the first line returns a URL that is about to stop working, and the failure lands in whichever test connects first rather than in the helper.

Container labels and cleanup ​

A failed start cleans up after itself. testcontainers removes a container on its own only when the wait strategy gives up. A failure earlier — the OCI runtime refusing to start the process, or the port inspection after the start — leaves the container behind, created or even running, with no handle to stop it. So every helper call stamps its container with a label of its own, dev.onebun.testing.owner=<fresh UUID>, before it is created, and when the start fails it removes exactly the containers carrying that label. Containers of other calls, other suites and other harnesses are never touched. The cleanup needs testcontainers 10.3.0 or later, the first release that exports getContainerRuntimeClient; it is used when it is there. With 10.0–10.2 a failed start attempts no cleanup: the start error is rethrown unchanged and the container is left behind. Your labels and the owner label are applied on every testcontainers version.

The error you see is always the start error itself — same object, class and message. With no container runtime at all it is still testcontainers' Could not find a working container runtime strategy. If the cleanup fails as well, the start error carries a containerCleanupFailure property (ContainerCleanupFailure): ownerLabel is the key=value filter that finds what was left behind, and error is why listing or removing it failed.

labels (since 0.8.2) mark everything a suite starts. They are applied before create, so a container is findable from its first moment — including one a run that was killed half-way through a start left behind. Without Ryuk (TESTCONTAINERS_RYUK_DISABLED=true) nothing else removes such a container, and pruning everything labelled org.testcontainers would take other harnesses' containers with it. Sweep by your own label instead:

typescript
import { afterAll, beforeAll } from 'bun:test';
import { getContainerRuntimeClient } from 'testcontainers';

import { createPostgresContainer, type TestContainer } from '@onebun/core/testing';

// Everything this suite starts carries these labels from the moment it is created.
const HARNESS_LABELS = { 'com.example.harness': 'orders-integration' };

/** Force-remove every container, running or not, that carries all of `labels`. */
async function sweepContainers(labels: Record<string, string>): Promise<number> {
  const { dockerode } = (await getContainerRuntimeClient()).container;
  const leftovers = await dockerode.listContainers({
    all: true, // a container killed mid-start is `created`, not running
    filters: { label: Object.entries(labels).map(([key, value]) => `${key}=${value}`) },
  });
  await Promise.all(leftovers.map(async (info) => {
    await dockerode.getContainer(info.Id).remove({ force: true, v: true });
  }));

  return leftovers.length;
}

let postgres: TestContainer;

beforeAll(async () => {
  // Whatever an earlier, killed run of this suite left behind — and nothing anyone else started.
  await sweepContainers(HARNESS_LABELS);
  postgres = await createPostgresContainer({ labels: HARNESS_LABELS });
});

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

Sweeping at start assumes one run of the suite at a time on the host. When runs can overlap, give each run its own value (a CI job id, say) and sweep a dead run's value from somewhere that knows it died.

The recipe imports getContainerRuntimeClient, which testcontainers exports from 10.3.0 on. With 10.0–10.2 the labels are there all the same; sweep them with the same filter from outside the suite, for example docker ps -aq --filter label=com.example.harness=orders-integration | xargs -r docker rm -f -v.

TestContainer interface ​

typescript
interface TestContainer {
  url: string;                    // Full connection URL
  host: string;                   // Container host
  port: number;                   // Mapped port
  container: StartedTestContainer; // testcontainers instance
  stop(): Promise<void>;          // Stop and remove container
}

Other Utilities ​

useFakeTimers ​

Replaces global setTimeout, setInterval, clearTimeout, clearInterval, and Date.now with controllable fakes. Useful for testing timer-based logic without real delays.

typescript
import { useFakeTimers } from '@onebun/core/testing';
import { describe, it, expect, afterEach } from 'bun:test';

describe('TimerService', () => {
  const timers = useFakeTimers();

  afterEach(() => {
    timers.restore();
  });

  it('executes callback after delay', () => {
    let called = false;
    setTimeout(() => { called = true; }, 1000);

    timers.advanceTime(999);
    expect(called).toBe(false);

    timers.advanceTime(1);
    expect(called).toBe(true);
  });
});

Returned methods:

  • advanceTime(ms) — advance time by ms milliseconds, executing every timer that falls due in that span — an interval fires once per period crossed, so a 100 ms interval ticks 10 times under advanceTime(1000)
  • runAllTimers() — run until no setTimeout is left pending. Only timeouts choose how far time jumps, but the jump goes through advanceTime, so intervals still fire along the way and remain pending afterwards: a 100 ms interval next to a 1000 ms timeout ticks 10 times
  • now() — get current fake time
  • getTimerCount() — get number of pending timers
  • clearAllTimers() — clear all pending timers without executing
  • restore() — restore real timers

createMockConfig ​

Creates a mock IConfig object for testing. Returns values from the provided map via get(path).

typescript
import { createMockConfig } from '@onebun/core/testing';

const config = createMockConfig({
  'server.port': 3000,
  'server.host': '0.0.0.0',
});

config.get('server.port'); // 3000
config.isInitialized;      // true

Options:

  • values — Record<string, unknown> — config values
  • options.isInitialized — whether config is initialized (default: true)

createMockLogger ​

Creates a silent async Logger (Effect-based). All methods return Effect.succeed(undefined), child() returns itself.

typescript
import { createMockLogger } from '@onebun/core/testing';

const logger = createMockLogger();
// Use with Effect programs that require Logger

makeMockLoggerLayer ​

Creates an Effect Layer that provides a silent mock logger. Used internally by TestingModule.

typescript
import { makeMockLoggerLayer } from '@onebun/core/testing';

const loggerLayer = makeMockLoggerLayer();
// Use with Effect.provide(loggerLayer)

createMockSyncLogger ​

Creates a silent no-op SyncLogger. All methods are no-ops, child() returns itself.

typescript
import { createMockSyncLogger } from '@onebun/core/testing';

const logger = createMockSyncLogger();
logger.info('this does nothing');
logger.child({ context: 'test' }); // returns same logger

Released under the MPL-2.0 License.