Skip to content
Buy me a coffee

Testing

Classes that take their dependencies through the constructor are easy to test without any container: new UserService(fakeRepo). The container earns its place in tests at two levels: integration tests that use the real wiring with a few pieces swapped, and one test that checks the wiring itself.

Tests need the same registrations as the app. Keep them in a function that fills a collection:

services.ts
import {
class ServiceCollection

Collects service registrations, then builds a

ServiceProvider

. Modeled on .NET's IServiceCollection.

@example

const services = new ServiceCollection();
services.addSingleton(Logger, ConsoleLogger);
services.addScoped(UserService, [Logger]);
const provider = services.buildServiceProvider({ validateScopes: true });

ServiceCollection
,
function createToken<T>(name: string): InjectionToken<T>

Creates a typed token.

@example

interface Logger { log(msg: string): void }
const Logger = createToken<Logger>('Logger');
services.addSingleton(Logger, ConsoleLogger);
const logger = await provider.getRequiredService(Logger); // typed as Logger

createToken
} from '@ylcnfrht/ioc';
export interface
interface Mailer
Mailer
{
Mailer.send(to: string, body: string): Promise<void>
send
(
to: string
to
: string,
body: string
body
: string):
interface Promise<T>

Represents the completion of an asynchronous operation

Promise
<void>;
}
export const
const Mailer: InjectionToken<Mailer>
Mailer
=
createToken<Mailer>(name: string): InjectionToken<Mailer>

Creates a typed token.

@example

interface Logger { log(msg: string): void }
const Logger = createToken<Logger>('Logger');
services.addSingleton(Logger, ConsoleLogger);
const logger = await provider.getRequiredService(Logger); // typed as Logger

createToken
<
interface Mailer
Mailer
>('Mailer');
export class
class SmtpMailer
SmtpMailer
implements
interface Mailer
Mailer
{
async
SmtpMailer.send(to: string, body: string): Promise<void>
send
(
to: string
to
: string,
body: string
body
: string) {
/* talks to a real SMTP server */
}
}
export class
class SignupService
SignupService
{
constructor(private readonly
SignupService.mailer: Mailer
mailer
:
interface Mailer
Mailer
) {}
async
SignupService.signup(email: string): Promise<void>
signup
(
email: string
email
: string) {
await this.
SignupService.mailer: Mailer
mailer
.
Mailer.send(to: string, body: string): Promise<void>
send
(
email: string
email
, 'Welcome!');
}
}
export function
function addAppServices(services?: ServiceCollection): ServiceCollection
addAppServices
(
services: ServiceCollection
services
= new
new ServiceCollection(): ServiceCollection

Collects service registrations, then builds a

ServiceProvider

. Modeled on .NET's IServiceCollection.

@example

const services = new ServiceCollection();
services.addSingleton(Logger, ConsoleLogger);
services.addScoped(UserService, [Logger]);
const provider = services.buildServiceProvider({ validateScopes: true });

ServiceCollection
()) {
return
services: ServiceCollection
services
.
ServiceCollection.addSingleton<Mailer>(token: Token<Mailer>, implementation: Newable<NoInfer<Mailer>, []>): ServiceCollection (+4 overloads)

Registers a singleton: one instance for the root provider and all its scopes.

  • addSingleton(Class) / addSingleton(Class, [deps]): the class is its own token.
  • addSingleton(token, Implementation, [deps]): deps are checked against the constructor's parameters.
  • addSingleton(token, (provider) => instance): a factory, may be async.

addSingleton
(
const Mailer: InjectionToken<Mailer>
Mailer
,
class SmtpMailer
SmtpMailer
).
ServiceCollection.addScoped<SignupService, [mailer: Mailer]>(token: Newable<SignupService, [mailer: Mailer]>, dependencies: readonly [mailer: Token<Mailer>]): ServiceCollection (+4 overloads)

Registers a scoped service: one instance per scope. Same forms as

addSingleton

.

addScoped
(
class SignupService
SignupService
, [
const Mailer: InjectionToken<Mailer>
Mailer
]);
}

replace keeps the lifetime of the registration it replaces, so the test changes what is built, not how long it lives. Build a new provider in every test and dispose it afterwards:

