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:
import {
createTestService,
createTestController,
TestingModule,
useFakeTimers,
createMockConfig,
createMockLogger,
makeMockLoggerLayer,
createMockSyncLogger,
createRedisContainer,
createNatsContainer,
createPostgresContainer,
} from '@onebun/core/testing';Installation
@onebun/core/testing requires testcontainers as a peer dependency:
bun add -d testcontainersThis 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.
Unit Testing — createTestService / createTestController
For isolated unit testing of services and controllers without starting an HTTP server.
createTestService
Creates a service instance with a mock logger and mock config. Calls initializeService() internally, so this.logger and this.config are available in the service.
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
const { instance } = createTestService(UserService, {
config: { 'database.url': 'postgres://localhost/test' },
deps: [mockRepository, mockCacheService],
});Options:
config—Record<string, unknown>— values returned byconfig.get(path)deps—unknown[]— constructor arguments (injected dependencies)
Return type: TestInstanceResult<T>
instance: T— the created service instancelogger: SyncLogger— mock logger whose methods arebun:testmock()functions; the declared type is plainSyncLogger, so cast (logger.info as Mock<SyncLogger['info']>) before asserting on.mock.callsconfig: IConfig— mock config
createTestController
Same API as createTestService, but calls initializeController() instead.
import { createTestController } from '@onebun/core/testing';
const { instance, logger, config } = createTestController(UserController, {
deps: [mockUserService],
});Integration Testing — TestingModule
For full integration testing with a real HTTP server, middleware pipeline, and DI.
Basic Usage
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 includeproviders— service/provider classes to includeimports— pre-decorated@Module()classes to import
.overrideProvider(ServiceClass)
Replaces a service with a mock. Returns an override builder:
// 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.):
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.
.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:
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:
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.close()
Stops the test server and releases resources. Always call this in afterEach or afterAll.
Testcontainers — createRedisContainer / createNatsContainer / createPostgresContainer
Helpers for spinning up Redis and NATS containers in tests. Requires Docker.
createRedisContainer
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:
image— Docker image (default:redis:7-alpine)startupTimeout— timeout in ms (default:30000)
createNatsContainer
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:
image— Docker image (default:nats:2.10-alpine)startupTimeout— timeout in ms (default:30000)enableJetStream— enable JetStream (default:false)
createPostgresContainer
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,60000under CI)database— database to create (default:onebun_test)username— role to create (default:onebun)password— its password (default:onebun)
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.
TestContainer interface
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.
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 bymsmilliseconds, executing every timer that falls due in that span — an interval fires once per period crossed, so a 100 ms interval ticks 10 times underadvanceTime(1000)runAllTimers()— run until nosetTimeoutis left pending. Only timeouts choose how far time jumps, but the jump goes throughadvanceTime, so intervals still fire along the way and remain pending afterwards: a 100 ms interval next to a 1000 ms timeout ticks 10 timesnow()— get current fake timegetTimerCount()— get number of pending timersclearAllTimers()— clear all pending timers without executingrestore()— restore real timers
createMockConfig
Creates a mock IConfig object for testing. Returns values from the provided map via get(path).
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; // trueOptions:
values—Record<string, unknown>— config valuesoptions.isInitialized— whether config is initialized (default:true)
createMockLogger
Creates a silent async Logger (Effect-based). All methods return Effect.succeed(undefined), child() returns itself.
import { createMockLogger } from '@onebun/core/testing';
const logger = createMockLogger();
// Use with Effect programs that require LoggermakeMockLoggerLayer
Creates an Effect Layer that provides a silent mock logger. Used internally by TestingModule.
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.
import { createMockSyncLogger } from '@onebun/core/testing';
const logger = createMockSyncLogger();
logger.info('this does nothing');
logger.child({ context: 'test' }); // returns same logger