di-framework latest Help

Testing

Learn how to test services effectively with di-framework.

Basic Testing Setup

Create isolated test containers to avoid polluting the global container:

import { Container as DIContainer } from '@di-framework/core/container'; import { describe, it, expect, beforeEach } from 'bun:test'; describe('UserService', () => { let testContainer: DIContainer; beforeEach(() => { // Create a fresh container for each test testContainer = new DIContainer(); }); it('should create user', () => { // Register services testContainer.register(DatabaseService); testContainer.register(UserService); // Resolve and test const userService = testContainer.resolve(UserService); expect(userService).toBeDefined(); }); });

Mocking Dependencies

Replace real services with mock implementations:

// Mock implementation class MockDatabaseService { query(sql: string) { return { rows: [{ id: '1', name: 'Test User' }] }; } } describe('UserService', () => { let testContainer: DIContainer; beforeEach(() => { testContainer = new DIContainer(); // Register mock instead of real service testContainer.register(MockDatabaseService); testContainer.register(UserService); }); it('should return user from database', () => { const userService = testContainer.resolve(UserService); const user = userService.getUser('1'); expect(user.rows[0].name).toBe('Test User'); }); });

Testing with Factory Services

Mock configuration and factory-registered services:

describe('UserService with config', () => { let testContainer: DIContainer; beforeEach(() => { testContainer = new DIContainer(); // Register test configuration testContainer.registerFactory( 'config', () => ({ apiUrl: 'http://test.example.com', timeout: 1000, }), { singleton: true }, ); testContainer.register(UserService); }); it('should use test configuration', () => { const userService = testContainer.resolve(UserService); const config = testContainer.resolve('config'); expect(config.apiUrl).toBe('http://test.example.com'); }); });

Testing Dependency Injection

Verify that dependencies are correctly injected:

describe('OrderService dependencies', () => { it('should inject all required dependencies', () => { const testContainer = new DIContainer(); testContainer.register(DatabaseService); testContainer.register(PaymentService); testContainer.register(EmailService); testContainer.register(OrderService); const orderService = testContainer.resolve(OrderService); // Verify service is properly initialized expect(orderService).toBeDefined(); expect(() => orderService.createOrder({})).not.toThrow(); }); });

Testing Singleton vs Transient

Verify singleton and transient behavior:

describe('Service lifecycle', () => { it('should return same instance for singletons', () => { const testContainer = new DIContainer(); testContainer.register(DatabaseService, { singleton: true }); const instance1 = testContainer.resolve(DatabaseService); const instance2 = testContainer.resolve(DatabaseService); expect(instance1).toBe(instance2); }); it('should return different instances for transient services', () => { const testContainer = new DIContainer(); testContainer.register(RequestContext, { singleton: false }); const instance1 = testContainer.resolve(RequestContext); const instance2 = testContainer.resolve(RequestContext); expect(instance1).not.toBe(instance2); }); });

Reusing Setup with fork()

Share a seeded container across tests while keeping isolation:

import { Container as DIContainer } from '@di-framework/core/container'; const base = new DIContainer(); base.register(DatabaseService); base.register(LoggerService); beforeEach(() => { testContainer = base.fork({ carrySingletons: true }); // reuse expensive singletons });

Why: Avoid re-registering common services while ensuring tests cannot mutate each other's registrations.

Construct Instances with Overrides

Build ad-hoc instances for tests without registering them:

class Greeter { constructor( @Component(LoggerService) private logger: LoggerService, private greeting: string, ) {} } it('should allow override of primitive args', () => { const c = new DIContainer(); c.register(LoggerService); const greeter = c.construct(Greeter, { 1: 'hello test' }); expect(greeter).toBeInstanceOf(Greeter); });

Why: Handy for targeted unit tests where you need DI-managed dependencies plus specific literal parameters.

Testing Publishers and Subscribers

You can verify that events are emitted and received correctly by registering both the publisher and subscriber in a test container:

it('should deliver events to subscribers', () => { const testContainer = new DIContainer(); let receivedEvent: any = null; @Container() class TestSubscriber { @Subscriber('test.event') onEvent(payload: any) { receivedEvent = payload; } } @Container() class TestPublisher { @Publisher('test.event') doWork() { return 'done'; } } testContainer.register(TestSubscriber); testContainer.register(TestPublisher); // Resolving the subscriber registers the listener testContainer.resolve(TestSubscriber); const publisher = testContainer.resolve(TestPublisher); publisher.doWork(); expect(receivedEvent).toBeDefined(); expect(receivedEvent.methodName).toBe('doWork'); expect(receivedEvent.result).toBe('done'); });

Testing Telemetry

You can test telemetry by creating a service with @TelemetryListener or by subscribing to the container's telemetry event:

it('should emit telemetry events', async () => { const testContainer = new DIContainer(); let telemetryPayload: any = null; testContainer.on('telemetry', (payload) => { telemetryPayload = payload; }); testContainer.register(ApiService); const api = testContainer.resolve(ApiService); await api.fetchData('123'); expect(telemetryPayload).toBeDefined(); expect(telemetryPayload.className).toBe('ApiService'); expect(telemetryPayload.methodName).toBe('fetchData'); expect(telemetryPayload.endTime - telemetryPayload.startTime).toBeGreaterThanOrEqual(0); });

Testing Error Scenarios

Test error handling and validation:

describe('Error handling', () => { it('should throw when service not registered', () => { const testContainer = new DIContainer(); expect(() => { testContainer.resolve(UnregisteredService); }).toThrow("Service 'UnregisteredService' is not registered"); }); it('should detect circular dependencies', () => { const testContainer = new DIContainer(); testContainer.register(ServiceA); testContainer.register(ServiceB); expect(() => { testContainer.resolve(ServiceA); }).toThrow('Circular dependency detected'); }); });

Spy and Stub Pattern

Create spies to verify method calls:

class SpyDatabaseService { queries: string[] = []; query(sql: string) { this.queries.push(sql); return { rows: [] }; } } describe('UserService with spy', () => { it('should call database with correct query', () => { const testContainer = new DIContainer(); testContainer.register(SpyDatabaseService); testContainer.register(UserService); const userService = testContainer.resolve(UserService); const spy = testContainer.resolve(SpyDatabaseService); userService.getUser('123'); expect(spy.queries).toContain("SELECT * FROM users WHERE id = '123'"); }); });

Testing Async Services

Test services with async operations:

class MockAsyncDatabaseService { async connect() { return Promise.resolve(); } async query(sql: string) { return Promise.resolve({ rows: [{ id: '1' }] }); } } describe('Async UserService', () => { it('should handle async operations', async () => { const testContainer = new DIContainer(); testContainer.register(MockAsyncDatabaseService); testContainer.register(UserService); const userService = testContainer.resolve(UserService); const user = await userService.getUserAsync('1'); expect(user.rows).toHaveLength(1); }); });

Integration Testing

Test multiple services working together:

describe('Order processing integration', () => { it('should process complete order flow', async () => { const testContainer = new DIContainer(); // Register all required services testContainer.register(MockDatabaseService); testContainer.register(MockPaymentService); testContainer.register(MockEmailService); testContainer.register(OrderService); const orderService = testContainer.resolve(OrderService); const order = await orderService.createOrder({ userId: '1', items: [{ id: 'item1', quantity: 2 }], total: 100, }); expect(order.id).toBeDefined(); expect(order.status).toBe('completed'); }); });

Testing Best Practices

1. Use Isolated Containers

// Good - Fresh container per test beforeEach(() => { testContainer = new DIContainer(); }); // Bad - Shared container const testContainer = new DIContainer(); // Global

2. Mock External Dependencies

// Good - Mock external services class MockEmailService { async sendEmail(to: string, subject: string) { return { success: true, messageId: 'test-123' }; } } // Bad - Using real email service in tests testContainer.register(RealEmailService); // Will send real emails

3. Test One Thing at a Time

// Good - Focused test it('should validate user email', () => { const validator = testContainer.resolve(UserValidator); expect(() => validator.validateEmail('invalid')).toThrow(); }); // Bad - Testing multiple things it('should create user and send email and log activity', () => { // Too much in one test });

4. Provide Clear Test Data

// Good - Clear test data const testUser = { id: '1', name: 'Test User', email: '[email protected]', }; // Bad - Unclear test data const testUser = { id: '1', n: 'TU', e: '[email protected]' };

Complete Test Example

Here's a complete testing example:

import { Container as DIContainer } from '@di-framework/core/container'; import { describe, it, expect, beforeEach } from 'bun:test'; // Mock services class MockDatabaseService { private users = new Map([['1', { id: '1', name: 'John Doe', email: '[email protected]' }]]); query(sql: string) { const match = sql.match(/id = '(\d+)'/); if (match) { const user = this.users.get(match[1]); return { rows: user ? [user] : [] }; } return { rows: [] }; } } class MockLoggerService { logs: string[] = []; log(message: string) { this.logs.push(message); } } // Test suite describe('UserService', () => { let testContainer: DIContainer; beforeEach(() => { testContainer = new DIContainer(); testContainer.register(MockDatabaseService); testContainer.register(MockLoggerService); testContainer.register(UserService); }); it('should get user by id', () => { const userService = testContainer.resolve(UserService); const user = userService.getUser('1'); expect(user.rows[0].name).toBe('John Doe'); }); it('should log user retrieval', () => { const userService = testContainer.resolve(UserService); const logger = testContainer.resolve(MockLoggerService); userService.getUser('1'); expect(logger.logs).toContain('Getting user: 1'); }); it('should return empty for non-existent user', () => { const userService = testContainer.resolve(UserService); const user = userService.getUser('999'); expect(user.rows).toHaveLength(0); }); });

Private service bindings

Substitute a bound dependency without changing caller code. Register a mock on serviceBindingToken, or use LocalServiceDevManager.substituteMock. Authorization still applies locally: bind(..., { grantAccess: false }) is how tests prove unbound callers are rejected with UnboundCallerError.

import { serviceBindingToken } from '@di-framework/core/service-bindings'; testContainer.registerValue(serviceBindingToken('inventory'), { reserve: async () => ({ reservationId: 'mock' }), release: async () => ({ released: true }), checkStock: async () => 99, });

See Private service bindings.

Scheduled methods

There is no fake cron clock. Call container.invokeCronJob(jobId) instead of waiting for in-process timers. CronRuntime is a process singleton — call CronRuntime.reset() in beforeEach/afterEach. Overlap tests use a second invokeCronJob while the first is still running.

See Scheduling.

Durable queues

Use InMemoryQueueBackend with advanceTime, step, and drain instead of real sleeps. Import handler modules before producing jobs if you rely on @QueueHandler enqueue defaults.

const memory = new InMemoryQueueBackend(); queue.setBackend(memory); await queue.get('receipts').enqueue(payload); await memory.step('receipts', (job) => dispatcher.dispatch(job));

See Queues.

Database migrations

Pass an explicit MigrationRunner({ db, migrations }) in tests. autoApply() runs only when NODE_ENV is development or test (or enabled: true). Failed up methods leave no history row. There is no rollback command — drop the test database instead.

See Repositories.

Actors

Register actor classes explicitly on an ActorRuntime. Default storage is in-memory. Direct calls on a plain instance skip the mailbox and storage transaction; runtime tests go through runtime.get(...). Import @di-framework/actors/testing only from tests (bun:test).

const runtime = new ActorRuntime(); runtime.register(CounterActor); const counter = runtime.get(CounterActor, 'test'); await counter.increment(1); await runtime.clear();

See Actors. For SQLite, use SqliteActorStorage.temporary() or { inMemory: true } and await storage.close() after runtime.clear(). Discovery is for application source trees; tests should keep calling runtime.register(...). Reload tests use runtime.reload({ policy: 'drain' | 'fail' }) — startup and reload never delete databases.

Next Steps

Last modified: 15 September 2026