How to Write Tests for DI/Scope Components Using the InstantiationService Harness

TLDR: Use TestInstantiationService from packages/agent-core/src/di/testInstantiationService.ts—a test-only subclass that exposes protected resolution methods, adds stubbing helpers (stub, mock, spy), and implements IDisposable for clean teardown.

The Kimi Code codebase includes a lightweight, purpose-built harness for testing dependency injection and scoped service containers. Built directly on top of the production InstantiationService, this harness lets you verify DI behavior without spinning up the full application runtime. The following guide walks through the core concepts and practical patterns for writing robust, isolated tests for DI/Scope components.


Understanding the Test Harness Architecture

The test harness centers on three interconnected pieces defined in packages/agent-core/src/di/testInstantiationService.ts:

TestInstantiationService Class

TestInstantiationService extends the production InstantiationService with test-specific capabilities:

  • Exposes _getOrCreateServiceInstance via a public get method for direct service resolution
  • Adds stubbing utilities: mock, stub, stubPromise, and spy for controlling dependency behavior
  • Implements proper cleanup: custom dispose restores Sinon stubs and optionally calls the parent disposal logic

The class signature includes a properDispose flag that determines whether super.dispose() runs—useful when you need to test lifecycle behavior without destroying parent containers.

createServices Factory Helper

The createServices function (lines 96-135 in testInstantiationService.ts) provides a one-line setup:

function createServices(
  disposables: DisposableStore,
  services: [ServiceIdentifier<T>, T | SyncDescriptor<T>][]
): TestInstantiationService

This helper instantiates TestInstantiationService, populates a ServiceCollection with your service tuples, and registers the container with a disposable store for automatic cleanup.

ServiceCollection Integration

The test harness uses the same ServiceCollection class as production code. This mutable map stores either concrete instances or SyncDescriptor wrappers that the container instantiates lazily. Using the real collection ensures your tests exercise identical resolution paths.


Basic Resolution Testing

Start with simple service registration and retrieval to verify your component receives correct dependencies.

import { createServices } from '#/di/test';
import { ServiceCollection } from '#/di/serviceCollection';
import { IMyService, MyService } from './myService';
import { DisposableStore } from '#/di/lifecycle';

test('my component receives the correct service', () => {
  const disposables = new DisposableStore();

  // Register the concrete implementation for IMyService
  const inst = createServices(disposables, [[IMyService, MyService]]);

  // Resolve the service via the test container
  const service = inst.get(IMyService);

  expect(service).toBeInstanceOf(MyService);
});

Key implementation detail: The call to inst.get delegates to TestInstantiationService.get, which exposes the protected _getOrCreateServiceInstance method from the base InstantiationService class defined in packages/agent-core/src/di/instantiationService.ts.


Stubbing Dependencies with TestInstantiationService

Replace real implementations with controlled stubs to isolate the unit under test.

import { createServices } from '#/di/test';
import { IConfigService } from './config';
import { MyComponent } from './myComponent';

test('component uses stubbed config', () => {
  const disposables = new DisposableStore();

  // Create an empty container, then stub the config service
  const inst = createServices(disposables, []);
  const stub = { getValue: () => 'stubbed' };
  inst.stub(IConfigService, stub);

  const component = new MyComponent(inst.get(IConfigService));
  expect(component.doSomething()).toBe('stubbed');
});

How it works: TestInstantiationService.stub (lines 95-118) inserts the stub directly into the internal ServiceCollection, causing all subsequent get calls to return your supplied object without invoking the real constructor or factory.


Mocking Class-Based Services

For more sophisticated verification, use mock to create Sinon mocks that track method calls and enforce expectations.

test('service methods are called with expected arguments', () => {
  const inst = createServices(new DisposableStore(), []);
  const mockService = inst.mock(ILoggerService);
  
  mockService.expects('log').once().withArgs('test-message');

  const consumer = new ServiceConsumer(inst.get(ILoggerService));
  consumer.run();

  mockService.verify();
});

The mock helper automatically creates a Sinon mock for the service identifier and registers it in the collection. When resolved via inst.get, you receive the mock object for setting expectations.


Testing Child Scopes and Disposal

Verify that scoped containers inherit correctly and clean up their resources independently.

import { createServices } from '#/di/test';
import { IServiceA, ServiceA } from './serviceA';
import { DisposableStore } from '#/di/lifecycle';

