title: 'Testing · Nael Framework' description: 'Unit and integration test Nael applications with @nl-framework/testing: an overridable testing module plus in-process HTTP, GraphQL, and microservice harnesses — no ports, no Mongo, no Dapr.'
Testing
@nl-framework/testing mirrors @nestjs/testing: build an application
context with overridable providers, then exercise your HTTP, GraphQL, and microservice handlers
entirely in-process. No port is ever bound, and the suite runs with no Mongo, no Dapr, and no network.
Installation
Add the package as a dev dependency. @nl-framework/core is a required peer;
@nl-framework/http, @nl-framework/graphql, and
@nl-framework/microservices are optional peers, loaded lazily — you only need the
transports you actually test.
bun add -d @nl-framework/testingThe testing module
Test.createTestingModule() collects your module definition, then
compile() bootstraps a real ApplicationContext. Resolve providers with
get(), and tear everything down — running the shutdown lifecycle — with
close().
import { Test } from '@nl-framework/testing';const moduleRef = await Test.createTestingModule({ imports: [AppModule], providers: [/* extra providers */],}).compile();const service = await moduleRef.get(UserService);// ...assertions...await moduleRef.close(); // runs onModuleDestroy for every instantiated providerOverrides
Substitute any provider, guard, interceptor, filter, or pipe. Each override terminal accepts
.useValue(), .useClass(), or .useFactory({ factory, inject }).
Because guards, interceptors, filters, and pipes all resolve through the container at runtime, overriding
them is the same token swap as overriding a provider.
const moduleRef = await Test.createTestingModule({ imports: [AppModule] }) .overrideProvider(UserService).useValue(mockUserService) .overrideProvider(CONFIG_TOKEN).useFactory({ factory: () => testConfig }) .overrideGuard(AuthGuard).useValue({ canActivate: () => true }) .overrideInterceptor(CacheInterceptor).useValue(passthroughInterceptor) .overrideFilter(HttpExceptionFilter).useClass(TestFilter) .compile();Overrides are registered before any provider is instantiated, so dependents receive the mock — not the original — when they are constructed.
@Injectable()class GreetingService { greet(name: string) { return `Hello ${name}`; }}@Injectable()class GreetingConsumer { constructor(private readonly service: GreetingService) {} run() { return this.service.greet('world'); }}const moduleRef = await Test.createTestingModule({ imports: [AppModule] }) .overrideProvider(GreetingService) .useValue({ greet: (name: string) => `Mocked ${name}` }) .compile();const consumer = await moduleRef.get(GreetingConsumer);// The consumer was constructed with the mock, not the real service.expect(consumer.run()).toBe('Mocked world');HTTP integration testing
createHttpApplication() returns a client that dispatches requests straight through the routing
pipeline (middleware → guards → interceptors → pipes → handler → filters) via
HttpApplication.handle. No port is bound.
const app = await moduleRef.createHttpApplication();// Raw Responseconst res = await app.request('/users/42', { headers: { 'x-token': 'secret' },});expect(res.status).toBe(200);// Parsed JSON helper (serializes the body, defaults content-type)const { status, body } = await app.requestJson('/users', { method: 'POST', json: { name: 'Grace' },});expect(status).toBe(200);expect(body).toEqual({ id: 'mock', name: 'Grace' });GraphQL execution
createGraphqlApplication() executes operations via Apollo's executeOperation,
with the framework's scoped container resolver attached so guards, interceptors, and field resolvers run
exactly as they would over HTTP.
const gql = await moduleRef.createGraphqlApplication();const { data, errors } = await gql.execute<{ report: { message: string; score: number } }>({ query: /* GraphQL */ ` query Report($score: Int!) { report(score: $score) { message score } } `, variables: { score: 7 },});expect(errors).toBeUndefined();expect(data?.report).toEqual({ message: 'live', score: 7 });Global schema metadata: the GraphQL type registry is a process-wide singleton shared by every test file. If you declare types at module scope, reset it between cases so your suite is independent of run order.
import { GraphqlMetadataStorage, clearGraphqlGuards, clearGraphqlInterceptors,} from '@nl-framework/graphql';beforeEach(() => { GraphqlMetadataStorage.get().clear(); clearGraphqlGuards(); clearGraphqlInterceptors();});Microservice handlers
createMicroserviceHarness() drives MessageDispatcher through an in-memory transport
— no Dapr sidecar. Use send() for request/response patterns and emit() for events.
const harness = await moduleRef.createMicroserviceHarness();// Defaults to the module's discovered controllers.// Pass { controllers: [OrdersController] } to scope it explicitly.// Request/responseconst total = await harness.send<number>('math.double', { value: 21 });expect(total).toBe(42);// Fire-and-forget eventawait harness.emit('math.logged', { value: 5 });The underlying InMemoryTransport is exported too, so you can wire it into a real module.
import { InMemoryTransport, MicroserviceHarness } from '@nl-framework/testing';import { createMicroservicesModule } from '@nl-framework/microservices';const harness = await moduleRef.createMicroserviceHarness();// Reuse the same in-memory transport inside a module wiring.const module = createMicroservicesModule({ transport: harness.getTransport() });Repositories
InMemoryRepository<T> is a dependency-free implementation of the full
OrmRepository contract — find, findOne, findById,
count, insertOne/insertMany, save, updateMany,
softDelete/restore, and deleteHard/deleteMany. It mirrors
MongoRepository's id, timestamp, and soft-delete semantics with a Mongo-style
filter matcher — no real MongoDB and no mongodb-memory-server. Substitute it for a real
repository on its injection token.
import { InMemoryRepository, createInMemoryRepository } from '@nl-framework/testing';import { getRepositoryToken } from '@nl-framework/orm';// Defaults timestamps + softDelete to true, matching @Document.const repo = new InMemoryRepository(User, { seed: [{ name: 'Ada', age: 36 }],});const moduleRef = await Test.createTestingModule({ imports: [AppModule] }) .overrideProvider(getRepositoryToken(User)) .useValue(repo) .compile();const service = await moduleRef.get(UserService);expect(await service.adults()).toHaveLength(1);// Read timestamps/softDelete/collection from the entity's @Document instead:const repo2 = await createInMemoryRepository(User, { seed: [/* ... */] });Extra conveniences: repo.snapshot(), repo.size, and repo.clear(). The
fake does not emit write-notifier events, and @nl-framework/orm stays an optional peer.
A complete example
Putting it together — an HTTP endpoint test that mocks an external payment provider and binds no port.
import { describe, expect, it } from 'bun:test';import { Test } from '@nl-framework/testing';import { AppModule } from '../src/app.module';import { PaymentGateway } from '../src/payments/payment.gateway';describe('POST /orders', () => { it('creates an order without a real payment provider', async () => { const moduleRef = await Test.createTestingModule({ imports: [AppModule] }) .overrideProvider(PaymentGateway) .useValue({ charge: async () => ({ id: 'ch_test', ok: true }) }) .compile(); const app = await moduleRef.createHttpApplication(); const { status, body } = await app.requestJson('/orders', { method: 'POST', json: { sku: 'BOOK-1', quantity: 2 }, }); expect(status).toBe(200); expect(body).toMatchObject({ status: 'paid', chargeId: 'ch_test' }); await moduleRef.close(); });});What's next?
Review Lifecycle events to
understand what close() triggers, or revisit Guards and
Interceptors to see what your overrides replace.