MockComponent API ​
Asena's built-in testing utilities provide automated dependency mocking for components using dependency injection. The mockComponent and mockComponentAsync functions automatically discover and mock all injected dependencies.
Quick Start ​
import { mockComponent } from '@asenajs/asena/test';
import { Service } from '@asenajs/asena/decorators';
import { Inject } from '@asenajs/asena/decorators/ioc';
import { mock } from 'bun:test';
@Service()
class UserService {
async createUser(name: string, email: string): Promise<{ id: string; name: string; email: string }> {
// implementation
}
}
@Service()
class AuthService {
@Inject(UserService)
private userService!: UserService;
async register(name: string, email: string, password: string) {
const user = await this.userService.createUser(name, email);
return { user, token: 'jwt-token' };
}
}
// Test
describe('AuthService', () => {
test('should register user', async () => {
const { instance, mocks } = mockComponent(AuthService);
// Configure mock behavior
mocks.userService.createUser.mockResolvedValue({
id: 'user-123',
name: 'John Doe',
email: 'john@example.com'
});
// Test
const result = await instance.register('John', 'john@example.com', 'pass');
expect(result.user.id).toBe('user-123');
expect(mocks.userService.createUser).toHaveBeenCalledWith('John', 'john@example.com');
});
});API Reference ​
mockComponent ​
Creates a component instance with all dependencies automatically mocked.
function mockComponent<T extends object>(
ComponentClass: new (...args: any[]) => T,
options?: MockComponentOptions
): MockedComponent<T>Parameters:
ComponentClass- The component class to instantiateoptions- Optional configuration (see MockComponentOptions)
Returns: MockedComponent<T>
Example:
const { instance, mocks } = mockComponent(PaymentService);mockComponentAsync ​
Asynchronous version of mockComponent for components with async postConstruct hooks.
async function mockComponentAsync<T extends object>(
ComponentClass: new (...args: any[]) => T,
options?: MockComponentOptions
): Promise<MockedComponent<T>>Parameters: Same as mockComponent
Returns: Promise<MockedComponent<T>>
Example:
import { Service } from '@asenajs/asena/decorators';
import { Inject } from '@asenajs/asena/decorators/ioc';
@Service()
class ConnectionPool {
async connect() {
// implementation
}
}
@Service()
class DatabaseService {
@Inject(ConnectionPool)
private pool!: ConnectionPool;
async initialize() {
await this.pool.connect();
}
}
const { instance, mocks } = await mockComponentAsync(DatabaseService, {
postConstruct: async (inst) => {
await inst.initialize();
}
});MockComponentOptions ​
Configuration options for component mocking.
interface MockComponentOptions {
// Only mock specific fields (optional)
injections?: string[];
// Provide custom mocks instead of auto-generated ones (optional)
overrides?: Record<string, any>;
// Your own callback, run after injection (optional, can be async).
// NOT the component's @OnStart - see below
postConstruct?: (instance: any) => void | Promise<void>;
}Properties:
injections ​
Array of field names to mock. Other fields will not be mocked.
const { instance, mocks } = mockComponent(PaymentService, {
injections: ['stripe'] // Only mock stripe
});
expect(mocks.stripe).toBeDefined();
expect(mocks.logger).toBeUndefined();overrides ​
Custom mock objects to use instead of auto-generated mocks.
import { mock } from 'bun:test';
const customMock = {
createUser: mock(async () => ({ id: 'custom-id' }))
};
const { instance, mocks } = mockComponent(AuthService, {
overrides: { userService: customMock }
});
// mocks.userService is now your custom mock
expect(mocks.userService).toBe(customMock);An override is the final value injected into the field:
- For expression-based injections (e.g.
@Inject(ulak('/chat'))or@Inject(UserService, (s) => s.createUser)), the expression is skipped entirely — your override is used as-is. - For
@Valuefields, the environment is not read at all — see below. - Presence is checked with
Object.hasOwn, so falsy values (0,'',null,undefined) are injected as-is rather than ignored.
Overriding @Value fields ​
mockComponent resolves @Value fields with exactly the container's precedence — override > field initializer > environment — so a unit test can pin configuration without touching process.env:
@Service()
class RetryPolicy {
@Value('MAX_RETRIES', { parse: Number, default: 3 })
private maxRetries: number;
@Value('API_KEY') // required: no default
private apiKey: string;
}
const { instance } = mockComponent(RetryPolicy, {
overrides: { maxRetries: 7, apiKey: 'test-key' },
});An overridden field is never read from the environment, so a required @Value with no default does not fail the test when the variable is unset. Resolved values are plain data, not doubles, so they do not appear in mocks.
postConstruct ​
A callback of yours, run after the dependencies are injected.
const { instance, mocks } = mockComponent(AuthService, {
postConstruct: (instance) => {
console.log('Component ready for testing');
}
});It is not the component's @OnStart
mockComponent builds the instance directly — it never goes through the container or the server, so the component's own @OnStart / @OnStop hooks are not invoked. This option is the hook you would otherwise write inline; if you want the real start hook, call it yourself:
const { instance } = await mockComponentAsync(DatabaseService, {
postConstruct: async (inst) => inst.onStart(), // the @OnStart method, called explicitly
});For hooks running in their real order against a real container, use createTestApp.
Async Support
The postConstruct hook can be async. Use mockComponentAsync when you need to await the hook.
MockedComponent<T> ​
Return type of mockComponent and mockComponentAsync.
interface MockedComponent<T> {
instance: T; // Component instance with injected mocks
mocks: Record<string, any>; // Object containing all mock dependencies
}Properties:
instance- The component instance with all dependencies injectedmocks- Object where keys are field names and values are mock objects
Advanced Usage Patterns ​
Selective Mocking ​
Mock only specific dependencies while leaving others undefined.
import { Service } from '@asenajs/asena/decorators';
import { Inject } from '@asenajs/asena/decorators/ioc';
@Service()
class StripeClient {
async charge(amount: number) {
// implementation
}
}
@Service()
class LoggerService {
log(message: string) {
// implementation
}
}
@Service()
class PaymentService {
@Inject(StripeClient)
private stripe!: StripeClient;
@Inject(LoggerService)
private logger!: LoggerService;
}
const { instance, mocks } = mockComponent(PaymentService, {
injections: ['stripe'] // Only mock stripe, logger remains undefined
});
expect(mocks.stripe).toBeDefined();
expect(mocks.logger).toBeUndefined();Custom Overrides ​
Provide your own mock implementations for specific dependencies.
import { mock } from 'bun:test';
const customUserService = {
createUser: mock(async (name: string, email: string) => ({
id: 'custom-id',
name,
email
})),
deleteUser: mock(async (id: string) => true)
};
const { instance, mocks } = mockComponent(AuthService, {
overrides: {
userService: customUserService
}
});
// mocks.userService is now your custom mock
expect(mocks.userService).toBe(customUserService);Combining Options ​
You can combine injections, overrides, and postConstruct together.
const { instance, mocks } = mockComponent(AuthService, {
injections: ['userService', 'emailService'],
overrides: {
userService: customUserService
},
postConstruct: (inst) => {
inst.setTestMode(true);
}
});Expression Transformations ​
mockComponent supports @Inject expression transformations automatically. When an expression field is not overridden, the expression is evaluated against a deep mock: every property access yields a Bun mock function and every call returns another chainable deep mock, so any expression works without a running application.
import { Service } from '@asenajs/asena/decorators';
import { Inject } from '@asenajs/asena/decorators/ioc';
@Service()
class UserService {
async createUser(name: string, email: string): Promise<{ id: string; name: string; email: string }> {
// implementation
}
}
@Service()
class AuthService {
@Inject(UserService, (service) => service.createUser)
private createUserFn!: (name: string, email: string) => Promise<User>;
}
const { instance, mocks } = mockComponent(AuthService);
// The expression is applied automatically - createUserFn is a real Bun mock
mocks.createUserFn.mockResolvedValue({ id: 'user-123' });
expect(mocks.createUserFn).toHaveBeenCalledWith('John', 'john@example.com');If the field is provided via overrides, the expression is skipped and your override is injected as-is.
Testing Services with Ulak Injections ​
Services that inject scoped namespaces with the ulak() helper are testable without a running WebSocket broker.
import { Service } from '@asenajs/asena/decorators';
import { Inject } from '@asenajs/asena/decorators/ioc';
import { ulak, type Ulak } from '@asenajs/asena/messaging';
@Service('UserService')
export class UserService {
@Inject(ulak('/ws/public/stats'))
private statsChannel: Ulak.NameSpace<'/ws/public/stats'>;
async createAnonUser(name: string) {
// ...create the user...
await this.statsChannel.broadcast({ action: 'update', data: { newUser: 1 } });
}
}Variant A — automatic deep mock. No configuration needed; the namespace methods are assertable Bun mocks:
import { describe, expect, test } from 'bun:test';
import { mockComponent } from '@asenajs/asena/test';
test('broadcasts stats on user creation', async () => {
const { instance, mocks } = mockComponent(UserService);
await instance.createAnonUser('John');
expect(mocks.statsChannel.broadcast).toHaveBeenCalledWith({
action: 'update',
data: { newUser: 1 }
});
});Variant B — typed stub via createTestUlakStub. Use an explicit override when you want a fully typed Ulak.NameSpace mock:
import { createTestUlakStub, mockComponent } from '@asenajs/asena/test';
test('broadcasts stats on user creation', async () => {
const statsChannel = createTestUlakStub('/ws/public/stats');
const { instance } = mockComponent(UserService, {
overrides: { statsChannel }
});
await instance.createAnonUser('John');
expect(statsChannel.broadcast).toHaveBeenCalledWith({
action: 'update',
data: { newUser: 1 }
});
});createTestUlakStub stays in sync with the framework
createTestUlakStub implements the full Ulak.NameSpace interface (broadcast, to, toSocket, toMany, getSocketCount) with Bun mocks, so it stays in sync with the framework at compile time.
Testing Components with Inheritance ​
mockComponent properly handles prototype chains.
import { Service } from '@asenajs/asena/decorators';
import { Inject } from '@asenajs/asena/decorators/ioc';
@Service()
class LoggerService {
log(message: string) {
// implementation
}
}
@Service()
class DatabaseService {
query(sql: string) {
// implementation
}
}
@Service()
class BaseService {
@Inject(LoggerService)
protected logger!: LoggerService;
}
@Service()
class UserService extends BaseService {
@Inject(DatabaseService)
private database!: DatabaseService;
}
const { instance, mocks } = mockComponent(UserService);
// Both inherited and own dependencies are mocked
expect(mocks.logger).toBeDefined();
expect(mocks.database).toBeDefined();Technical Details ​
How It Works ​
- Metadata Discovery - Reads the same metadata that Asena's IoC Container uses (
ComponentConstants.DependencyKey,ComponentConstants.DependencyClassKeyandComponentConstants.ExpressionKey) - Mock Generation - Uses Bun's native
mock()function to create mocks- Automatically detects async methods and creates
mock(async () => null) - Sync methods get
mock(() => undefined)
- Automatically detects async methods and creates
- Injection - Injects mocks into the component instance
- Expression Support - Evaluates expression transformations against a deep mock (unless the field is overridden, in which case the override is injected as-is)
What each field receives ​
The mock a field gets depends on how it was injected:
| Injection | Mock |
|---|---|
@Inject(UserService) | An object shaped like the class — every method is a bun:test mock |
@Inject(ulak('/chat')) and other expression injections | The expression evaluated against a deep mock, so any call chain works and stays assertable |
@Inject('UserService') | A plain {} — a string carries no class reference, so no method shape can be derived |
@Value('KEY') | Not a mock at all — the real resolved value, with the container's overrides > initializer > environment precedence |
String injections need overrides
Only class-based injections can be auto-shaped. For @Inject('UserService') pass the double yourself:
mockComponent(LegacyService, {
overrides: { userService: { findById: mock(async () => ({ id: '1' })) } },
});Zero Dependencies ​
This feature follows Asena's zero-dependency philosophy:
- Uses only Bun's native
mock()function frombun:test - No external testing libraries required
- Fully compatible with Bun's test runner
Import Path ​
import {
mockComponent,
mockComponentAsync,
createMockFromClass,
createDeepMock,
createTestUlakStub
} from '@asenajs/asena/test';Package export configuration:
{
"exports": {
"./test": {
"import": "./dist/lib/test/index.js",
"types": "./dist/lib/test/index.d.ts"
}
}
}Related ​
- Testing Overview - Introduction to testing in Asena
- createTestApp - Full-application testing with real HTTP
- createWebTest - Controller-slice testing (note:
mocksthere is keyed by service name, not field name) - Examples - Real-world testing patterns
- Dependency Injection - Understanding DI in Asena
- Bun Test Documentation - Learn more about Bun's test runner