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.
  • For @Value fields, 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:

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

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

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.

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

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.