Skip to content

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 ​

typescript
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.

typescript
function mockComponent<T extends object>(
  ComponentClass: new (...args: any[]) => T,
  options?: MockComponentOptions
): MockedComponent<T>

Parameters:

  • ComponentClass - The component class to instantiate
  • options - Optional configuration (see MockComponentOptions)

Returns: MockedComponent<T>

Example:

typescript
const { instance, mocks } = mockComponent(PaymentService);

mockComponentAsync ​

Asynchronous version of mockComponent for components with async postConstruct hooks.

typescript
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:

typescript
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.

typescript
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.

typescript
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.

typescript
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.
  • Presence is checked with Object.hasOwn, so falsy values (0, '', null, undefined) are injected as-is rather than ignored.

postConstruct ​

A callback of yours, run after the dependencies are injected.

typescript
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:

typescript
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.

typescript
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 injected
  • mocks - Object where keys are field names and values are mock objects

Advanced Usage Patterns ​

Selective Mocking ​

Mock only specific dependencies while leaving others undefined.

typescript
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.

typescript
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.

typescript
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.

typescript
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.

typescript
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:

typescript
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:

typescript
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 }
  });
});

TIP

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.

typescript
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 ​

  1. Metadata Discovery - Reads the same metadata that Asena's IoC Container uses (ComponentConstants.DependencyKey, ComponentConstants.DependencyClassKey and ComponentConstants.ExpressionKey)
  2. 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)
  3. Injection - Injects mocks into the component instance
  4. 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:

InjectionMock
@Inject(UserService)An object shaped like the class — every method is a bun:test mock
@Inject(ulak('/chat')) and other expression injectionsThe 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

String injections need overrides

Only class-based injections can be auto-shaped. For @Inject('UserService') pass the double yourself:

typescript
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 from bun:test
  • No external testing libraries required
  • Fully compatible with Bun's test runner

Import Path ​

typescript
import {
  mockComponent,
  mockComponentAsync,
  createMockFromClass,
  createDeepMock,
  createTestUlakStub
} from '@asenajs/asena/test';

Package export configuration:

json
{
  "exports": {
    "./test": {
      "import": "./dist/lib/test/index.js",
      "types": "./dist/lib/test/index.d.ts"
    }
  }
}

Released under the MIT License.