Solitary and sociable auto-mocking test beds for @codefast/di: unit-test an @injectable class with every
collaborator mocked for you, or keep chosen collaborators real.
- Auto-mocking.
TestBed.solitary(Class)reads the class's declared dependencies through di's ownMetadataReaderand builds a mock for each — no per-collaboratorbind(...).toConstantValue(...). - Real instance, real wiring. The unit is constructed through a container, so
@postConstruct, accessor injection, and@preDestroyrun exactly as in production. - Zero test-framework dependency. The default mock is a small built-in spy. Pass
() => vi.fn()(orjest.fn,() => sinon.stub()) to build the mocks from that backend and use its matchers instead. - Backend-typed lookups. The factory's return type flows through the whole bed: with
() => vi.fn(),mocks.get(EmailToken).sendcarries Vitest's own mock surface (mockReturnValueOnce,mockClear, …) with no adapter package and no module augmentation.
Installation
pnpm add -D @codefast/di-testingRequires Node.js 24 or later and a peer install of @codefast/di (>=0.8.0) with the same TypeScript setup: native
Stage 3 decorators, experimentalDecorators off. Published on 0.x and versioned on its own track: breaking changes ship
as minor versions, so pin the minor if you need stability.
Quick start
import { injectable, token } from "@codefast/di";
import { TestBed } from "@codefast/di-testing";
import { expect, it, vi } from "vitest";
interface UserService {
findUser(id: string): { id: string; email: string };
}
interface PaymentGateway {
charge(userId: string, amount: number): void;
}
interface EmailService {
send(to: string, body: string): void;
}
const UserServiceToken = token<UserService>("UserService");
const PaymentGatewayToken = token<PaymentGateway>("PaymentGateway");
const EmailServiceToken = token<EmailService>("EmailService");
@injectable([UserServiceToken, PaymentGatewayToken, EmailServiceToken])
class OrderProcessor {
constructor(
private readonly users: UserService,
private readonly payments: PaymentGateway,
private readonly email: EmailService,
) {}
placeOrder(userId: string, amount: number): string {
const user = this.users.findUser(userId);
this.payments.charge(userId, amount);
this.email.send(user.email, `Order confirmed — ${amount}`);
return `ord-${userId}`;
}
}
it("charges then emails a confirmation", () => {
const { unit, mocks } = TestBed.solitary(OrderProcessor, { mockFactory: () => vi.fn() })
.mock(UserServiceToken)
.stub((fn) => ({ findUser: fn().mockReturnValue({ id: "u1", email: "alice@example.com" }) }))
.compile();
unit.placeOrder("u1", 42);
expect(mocks.get(PaymentGatewayToken).charge).toHaveBeenCalledWith("u1", 42);
expect(mocks.get(EmailServiceToken).send).toHaveBeenCalledWith("alice@example.com", "Order confirmed — 42");
});The zero-dependency default reads the same, minus the mockFactory. Assert against the built-in spy's .mock.calls and
stub with .mockReturnValue():
import { TestBed } from "@codefast/di-testing";
import assert from "node:assert/strict";
const { unit, mocks } = TestBed.solitary(OrderProcessor).compile();
unit.placeOrder("u1", 42);
assert.deepEqual(mocks.get(PaymentGatewayToken).charge.mock.calls[0], ["u1", 42]);Solitary beds
TestBed.solitary(target, options?) begins a bed for target and auto-mocks every dependency it declares. Nothing is
instantiated until compile(). The options:
mockFactory?: () => spy— the spy backend each auto-mock is built from. Defaults to the built-in spy.metadataReader?: MetadataReader— the reader dependencies are discovered through. Defaults to di's reader.
The builder records overrides, then compiles:
.mock(token).stub((fn) => stub)— bind a partial stub built from the active spy factory; unlisted members stay auto-mocked.fn()is typed as whatever the backend produces, sofn().mockReturnValue(...)(jest-shaped) orfn().returns(...)(Sinon) type-check against the factory you chose..mock(token).using(value)— bind a fixed value. The value is sealed: it has no mock surface, somocks.getrefuses it rather than hand it back mistyped — the test already holds the reference it passed in..mock(token).absent()— leave the dependency unbound: anoptional()slot resolvesundefined, aninjectAll()slot[]. On a required dependency this is anOverrideMismatchError..mock(token).usingAll([a, b])— supply the elements of an unconstrainedinjectAll()slot, in order. Sealed like.using..mock(token, { name })/.mock(token, { tag })— target one slot of a token that is injected several ways; the slotless form covers every slot without a more specific override. Registering the same target twice replaces the earlier override..compile()— instantiate the unit synchronously, running accessor injection and@postConstruct..compileAsync()— the same for a unit whose@postConstructis asynchronous.
.mock(...) accepts a token or a class, matching how the unit declares the dependency.
Sociable beds
A sociable bed keeps chosen collaborators real while everything else stays mocked — a unit test over a small real
subtree, not an integration test. TestBed.sociable(target, options?) takes the same options and returns only
.expose(), because a sociable bed with nothing exposed is a solitary bed.
import { injectable, token } from "@codefast/di";
import { TestBed } from "@codefast/di-testing";
import { expect, it, vi } from "vitest";
interface TaxPolicy {
rateFor(currency: string): number;
}
const TaxPolicyToken = token<TaxPolicy>("TaxPolicy");
@injectable([TaxPolicyToken])
class PricingService {
constructor(private readonly tax: TaxPolicy) {}
total(amount: number, currency: string): number {
return amount * (1 + this.tax.rateFor(currency));
}
}
@injectable([PricingService])
class CheckoutService {
constructor(private readonly pricing: PricingService) {}
checkout(amount: number, currency: string): number {
return this.pricing.total(amount, currency);
}
}
it("prices through the real PricingService over a mocked tax boundary", () => {
const bed = TestBed.sociable(CheckoutService, { mockFactory: () => vi.fn() })
.expose(PricingService)
.mock(TaxPolicyToken)
.stub((fn) => ({ rateFor: fn().mockReturnValue(0.1) }))
.compile();
expect(bed.unit.checkout(100, "USD")).toBe(110);
expect(bed.exposed(PricingService)).toBeInstanceOf(PricingService);
});- Exposure follows class identity. A class-keyed dependency — of the unit or of another exposed class — stays real when exposed, and its own dependencies follow the same rules recursively.
- Tokens are the boundary. A
Token-keyed dependency is always mocked, in both modes: tokens mark where the logic under test meets the outside world. - Exposed collaborators are singletons resolved through the container, so their
@postConstructruns at compile and@preDestroyon dispose.bed.exposed(Class)returns the instance the unit was built with;mocks.get(Class)refuses it with aSealedDependencyErrorbecause it carries no mock surface. - Exposing the unit itself, or a class the unit never reaches through exposed collaborators, is an
ExposureErrorat compile.
Behaviour notes
- A class whose constructor takes no parameters is testable without
@injectable; a parameterful constructor with no metadata is aNotInjectableError. - An
optional()dependency is auto-mocked like any other — it resolves to the mock, not to theundefinedan unbound optional would give in production. Use.mock(token).absent()to exercise the absent branch. - An
injectAll()dependency receives a one-element array holding the token's mock; use.mock(token).usingAll([...])to supply several elements. - Named or tagged parameters of one token share the token's mock unless a slot-targeted
.mock(token, { name })gives that slot its own; either waymocks.get(token, { name })addresses the slot directly. - A
.mock(...)that names a token or slot the unit does not declare is anUndeclaredDependencyErrorat compile — a typo fails loudly instead of binding an unused constant. - A failed compile disposes the container it was building, so no lifecycle state leaks between tests.
Result
compile() returns a UnitTestBed (a sociable bed returns a SociableUnitTestBed, which adds exposed):
unit— the real class under test.mocks.get(token, options?)— theMocked<T>bound for a dependency, or for one slot of it. Only auto-mocks and.stubstubs come back; sealed values and exposed classes throwSealedDependencyError.resetMocks()— clear the call history and configured behaviour of every auto-mock and stub the bed created.dispose()— run the unit's@preDestroyhooks and dispose the container.
The bed implements AsyncDisposable, so await using bed = TestBed.solitary(X).compile() disposes it at the end of the
block; that needs the esnext.disposable lib in your TypeScript configuration if your target does not include it.
The lower-level pieces are exported too: createAutoMock, createSpy, defaultMockFactory, and the Mocked,
DeepPartial, MockFactory, and Spy types.
Errors
Every error extends TestingError and carries a stable code. TestingError mirrors di's DiError shape without
extending it, so setup failures can be caught separately from resolution failures.
| Error | Code | Raised when |
|---|---|---|
NotInjectableError |
NOT_INJECTABLE |
A scanned class takes constructor parameters but is not @injectable |
UndeclaredDependencyError |
UNDECLARED_DEPENDENCY |
A .mock(...) or mocks.get(...) named a token or slot the unit does not use |
SealedDependencyError |
SEALED_DEPENDENCY |
mocks.get(...) asked for a .using()/.absent()/.usingAll() value or an exposed class |
OverrideMismatchError |
OVERRIDE_MISMATCH |
.absent() on a required dependency, or .usingAll() with no unconstrained injectAll() slot |
ExposureError |
EXPOSURE |
An exposed class is the unit, is unreachable, or bed.exposed() names a class that was not exposed |
Documentation
- Rendered docs on codefastlabs.com
@codefast/di— the container this package builds on, including itsSPEC.md.CHANGELOG.md— release history.
Contributing
See the repo-wide contributing guide for setup, conventions, and the test taxonomy.
License
Released under the MIT License.