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.
Put the registrations in a function
Section titled “Put the registrations in a function”Tests need the same registrations as the app. Keep them in a function that fills a collection:
import { class ServiceCollection
Collects service registrations, then builds a
ServiceProvider
.
Modeled on .NET's IServiceCollection.
ServiceCollection, function createToken<T>(name: string): InjectionToken<T>
Creates a typed token.
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.
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.
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]);}Swap in fakes with replace
Section titled “Swap in fakes with replace”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:
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.
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
() => mailerhands out the test’s own instance, so the test can inspect it.addValue(Mailer, mailer)works too, butreplacekeeps the original lifetime andremove+addValuereads as more than it is. - A fresh provider per test means no singleton leaks state from one test into the next.
buildServiceProvidercopies the registrations, so changing the collection in one test can’t affect a provider built in another.
A wiring test
Section titled “A wiring test”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:
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.
Asserting on errors
Section titled “Asserting on errors”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.
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.
Related
Section titled “Related”- Service management:
replace,remove,tryAdd* - Validation
- Diagnostics