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
For HTTP integration tests, use HttpModule.forRoot({ port: 0, listen: false }) to get a random port and disable auto-listen:
const harness = createTestHarness(
[
ModularityModule,
HttpModule.forRoot({ port: 0, listen: false }),
HttpFastifyModule,
MyControllersModule,
],
{ di: inversify },
);
it('returns 200 for GET /health', async () => {
app = await harness.boot();
const http = app.get(HttpServer);
await http.start();
const address = http.getAddress()!;
const response = await fetch(`http://${address.host}:${address.port}/health`);
expect(response.status).toBe(200);
});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().
Conventions
- Tests live in
src/__tests__/within each package - Test files use the
.spec.tssuffix - Use Vitest (
describe,it,expect,vi) - Always call
app.shutdown()inafterEachto clean up - Pass
signals: falsetocreateApp()in tests (the harness does this automatically)