DI Overrides
The container's central role makes testing easy: replace any
provider with a test double, and every consumer resolves the double
without code changes. This page is the reference for how to swap a
provider in a Titan test — at the container level, at the
Application.create level, and with the @omnitron-dev/testing/titan
helpers.
How overriding works
A Nexus container keys registrations by token, and the last registration for a token wins. Overriding is therefore just re-registering a token with a different provider definition after the original is in place.
A provider definition is the standard Nexus shape (one of useClass,
useValue, useFactory, useToken, or a bare constructor — see
Provider in Provider in packages/titan/src/nexus/types.ts):
import { Container } from '@omnitron-dev/titan/nexus';
const container = new Container();
container.register(DATABASE, { useClass: FakeDatabase });
container.register(USERS_SERVICE, { useClass: UsersService });
// UsersService resolves with FakeDatabase as its `database` dep.
To force a re-registration over an existing one, pass
{ override: true }:
container.register(DATABASE, { useClass: RealDatabase });
container.register(DATABASE, { useClass: FakeDatabase }, { override: true });
// resolve(DATABASE) → FakeDatabase
Application exposes the same surface as app.register(token, provider, { override: true }).
Overriding through Application.create
When you boot a full app for an integration test, pass the
providers option — an array of [token, providerDefinition]
tuples:
import { Application } from '@omnitron-dev/titan';
const app = await Application.create({
modules: [AppModule],
providers: [
[Database, { useClass: FakeDatabase }],
[REDIS, { useValue: fakeRedis }],
],
disableGracefulShutdown: true,
});
The shape of providers is defined by CreateOptions in
IModuleOptions.imports in packages/titan/src/application/application.ts:
providers?: Array<[InjectionToken<unknown>, Provider<unknown>]>;
create() registers these into the container after the modules
are wired (Application.create–288 — modules at lines 269–274,
providers immediately after), so each tuple's definition wins over
whatever the module declared for the same token. There is no
overrides option — providers is the override mechanism.
Each tuple's provider definition uses the standard Nexus shape:
providers: [
[Database, { useClass: FakeDatabase }], // construct a fake
[Clock, { useValue: { now: () => 0 } }], // pin a value
[Cache, { useFactory: () => new MapCache() }], // build per resolution
]
After all modules and providers are registered, create() calls
container.eagerlyInitialize() (Application.create), so singleton
providers — including your fakes — are constructed before create()
resolves.
Fake vs mock vs stub
Three styles, all valid:
- Fake — a working implementation backed by a simpler store (in-memory database, in-memory cache). Best for integration tests of wiring.
- Mock — a programmable double that records calls and returns
configured responses (
vi.fn()). Best for unit tests of business logic. - Stub — a hard-coded response, no programming.
A canonical fake
import type { Database, User, NewUser } from './types.js';
export class FakeDatabase implements Database {
private readonly users = new Map<string, User>();
async findUser(id: string): Promise<User | null> {
return this.users.get(id) ?? null;
}
async createUser(input: NewUser): Promise<User> {
const user = { id: crypto.randomUUID(), ...input };
this.users.set(user.id, user);
return user;
}
async deleteUser(id: string): Promise<void> {
this.users.delete(id);
}
/** Test affordance: wipe between tests. */
reset(): void {
this.users.clear();
}
}
Wire it through providers, then reset it in beforeEach:
const app = await Application.create({
modules: [AppModule],
providers: [[Database, { useClass: FakeDatabase }]],
disableGracefulShutdown: true,
});
await app.start();
beforeEach(() => {
(app.resolve(Database) as FakeDatabase).reset();
});
The same fake works at every test level — unit, integration, e2e.
The @omnitron-dev/testing/titan helpers
The testing package ships container-level test helpers under the
@omnitron-dev/testing/titan subpath (the "./titan" entry in the
package's exports map). They are not exported from
the package root — always import from the subpath:
import {
createTestModule,
MockProvider,
createMockProvider,
} from '@omnitron-dev/testing/titan';
These wrap a TestContainer (a Container subclass), not a booted
Application. Use them when you want fine-grained mocking, spying,
and interaction recording without standing up the full lifecycle.
createTestModule / TestModule
createTestModule(options) returns a TestModule
(createTestModule in packages/testing/src/titan/test-module.ts). Its options
(TestModuleOptions, same file lines 11–21):
interface TestModuleOptions {
modules?: IModule[];
providers?: Array<[InjectionToken<any>, ProviderDefinition<any>]>;
mocks?: Array<{ token: InjectionToken<any>; mock: any; spy?: boolean }>;
config?: Partial<IApplicationConfig>;
autoMock?: boolean;
}
mocks is the override channel here — each entry registers
{ useValue: mock } for its token (with optional spy wrapping).
TestModule also exposes a fluent API:
import { createTestModule } from '@omnitron-dev/testing/titan';
const harness = createTestModule({
providers: [[USERS_SERVICE, { useClass: UsersService }]],
mocks: [{ token: DATABASE, mock: new FakeDatabase() }],
});
// Fluent overrides (each returns `this`):
harness
.override(CLOCK).useValue({ now: () => 0 })
.mock(MAILER, { send: vi.fn() }, /* spy */ true)
.stub(FEATURE_FLAGS, { isEnabled: true });
const users = harness.get(USERS_SERVICE); // resolves from the test container
Method reference (TestModuleBuilder.mock–159):
| Method | Effect |
|---|---|
mock(token, mock, spy?) | Register mock as the token's value; optionally wrap its methods in spies. |
stub(token, partial) | Register a partial implementation. |
override(token) | Returns { useValue, useClass, useFactory } — pick one. |
spy(token, method) | Spy on a method of the already-registered instance. |
get(token) | Resolve from the test container. |
resetMocks() / clearMocks() / restore() | Mock lifecycle control. |
cleanup() | Stop the app (if createApplication() was called) and restore the container. |
TestModule.createApplication()constructs anApplicationand attaches the test container, but does not callstart()— it is for container-resolution tests, not lifecycle tests. To exercise the real lifecycle (onStart, module ordering, hooks), boot withApplication.create({ ..., providers: [...] })and callapp.start()yourself (see Integration).
MockProvider and createMockProvider
Two distinct helpers (packages/testing/src/titan/nexus/mock-provider.ts):
MockProvider (lines 112–126) — wrap a plain object so every
function becomes a vi.fn(). Hand the instance straight to a
useValue provider:
import { MockProvider } from '@omnitron-dev/testing/titan';
const mailer = new MockProvider({
send: async () => ({ id: 'msg_1' }),
flush: () => {},
});
const app = await Application.create({
modules: [AppModule],
providers: [[MAILER, { useValue: mailer }]],
disableGracefulShutdown: true,
});
// `mailer.send` is a vi.fn — assert on it directly:
expect(mailer.send).toHaveBeenCalledWith(expect.objectContaining({ to: 'ada@x.com' }));
createMockProvider(config) (lines 95–97) — builds a
MockProviderDI from a config object
(MockProviderConfig, lines 9–15: { token, value?, factory?, spy?, autoMock? }). Call .getProvider() to get the Nexus provider
definition:
import { createMockProvider } from '@omnitron-dev/testing/titan';
const dbMock = createMockProvider({
token: DATABASE,
value: new FakeDatabase(),
});
const app = await Application.create({
modules: [AppModule],
providers: [[DATABASE, dbMock.getProvider()]], // → { useValue: ... }
disableGracefulShutdown: true,
});
With autoMock: true (and no value/factory), the provider is a
proxy that lazily returns a vi.fn() for every accessed property —
handy for a dependency you must satisfy but never call.
Module-level overrides
Sometimes you want to override a whole module, not a single provider. Define a test variant and import it instead of the real one:
@Module({
providers: [{ provide: Database, useClass: FakeDatabase }],
exports: [Database],
})
class FakeDatabaseModule {}
@Module({
imports: [
FakeDatabaseModule, // instead of DatabaseModule
UsersModule,
OrdersModule,
],
})
class TestAppModule {}
const app = await Application.create(TestAppModule, {
disableGracefulShutdown: true,
});
Useful for swapping infrastructure (database, redis, file storage) to
test-friendly implementations across many tests. Note this uses the
module-decorator { provide, useClass } shape, distinct from the
create({ providers: [[token, def]] }) tuple shape.
Spying without replacing
Sometimes you want to observe calls without changing behaviour:
import { vi } from 'vitest';
const real = app.resolve(LoggerService);
const spy = vi.spyOn(real, 'info');
// run code that should log
expect(spy).toHaveBeenCalledWith('ready', expect.objectContaining({ port: 3000 }));
The spy wraps the implementation; it doesn't replace it. The
TestModule.spy(token, method) helper does the same against the test
container.
The "two real, one fake" pattern
A common integration setup — real logger, real config, fake database, real services:
const app = await Application.create({
modules: [LoggerModule, ConfigModule.forRoot({ /* ... */ }), UsersModule],
providers: [[Database, { useClass: FakeDatabase }]],
disableGracefulShutdown: true,
});
Catches wiring bugs without paying for a real database.
Anti-patterns
- Overriding too much. A test where every provider is mocked is testing the test, not the code. Override only what you need to control.
- Mutable shared fakes across tests. Two tests that share one
FakeDatabasesee each other's data. Use a fresh fake per test, orreset()inbeforeEach. - Testing the framework. "Does the container resolve providers?" is the framework's job. Test your code; trust the framework.
→ Next: Integration.