createTestApp
createTestApp boots a complete Asena application inside a test: the IoC container, every bootstrap phase, the adapter and its real routing pipeline. It is the equivalent of Spring Boot's @SpringBootTest.
Where mockComponent tests a single class in isolation, createTestApp tests how your components behave once the framework has wired them together and a real request comes in.
import { createTestApp, silentLogger } from '@asenajs/asena/test';
import { createHonoAdapter } from '@asenajs/hono-adapter';
import { describe, test, expect } from 'bun:test';
describe('UserController', () => {
test('lists users', async () => {
const [adapter] = createHonoAdapter({ logger: silentLogger });
await using app = await createTestApp({
adapter,
components: [UserController, UserService],
});
await app.get('/api/users').expectStatus(200).expectJsonContains({ total: 3 });
});
});Options
interface TestAppOptions {
adapter: AsenaAdapter; // required
components: Class[]; // required - skips filesystem scanning entirely
imports?: (Class | readonly Class[])[]; // package components, in addition to `components`
overrides?: Record<string, object>; // service name -> replacement instance
logger?: ServerLogger; // default: silentLogger
port?: number; // default: 0 (Bun picks a free port)
dispatch?: 'server' | 'socket'; // default: 'server'
}components
Passing an explicit component list skips config-based filesystem scanning, so a test boots only what it names.
You name the roots — typically the controllers. Every class reachable from them through @Inject(SomeClass) is walked and registered for real, so the injection closure does not have to be spelled out by hand:
@Controller('/api/users')
class UserController {
@Inject(UserService)
private userService: UserService; // UserService injects UserRepository, which injects Db
}
await using app = await createTestApp({
adapter,
components: [UserController], // UserService, UserRepository and Db come along
});Three rules govern the walk:
- Only
@Inject(Class)edges are followed. A dependency injected by name —@Inject('UserService')— has no class reference to follow, so it must be listed incomponentsor replaced throughoverrides. - A name in
overridesstops the walk. The double replaces the real class, so nothing behind it is registered. @Strategyfields are not walked. They live under a different metadata key, and an empty strategy key is a legitimate plugin point injected as[], not a missing dependency.
A class registered under an @Implements interface key counts as providing that key too, so listing the implementation satisfies a dependency injected by the interface name.
Missing dependencies fail before the boot
Anything the walk cannot satisfy is collected and reported before anything starts, with one line per problem naming the component and the field:
createTestApp: missing dependencies:
UserController.userService injects 'UserService', which is not in components or overrides
OrderService.mailer injects Mailer, which is not a decorated componentInside the container the corresponding failure names the dependent as well: 'MailService' is not registered (injected into OrderService.mail), with the original error attached as cause.
imports
Components that ship inside a package cannot be reached by a filesystem scan and are awkward to enumerate by hand. Pass them as imports instead — they are registered in addition tocomponents, and each entry must carry its own component decorator:
await using app = await createTestApp({
adapter,
components: [OrderController],
imports: [OtelService],
});See imports for the full contract.
port
Defaults to 0, which asks Bun for a free ephemeral port. Read the bound port back from app.port:
const app = await createTestApp({ adapter, components });
console.log(app.port); // e.g. 43117
console.log(app.baseUrl); // http://localhost:43117No more random-port collisions
Hand-rolled helpers usually pick 10000 + Math.random() * 50000 and occasionally collide when suites run in parallel. port: 0 removes the race entirely — the kernel hands out a port that is guaranteed free.
Replacing components with mocks
overrides maps a registered service name to a replacement instance — Spring's @MockBean:
const userService = {
getAll: mock(async () => [{ id: '1', name: 'Ada' }]),
};
await using app = await createTestApp({
adapter,
components: [UserController, UserService],
overrides: { UserService: userService },
});
await app.get('/api/users').expectStatus(200).expectJson([{ id: '1', name: 'Ada' }]);
expect(userService.getAll).toHaveBeenCalledTimes(1);Overrides are seeded before any user component is registered, so:
- the real class is never constructed, so its
@OnStart(and its deprecated@PostConstructalias) never runs - every dependent captures the double, because Asena builds injection closures eagerly at registration time
What can and cannot be overridden
| ✅ Services, repositories, components | The normal case |
| ❌ Core services | Container, ServerLogger, __Ulak__, EventEmitter, … are wired during bootstrap phases 1–5 and have already captured their dependencies. Attempting it throws with a clear message. |
| ❌ Controllers | A plain object carries no @Controller metadata, so an overridden controller's routes would never be registered. Override the services it depends on instead. |
⚠️ @Strategy arrays | Overriding an interface name to displace one member of a strategy array is not supported. |
Do not assign to an injected field
@Inject and @Strategy install accessors with no setter, so Object.assign(instance, { dep: fake }) throws. The error names the field and the class and points back here. Use overrides, or mockComponent() for a unit-level double.
If a component is registered under a custom name (@Service('Mailer')), override it by that name.
Fluent HTTP assertions
app.get() / .post() / … return a TestHttpCall. Nothing is sent until the call is awaited, assertions run in the order they were chained, and the send is memoized so awaiting twice does not issue a second request.
await app.post('/api/users', { body: JSON.stringify({ name: 'Ada' }) })
.expectStatus(201)
.expectHeader('content-type', /json/)
.expectJsonContains({ name: 'Ada' });| Method | Assertion |
|---|---|
expectStatus(code) | Status code. On failure the message includes the method, URL and response body. |
expectHeader(name, value) | Header equals a string, or matches a RegExp. |
expectJson(expected) | Whole JSON body deep-equals expected. |
expectJsonContains(partial) | JSON body contains at least these properties. |
expectBody(expected) | Raw text body equals a string, or matches a RegExp. |
expect(fn) | Escape hatch — receives the buffered response. |
Awaiting the call resolves to a TestHttpResponse whose body is already buffered, so it can be read as many times and as many ways as you like:
const response = await app.get('/api/users').expectStatus(200);
expect(response.json<User[]>()).toHaveLength(3);
expect(response.text()).toContain('Ada');
expect(response.headers.get('x-total')).toBe('3');
expect(response.raw).toBeInstanceOf(Response);Cleanup
app.stop() is idempotent. The app also implements Symbol.asyncDispose, so await using cleans up automatically even when a test throws:
test('...', async () => {
await using app = await createTestApp({ adapter, components });
// no afterEach needed
});Dispatch modes
'server' (default)
Listens on a TCP port. Identical to production.
'socket'
Listens on a unix domain socket instead. It is still the adapter's real routing pipeline — Bun's own router, real cookies, real validators, real WebSocket upgrades — but no TCP port is used at all, so parallel suites can never collide.
await using app = await createTestApp({
adapter,
components,
dispatch: 'socket',
});
expect(app.port).toBe(0);
expect(app.socketPath).toMatch(/\.sock$/);
await app.get('/api/users').expectStatus(200);WebSocket URLs differ between modes, so build them with app.wsUrl() and the same test works in both:
const socket = new WebSocket(app.wsUrl('/ws/chat'));
// 'server' -> ws://localhost:43117/ws/chat
// 'socket' -> ws+unix:///tmp/asena-test-1234-1.sock:/ws/chatAdapter support
Socket dispatch requires an adapter that honours AsenaStartOptions.unix. The official @asenajs/hono-adapter and @asenajs/ergenecore both do.
The container
const service = await app.resolve<UserService>('UserService');
expect(app.container.has('UserRepository')).toBe(true);Known behaviours
- Cron and schedules run for real.
cronRunner.startAll()executes as part of start-up, so a@Schedulecomponent in your test set will fire. @OnStartand@OnStoprun for real.createTestAppcallsserver.start()andapp.stop()callsserver.stop(), so a component's start hook runs before the first request and its stop hook runs during cleanup — which is what releases pools, subscribers and timers between test files.- A throwing
@OnStartfails the boot, not the process.createTestApp()rejects with an error naming the hook. Up to 0.9.x the container calledprocess.exit(1)instead, which reported0 pass / 1 failwith no indication of why. - Signals are not intercepted and the loop is not held open. The harness passes
shutdown: { signals: false }andkeepAlive: false, so booting twenty apps in one suite installs no listeners and nothing keeps the process alive after the last test. lib/testrequires Bun. The utilities importbun:testat module scope.
Related
- Component Lifecycle - What
start()andstop()run on your behalf - createWebTest - Controller-slice testing with automatic mocks
- MockComponent API - Unit-level dependency mocking
- Testing Overview - Introduction to testing in Asena