Skip to content

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:

typescript
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:

typescript
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:

typescript
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:

typescript
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:

typescript
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:

typescript
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:

typescript
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:

typescript
@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:

typescript
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:

typescript
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:

typescript
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:

typescript
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:

SuffixLaneRuns against
*.spec.tsunitin-process, mocked externals — always runs
*.integration.spec.tsintegrationa real backend (pnpm --filter <pkg> test:integration)
*.e2e.spec.tse2ea 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/testingdescribe<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:

typescript
// 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.ts suffix (.integration.spec.ts / .e2e.spec.ts for the other lanes)
  • Use Vitest (describe, it, expect, vi)
  • Always call app.shutdown() in afterEach to clean up
  • Pass signals: false to createApp() in tests (the harness does this automatically)