signup.test.ts
class
class FakeMailer
FakeMailer
implements
interface Mailer
Mailer
{
FakeMailer.sent: {
to: string;
body: string;
}[]
sent
:
interface Array<T>
Array
<{
to: string
to
: string;
body: string
body
: string }> = [];
async
FakeMailer.send(to: string, body: string): Promise<void>
send
(
to: string
to
: string,
body: string
body
: string) {
this.
FakeMailer.sent: {
to: string;
body: string;
}[]
sent
.
Array<{ to: string; body: string; }>.push(...items: {
to: string;
body: string;
}[]): number

Appends new elements to the end of an array, and returns the new length of the array.

@param ― items New elements to add to the array.

push
({
to: string
to
,
body: string
body
});
}
}
let
let provider: ServiceProvider
provider
:
class ServiceProvider

Resolves services registered in a

ServiceCollection

. The root provider owns singletons;

createScope

returns a scope that owns its scoped services. Modeled on .NET's IServiceProvider.

Supports await using: await using scope = provider.createScope();.

ServiceProvider
;
let
let mailer: FakeMailer
mailer
:
class FakeMailer
FakeMailer
;
function beforeEach(fn: () => void): void
beforeEach
(() => {
let mailer: FakeMailer
mailer
= new
constructor FakeMailer(): FakeMailer
FakeMailer
();
let provider: ServiceProvider
provider
=
function addAppServices(services?: ServiceCollection): ServiceCollection
addAppServices
().
ServiceCollection.replace<Mailer>(token: Token<Mailer>, factory: ServiceFactory<NoInfer<Mailer>>): ServiceCollection (+2 overloads)

Replaces the plain registrations of token, keeping the lifetime of the last one (singleton if there was none).

replace
(
const Mailer: InjectionToken<Mailer>
Mailer
, () =>
let mailer: FakeMailer
mailer
).
ServiceCollection.buildServiceProvider(options?: BuildOptions): ServiceProvider

Builds the provider. Registrations are copied, so changing this collection afterwards does not affect providers already built.

buildServiceProvider
({
BuildOptions.validateScopes?: boolean | undefined

Throw a ScopeError when a scoped service is resolved from the root provider or captured by a singleton. Recommended in development.

validateScopes
: true });
});
function afterEach(fn: () => Promise<void>): void
afterEach
(async () => {
await
let provider: ServiceProvider
provider
.
ServiceProvider.dispose(): Promise<void>

Disposes every instance this provider (or scope) created, in reverse creation order: onDestroy(), else [Symbol.asyncDispose](), else [Symbol.dispose](). Disposing the root disposes singletons; disposing a scope disposes its scoped and transient instances. Every instance is disposed even if some throw; the errors are rethrown afterwards (an AggregateError if more than one). Later calls wait for the first one and do not rethrow.

dispose
(); // runs onDestroy of everything the test created
});
function it(name: string, fn: () => Promise<void>): void
it
('sends a welcome mail', async () => {
await using
await using scope: ServiceProvider
scope
=
let provider: ServiceProvider
provider
.
ServiceProvider.createScope(): ServiceProvider

Creates a scope: scoped services resolve once per scope. Dispose it when done (or use await using).

createScope
();
const
const signup: SignupService
signup
= await
await using scope: ServiceProvider
scope
.
ServiceProvider.getRequiredService<SignupService>(token: Token<SignupService>): Promise<SignupService>

Resolves token, throwing ServiceNotFoundError if it is not registered.

getRequiredService
(
class SignupService
SignupService
);
await
const signup: SignupService
signup
.
SignupService.signup(email: string): Promise<void>
signup
('ada@example.com');
function expect<{
to: string;
body: string;
}[]>(value: {
to: string;
body: string;
}[]): {
toEqual(expected: unknown): void;
}
expect
(
let mailer: FakeMailer
mailer
.
FakeMailer.sent: {
to: string;
body: string;
}[]
sent
).
function toEqual(expected: unknown): void
toEqual
([{
to: string
to
: 'ada@example.com',
body: string
body
: 'Welcome!' }]);
});
  • A factory () => mailer hands out the test’s own instance, so the test can inspect it. addValue(Mailer, mailer) works too, but replace keeps the original lifetime and remove + addValue reads as more than it is.
  • A fresh provider per test means no singleton leaks state from one test into the next.
  • buildServiceProvider copies the registrations, so changing the collection in one test can’t affect a provider built in another.