test('child scope disposes its own services', () => {
  const disposables = new DisposableStore();

  // Parent container registers ServiceA as a descriptor
  const parent = createServices(disposables, [[IServiceA, ServiceA]]);

  // Child container overrides IServiceA with a mock
  const childCollection = new ServiceCollection();
  const child = parent.createChild(childCollection) as TestInstantiationService;
  const mock = child.mock(IServiceA);

  // Resolve the mock inside the child
  const resolved = child.get(IServiceA);
  expect(resolved).toBe(mock);

  // Dispose the child – Sinon is restored automatically
  child.dispose();

  // Parent container still works with original implementation
  expect(parent.get(IServiceA)).toBeInstanceOf(ServiceA);
});

Critical behavior: parent.createChild returns a new TestInstantiationService with its own ServiceCollection. The child's dispose method first calls sinon.restore() to clean up mocks, then calls super.dispose() only when properDispose is true (the default). This pattern lets you test scope isolation without affecting sibling or parent containers.


Spying on Service Methods

When you need to observe behavior without replacing the implementation, use spy:

test('observes service interactions', () => {
  const inst = createServices(disposables, [[IMetricsService, MetricsService]]);
  const spy = inst.spy(IMetricsService, 'record');

  const worker = new BackgroundWorker(inst.get(IMetricsService));
  worker.process();

  expect(spy.calledOnce).toBe(true);
  expect(spy.firstCall.args[0]).toBe('processing-complete');
});

The spy helper wraps the method on the resolved service instance, allowing verification without modifying the service's return values or side effects.


File Reference: Core DI Implementation

File Purpose Key Exports
packages/agent-core/src/di/instantiationService.ts Production DI container with cyclic-dependency detection, tracing, and child creation InstantiationService, IInstantiationService
packages/agent-core/src/di/testInstantiationService.ts Test harness extending production container TestInstantiationService, createServices
packages/agent-core/src/di/serviceCollection.ts Mutable service registry ServiceCollection, SyncDescriptor
packages/agent-core/src/di/instantiation.ts Service identifier decorators createDecorator, IInstantiationService interface

These files work together to ensure your tests exercise the same resolution logic, lifecycle management, and scope inheritance as production code.


Advanced Pattern: Testing Scoped Disposable Cleanup

When services implement IDisposable, verify that child scope disposal triggers proper cleanup:

test('child scope triggers dispose on owned services', () => {
  const disposables = new DisposableStore();
  const disposeSpy = sinon.spy();

  class DisposableService {
    dispose = disposeSpy;
  }

  const parent = createServices(disposables, []);
  const childCollection = new ServiceCollection();
  childCollection.set(IDisposableService, new DisposableService());

  const child = parent.createChild(childCollection) as TestInstantiationService;
  child.get(IDisposableService);
  
  child.dispose(true); // properDispose = true forces super.dispose()

  expect(disposeSpy.calledOnce).toBe(true);
});

Passing true to dispose ensures the container iterates through its service graph and invokes dispose() on any IDisposable instances it owns.


Summary

  • createServices provides one-line container setup with automatic disposable registration
  • TestInstantiationService.get exposes protected resolution for direct service access
  • stub, mock, spy replace or observe dependencies without constructor invocation
  • createChild spawns isolated scopes that inherit parent services and dispose independently
  • dispose cleans up Sinon state and, with properDispose, triggers full container teardown

These primitives align your tests with the production DI behavior defined in packages/agent-core/src/di/instantiationService.ts, eliminating the gap between test doubles and real container resolution.


Frequently Asked Questions

What is the difference between stub and mock in TestInstantiationService?

stub replaces a service identifier with a plain object or partial implementation—useful for simple value overrides. mock creates a full Sinon mock with expectation methods like expects and verify—better for behavioral verification. Both store the replacement in the ServiceCollection so inst.get returns the controlled object.

When should I use properDispose in TestInstantiationService?

Set properDispose to true (the default) when you want the container to dispose its actual services and clean up the service graph. Set it to false only when testing disposal behavior itself or when the parent container manages lifecycle—you can see this flag checked in the custom dispose implementation at testInstantiationService.ts.

How do I verify that services are singletons within a scope?

Request the same service identifier twice and assert reference equality: expect(inst.get(IService)).toBe(inst.get(IService)). The InstantiationService caches instances per container, so this validates that your SyncDescriptor or registered instance behaves as a singleton within that scope.

Can I use TestInstantiationService with async service initialization?

Yes. For promise-based initialization, use stubPromise to register a pre-resolved promise: inst.stubPromise(IService, Promise.resolve(mockService)). The container treats this as an already-available instance, avoiding async resolution paths in synchronous tests.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →