This page documents the 0.4 release candidates of @nexusdi/core. The newest on npm is 0.4.0-rc.1. The documentation for 0.3.2, the current release, is at nexus.js.org. Try the release candidate.
Testing and Overrides
Testing a component that depends on a complex system (like a database or a remote API) is difficult if you have to set up the entire production environment just to run a single test.
NexusDI provides a specialized testing utility that allows you to build your real production graph but “swap out” specific providers for fakes. The utility is in @nexusdi/testing. Install it as a dev dependency:
npm install -D @nexusdi/testing@nextUnit Testing without the Container
For simple unit tests, you don’t need a NexusDI container at all. Since your classes take their dependencies in the constructor, you can pass a fake object that implements the required interface.
interface IReactorCore {
readonly output: number;
}
interface IShipComputer {
status(): string;
}
class QuantumComputer implements IShipComputer {
constructor(private readonly reactor: IReactorCore) {}
status() {
return `ShipComputer online. Reactor output ${this.reactor.output} GW.`;
}
}
const fakeReactor: IReactorCore = { output: 0.5 };
const status = new QuantumComputer(fakeReactor).status(); // -> 'ShipComputer online. Reactor output 0.5 GW.'
console.log(status);Try it: Save the region as unit.ts and run npx tsx unit.ts. It prints ShipComputer online. Reactor output 0.5 GW. Change the fake’s output to 0.7 and run it again, and the printed line follows the fake.
Integration Testing with createTestingContainer
When you need to test how a component interacts with the rest of the system, use createTestingContainer. This utility builds the real module graph but allows you to override specific tokens. It takes the root, as in createTestingContainer(root), and create() builds the container.
import { Token, defineModule, provide } from '@nexusdi/core';
import { createTestingContainer } from '@nexusdi/testing';
interface IReactorCore {
readonly output: number;
}
interface IShipComputer {
status(): string;
}
const REACTOR = new Token<IReactorCore>('ReactorCore');
const COMPUTER = new Token<IShipComputer>('ShipComputer');
class FusionReactor implements IReactorCore {
readonly output = 1.21;
}
class QuantumComputer implements IShipComputer {
static deps = [REACTOR] as const;
constructor(private readonly reactor: IReactorCore) {}
status() {
return `ShipComputer online. Reactor output ${this.reactor.output} GW.`;
}
}
class FakeReactor implements IReactorCore {
readonly output = 0.5;
}
const Engineering = defineModule({
name: 'Engineering',
providers: [
provide(REACTOR, { useClass: FusionReactor }),
provide(COMPUTER, { useClass: QuantumComputer }),
],
exports: [COMPUTER],
});
const Meridian = defineModule({ name: 'Meridian', imports: [Engineering] });
await using ship = await createTestingContainer(Meridian)
.override(REACTOR, { useClass: FakeReactor })
.create();
const status = ship.get(COMPUTER).status(); // -> 'ShipComputer online. Reactor output 0.5 GW.'
console.log(status);override(REACTOR, { useClass: FakeReactor }) replaces every provider of REACTOR in the graph. QuantumComputer stays as it is and receives the fake through its REACTOR dependency, because its deps name the token. override() takes a useClass, useValue or useFactory definition with deps, typed as provide() types it. No form takes useExisting.
Try it:
- Save the region as
override.tsand runnpx tsx override.ts. It printsShipComputer online. Reactor output 0.5 GW. - Delete the
.override(REACTOR, { useClass: FakeReactor })line and run it again. It printsShipComputer online. Reactor output 1.21 GW., the real reactor’s output. - Put the line back. Then change
FakeReactorin theoverride()call to a class that has nooutputproperty and runnpx tsc --noEmit.tscflags theoverride()line and reports that propertyoutputis missing in the class and required inIReactorCore. Change it back and the error goes away.
Overrides Are Validated
The testing container is a full NexusDI container. It runs the same compiler and validation checks as production. If your override introduces a missing dependency or a circular reference, create() will reject just as it would in the real app. ChartedReactor depends on NAV_CHARTS, which no module provides, and create() rejects with NEXUS_BLUEPRINT_INVALID holding NEXUS_MISSING_PROVIDER:
import { Token, defineModule, isNexusError, provide } from '@nexusdi/core';
import { createTestingContainer } from '@nexusdi/testing';
interface IReactorCore {
readonly output: number;
}
interface INavCharts {
plot(to: string): string;
}
const REACTOR = new Token<IReactorCore>('ReactorCore');
const NAV_CHARTS = new Token<INavCharts>('NavCharts');
class FusionReactor implements IReactorCore {
readonly output = 1.21;
}
class ChartedReactor implements IReactorCore {
static deps = [NAV_CHARTS] as const;
constructor(private readonly charts: INavCharts) {}
readonly output = 0.5;
}
const Engineering = defineModule({
name: 'Engineering',
providers: [provide(REACTOR, { useClass: FusionReactor })],
exports: [REACTOR],
});
const error = await createTestingContainer(Engineering)
.override(REACTOR, { useClass: ChartedReactor })
.create()
.catch((caught: unknown) => caught);
const errors = isNexusError(error, 'NEXUS_BLUEPRINT_INVALID')
? error.errors
: [];
const codes = errors.map((inner) => inner.code); // -> ['NEXUS_MISSING_PROVIDER']
console.log(codes);Try it: Run the region. It prints [ 'NEXUS_MISSING_PROVIDER' ]. Add provide(NAV_CHARTS, { useValue: { plot: (to) => to } }) to the providers of Engineering and run it again. ChartedReactor now has its dependency, create() resolves, and the region prints [].
An override that matches no provider fails as well. create() rejects with NEXUS_BLUEPRINT_INVALID holding NEXUS_OVERRIDE_UNUSED, so a test never runs against a replacement that changes nothing.
import { Token, defineModule, isNexusError, provide } from '@nexusdi/core';
import { createTestingContainer } from '@nexusdi/testing';
interface IReactorCore {
readonly output: number;
}
interface INavCharts {
plot(to: string): string;
}
const REACTOR = new Token<IReactorCore>('ReactorCore');
const NAV_CHARTS = new Token<INavCharts>('NavCharts');
class FusionReactor implements IReactorCore {
readonly output = 1.21;
}
const fakeCharts: INavCharts = { plot: (to) => `loopback course to ${to}` };
const Engineering = defineModule({
name: 'Engineering',
providers: [provide(REACTOR, { useClass: FusionReactor })],
exports: [REACTOR],
});
const error = await createTestingContainer(Engineering)
.override(NAV_CHARTS, { useValue: fakeCharts })
.create()
.catch((caught: unknown) => caught);
const errors = isNexusError(error, 'NEXUS_BLUEPRINT_INVALID')
? error.errors
: [];
const message = errors[0]?.message; // -> '[NEXUS_OVERRIDE_UNUSED] override(NavCharts) matched no provider in the module graph.\n Fix: remove the override, or import the module that provides NavCharts.'
console.log(message);Try it: Run the region and read the message it prints. Then add provide(NAV_CHARTS, { useValue: fakeCharts }) to the providers of Engineering and run it again. The override now matches a provider, create() resolves, and the region prints undefined because there is no error to read.
Overriding Modules
In some cases, you need to replace an entire set of providers. You can use overrideModule to replace one module with a stub module. overrideModule(Comms, CommsStub) replaces the module wherever the graph imports it, including every forRoot() and forRootAsync() instance of it.
import { Token, defineModule, provide } from '@nexusdi/core';
import { createTestingContainer } from '@nexusdi/testing';
interface ISubspaceLink {
send(message: string): string;
}
const SUBSPACE_LINK = new Token<ISubspaceLink>('SubspaceLink');
class SubspaceRelay implements ISubspaceLink {
send(message: string) {
return `relayed ${message}`;
}
}
class LoopbackLink implements ISubspaceLink {
send(message: string) {
return `loopback ${message}`;
}
}
const Comms = defineModule({
name: 'Comms',
providers: [provide(SUBSPACE_LINK, { useClass: SubspaceRelay })],
exports: [SUBSPACE_LINK],
});
const CommsStub = defineModule({
name: 'CommsStub',
providers: [provide(SUBSPACE_LINK, { useClass: LoopbackLink })],
exports: [SUBSPACE_LINK],
});
const Meridian = defineModule({ name: 'Meridian', imports: [Comms] });
await using ship = await createTestingContainer(Meridian)
.overrideModule(Comms, CommsStub)
.create();
const reply = ship.get(SUBSPACE_LINK).send('hail'); // -> 'loopback hail'
console.log(reply);The stub module must export every token that the original module exported; otherwise, the container will reject the startup process with a NEXUS_OVERRIDE_EXPORTS error. The rejection is NEXUS_BLUEPRINT_INVALID holding NEXUS_OVERRIDE_EXPORTS.
Try it: Delete exports: [SUBSPACE_LINK] from CommsStub and run the region. The process ends with the error, and the inner message reads the stub for Comms does not export SubspaceLink, which Comms exports. Put the export back and the region prints loopback hail.
An overrideModule() for a module the graph never imports fails with NEXUS_OVERRIDE_UNUSED, and the message names the module, such as override(Comms) matched no provider in the module graph. Pass { lazy: true } as the third argument when a later ship.load() brings the module in, and create() no longer reports it.
Advanced Testing Patterns
Preserving Lifetimes
By default, an override preserves the lifetime of the original provider. If a provider was transient in production, your fake will also be transient. You can override this by explicitly setting a lifetime in the override definition. DRONE is transient in Tactical, so TrainingDrone is transient as well, and two get(DRONE) calls return two drones:
import { Token, defineModule, provide } from '@nexusdi/core';
import { createTestingContainer } from '@nexusdi/testing';
interface ISurveyDrone {
readonly serial: number;
}
const DRONE = new Token<ISurveyDrone>('SurveyDrone');
let launched = 0;
class ScoutDrone implements ISurveyDrone {
readonly serial = ++launched;
}
class TrainingDrone implements ISurveyDrone {
readonly serial = 0;
}
const Tactical = defineModule({
name: 'Tactical',
providers: [provide(DRONE, { useClass: ScoutDrone, lifetime: 'transient' })],
exports: [DRONE],
});
await using ship = await createTestingContainer(Tactical)
.override(DRONE, { useClass: TrainingDrone })
.create();
const distinct = ship.get(DRONE) !== ship.get(DRONE); // -> true
console.log(distinct);Try it: Run the region. It prints true. Change the override to { useClass: TrainingDrone, lifetime: 'singleton' } and run it again. Both get(DRONE) calls now return one drone, and the region prints false. Remove the lifetime and it prints true again.
Shared Base Builders
To avoid repeating the same overrides across multiple tests, you can create a base builder. Because the builder is immutable, you can derive specific test configurations from a single base without them interfering with each other. Each override() returns a new builder and leaves the one it was called on unchanged.
import { Token, defineModule, provide } from '@nexusdi/core';
import { createTestingContainer } from '@nexusdi/testing';
interface IReactorCore {
readonly output: number;
onInit?(): void;
}
const REACTOR = new Token<IReactorCore>('ReactorCore');
const log: string[] = [];
class FusionReactor implements IReactorCore {
readonly output = 1.21;
onInit() {
log.push('reactor online');
}
}
class FakeReactor implements IReactorCore {
readonly output = 0.5;
}
const Engineering = defineModule({
name: 'Engineering',
providers: [provide(REACTOR, { useClass: FusionReactor })],
exports: [REACTOR],
});
const base = createTestingContainer(Engineering);
await using quiet = await base.create({ onInit: false });
const skipped = log.length; // -> 0
console.log(skipped);
await using faked = await base
.override(REACTOR, { useClass: FakeReactor })
.create();
const output = faked.get(REACTOR).output; // -> 0.5
console.log(output);
await using real = await base.create();
const realOutput = real.get(REACTOR).output; // -> 1.21
console.log(realOutput);The first container in the region is built with create({ onInit: false }), which builds the graph and skips every onInit method, for a test that must not start what an onInit starts.
Try it: Run the region. It prints 0, then 0.5, then 1.21. The first container skipped onInit, and the third, built from the same base that the second one overrode, got the real reactor. Add the statement base.override(REACTOR, { useClass: FakeReactor }); before the third container, and the third line is still 1.21: override() returns a new builder and leaves base as it was.