One test that builds the real collection with every check on catches missing registrations, wrong dependency counts and captive scoped services before they reach a running app:

wiring.test.ts
function it(name: string, fn: () => void): void
it
('every service can be built', () => {
const
const services: ServiceCollection
services
=
function addAppServices(services?: ServiceCollection): ServiceCollection
addAppServices
();
function expect<() => ServiceProvider>(value: () => ServiceProvider): {
toEqual(expected: unknown): void;
not: {
toThrow(): void;
};
}
expect
(() =>
const services: ServiceCollection
services
.
ServiceCollection.buildServiceProvider(options?: BuildOptions): ServiceProvider

Builds the provider. Registrations are copied, so changing this collection afterwards does not affect providers already built.

buildServiceProvider
({
BuildOptions.validateOnBuild?: boolean | undefined

Check every registration up front (missing dependencies, captive scoped services) and throw a ValidationError.

validateOnBuild
: true,
BuildOptions.validateScopes?: boolean | undefined

Throw a ScopeError when a scoped service is resolved from the root provider or captured by a singleton. Recommended in development.

validateScopes
: true })).
not: {
toThrow(): void;
}
not
.
function toThrow(): void
toThrow
();
function expect<string>(value: string): {
toEqual(expected: unknown): void;
not: {
toThrow(): void;
};
}
expect
(
const services: ServiceCollection
services
.
ServiceCollection.visualizeCircularDependencies(): string

Every dependency cycle as text.

visualizeCircularDependencies
()).
function toEqual(expected: unknown): void
toEqual
('No circular dependencies found.');
});

When it fails, the ValidationError lists every problem at once (error.errors), not just the first.

Every container error is a class, so tests can check the type instead of matching messages:

function it(name: string, fn: () => Promise<void>): void
it
('fails clearly when a dependency is missing', async () => {
const
const provider: ServiceProvider
provider
= new
new ServiceCollection(): ServiceCollection

Collects service registrations, then builds a

ServiceProvider

. Modeled on .NET's IServiceCollection.

@example

const services = new ServiceCollection();
services.addSingleton(Logger, ConsoleLogger);
services.addScoped(UserService, [Logger]);
const provider = services.buildServiceProvider({ validateScopes: true });

ServiceCollection
().
ServiceCollection.addSingleton<Reports, [clock: {
now(): Date;
}]>(token: Newable<Reports, [clock: {
now(): Date;
}]>, dependencies: readonly [clock: Token<{
now(): Date;
}>]): ServiceCollection (+4 overloads)

Registers a singleton: one instance for the root provider and all its scopes.

  • addSingleton(Class) / addSingleton(Class, [deps]): the class is its own token.
  • addSingleton(token, Implementation, [deps]): deps are checked against the constructor's parameters.
  • addSingleton(token, (provider) => instance): a factory, may be async.

addSingleton
(
class Reports
Reports
, ['Clock']).
ServiceCollection.buildServiceProvider(options?: BuildOptions): ServiceProvider

Builds the provider. Registrations are copied, so changing this collection afterwards does not affect providers already built.

buildServiceProvider
();
await
function expect<Promise<Reports>>(value: Promise<Reports>): {
rejects: {
toBeInstanceOf(c: unknown): Promise<void>;
};
}
expect
(
const provider: ServiceProvider
provider
.
ServiceProvider.getRequiredService<Reports>(token: Token<Reports>): Promise<Reports>

Resolves token, throwing ServiceNotFoundError if it is not registered.

getRequiredService
(
class Reports
Reports
)).
rejects: {
toBeInstanceOf(c: unknown): Promise<void>;
}
rejects
.
function toBeInstanceOf(c: unknown): Promise<void>
toBeInstanceOf
(
class ServiceNotFoundError

A required service (or one of its dependencies) has no registration.

ServiceNotFoundError
);
});

See Errors for every error class and its fields.