Testing
Test Harness
The createTestHarness() utility simplifies testing module-based applications. It creates a reusable harness from a base set of modules, and each test can add or remove modules via overrides:
import { ModularityModule, createTestHarness } from '@modularityjs/modularity';
import { inversify } from '@modularityjs/di-inversify';
const harness = createTestHarness(
[ModularityModule, CacheModule, CacheMemoryModule, MyServiceModule],
{ di: inversify },
);Booting
Each test calls harness.boot() to create a fresh application:
describe('MyService', () => {
let app: Application;
afterEach(async () => {
if (app) await app.shutdown();
});
it('caches results', async () => {
app = await harness.boot();
const service = app.get(MyService);
await service.process('key');
expect(await service.getCached('key')).toBeDefined();
});
});Adding Modules
Add modules for a specific test — useful for providing test doubles or extra fixtures:
it('uses custom cache implementation', async () => {
app = await harness.boot({
add: [TestCacheModule], // adds a test-specific cache driver
});
});Removing Modules
Remove modules from the base set — useful for replacing a driver or isolating a contract:
it('works without the cache driver', async () => {
app = await harness.boot({
remove: [CacheMemoryModule], // remove the default driver
add: [MockCacheModule], // substitute a mock
});
});Skipping Start
By default, boot() calls app.start() after creation. Passing start: false skips only the onReady phase — afterLoad and onInit have already run as part of createApp(), so the DI container is fully wired and any resources acquired in onInit (DB connections, temp dirs) are live. Use this when you want to assert against the container without booting listeners / queue consumers / schedulers:
it('resolves services without starting', async () => {
app = await harness.boot({ start: false });
const service = app.get(MyService);
expect(service).toBeDefined();
});Testing Patterns
Unit Testing Services
For pure unit tests, instantiate services directly without the module system:
import { AuthJwtConfig } from '@modularityjs/auth-jwt';
import { JwtAuthService } from '@modularityjs/auth-jwt';
function createService(overrides: Partial<AuthJwtConfig> = {}) {
const config = new AuthJwtConfig();
config.secret = 'test-secret-0123456789abcdef-padding';
Object.assign(config, overrides);
return new JwtAuthService(config);
}
it('signs and verifies a token', async () => {
const service = createService();
const token = await service.sign({ id: 'user-1', attributes: {} });
const identity = await service.resolve(token);
expect(identity?.id).toBe('user-1');
});Integration Testing with HTTP
Use createHttpTestClient from @modularityjs/testing: it wraps HttpServer.inject, so the request runs through the full pipeline — routing, guards, interceptors, error filters — without ever binding a port:
import { createHttpTestClient } from '@modularityjs/testing';
const appPromise = createApp({
di: inversify,
modules: [
ModularityModule,
HttpModule.forRoot({ port: 0 }),
HttpFastifyModule,
MyControllersModule,
],
signals: false,
});
afterAll(async () => {
await (await appPromise).shutdown();
});
it('returns the greeting', async () => {
const client = createHttpTestClient(await appPromise);
const response = await client.get('/hello/world');
expect(response.statusCode).toBe(200);
expect(response.json()).toEqual({ message: 'Hello, world!' });
});createHttpTestClient(app) takes a booted application and returns get / post / put / patch / delete helpers plus a raw inject(options) escape hatch. Each verb takes an optional { headers, cookies } (bodies as the second argument on post/put/patch); responses expose statusCode, headers, body, and json<T>(). This is the pattern pnpm create @modularityjs scaffolds into every new app's controller spec.
To exercise a real socket instead (e.g. testing SSE or client libraries), boot with HttpModule.forRoot({ port: 0, listen: false }), call http.start(), and fetch against http.getAddress().
Mocking External Dependencies
Replace external service drivers with test doubles:
@Injectable()
class MockCacheService extends CacheService {
private store = new Map<string, unknown>();
async get<T>(key: string) {
return this.store.get(key) as T | undefined;
}
async set<T>(key: string, value: T, _options?: CacheSetOptions) {
this.store.set(key, value);
}
async delete(key: string) {
this.store.delete(key);
}
async has(key: string) {
return this.store.has(key);
}
async invalidateTag() {}
async invalidateTags() {}
}
@Module({
name: 'mock-cache',
imports: [CacheModule],
providers: [MockCacheService],
preferences: [{ provide: CacheService, useClass: MockCacheService }],
})
class MockCacheModule {}
// In test:
app = await harness.boot({
remove: [CacheRedisModule],
add: [MockCacheModule],
});Testing Config
Provide test configuration via schema defaults or env vars:
it('reads config from schema defaults', async () => {
@Module({
name: 'test-config',
imports: [ConfigModule],
pools: [
{
pool: ConfigSchemaPool,
key: 'app/name',
useValue: { path: 'app/name', type: 'string', default: 'TestApp' },
},
],
})
class TestConfigModule {}
app = await harness.boot({ add: [TestConfigModule] });
const config = app.get(ConfigService);
expect(config.get('app/name')).toBe('TestApp');
});Database Testing
Use sql.js (in-memory SQLite via TypeORM) for zero-infrastructure database tests:
import { DatabaseModule } from '@modularityjs/database';
import { DatabaseTypeormModule } from '@modularityjs/database-typeorm';
import { ModularityModule, createTestHarness } from '@modularityjs/modularity';
import { inversify } from '@modularityjs/di-inversify';
const harness = createTestHarness(
[
ModularityModule,
DatabaseModule.forRoot({ migrationsRun: true }),
DatabaseTypeormModule.forRoot({
type: 'sqljs',
synchronize: true,
}),
MyEntityModule,
],
{ di: inversify },
);sql.js runs entirely in memory -- no database server needed. Set synchronize: true to auto-create tables from entity metadata. For migration testing, use migrationsRun: true instead.
sql.js caveat
TypeORM's QueryRunner state is not shared across instances with sql.js. Use MigrationRunner.list() instead of calling executed() and pending() separately, which would create two QueryRunners that both try to create the migrations table.
Testing Scoped Services
Services that depend on ScopeService require an active scope context during assertions:
it('resolves tenant-specific config', async () => {
app = await harness.boot();
const scope = app.get(ScopeService);
const config = app.get(ConfigService);
await scope.runInScope({ level: 'tenant', id: 'acme' }, async () => {
const theme = config.get<string>('app/theme');
expect(theme).toBe('acme-dark');
});
});Without runInScope, scoped services see an empty scope chain. Always wrap assertions that depend on scope context.
Testing CLI Commands
Test CLI commands by providing custom argv and capturing output:
import { CliModule } from '@modularityjs/cli';
import { ModularityModule, createTestHarness } from '@modularityjs/modularity';
import { CliCommanderModule } from '@modularityjs/cli-commander';
import { inversify } from '@modularityjs/di-inversify';
const harness = createTestHarness(
[
ModularityModule,
CliModule.forRoot({
name: 'test',
argv: ['node', 'test', 'greet', 'World'],
}),
CliCommanderModule,
GreetModule,
],
{ di: inversify },
);
it('runs the greet command', async () => {
const spy = vi.spyOn(console, 'log');
app = await harness.boot();
expect(spy).toHaveBeenCalledWith('Hello World');
spy.mockRestore();
});The Commander driver executes commands during boot (in onReady). Capture output with vi.spyOn(console, 'log') before calling harness.boot().
Test lanes
Tests are split into lanes by file suffix, selected by createVitestConfig({ lane }) from @modularityjs/coding-standard:
| Suffix | Lane | Runs against |
|---|---|---|
*.spec.ts | unit | in-process, mocked externals — always runs |
*.integration.spec.ts | integration | a real backend (pnpm --filter <pkg> test:integration) |
*.e2e.spec.ts | e2e | a fully booted app |
A backend driver's integration spec calls requireBackend('REDIS_URL' | 'DATABASE_URL' | 'S3_ENDPOINT' | 'ELASTICSEARCH_URL' | …) from @modularityjs/testing. It skips loudly when the backend is absent locally, but fails when CI is set — so an integration suite can never silently skip. CI provides Redis, Postgres, MinIO, and Elasticsearch service containers for the integration lane.
Cross-driver conformance suites
Contracts with more than one driver ship a shared behavioral suite from @modularityjs/testing — describe<Contract>ServiceContract (cache, lock, search, session, queue, storage, rate-limit, events, outbox, secrets, metrics, webhook, mail, sms, push, template, feature-flags, validator, schema-serializer, plus a DIProvider suite; see the describe* exports in packages/testing/src/index.ts for the current set). Every driver runs the same suite, so memory and backend implementations can't drift apart:
// cache-memory: unit lane
import { describeCacheServiceContract } from '@modularityjs/testing';
describeCacheServiceContract('cache-memory', () => ({
service: new MemoryCacheService(new CacheMemoryConfig()),
}));A memory driver runs its suite in the unit lane; a backend driver runs the same suite in the integration lane (*.integration.spec.ts + requireBackend).
Conventions
- Tests live in
src/__tests__/within each package - Test files use the
.spec.tssuffix (.integration.spec.ts/.e2e.spec.tsfor the other lanes) - Use Vitest (
describe,it,expect,vi) - Always call
app.shutdown()inafterEachto clean up - Pass
signals: falsetocreateApp()in tests (the harness does this automatically)