@neoma/managed-app
v0.6.0
Published
A package for creating a managed NestJs application in tests.
Maintainers
Readme
@neoma/managed-app
A NestJS testing utility that provides managed application instance handling for E2E tests with automatic lifecycle management and singleton pattern support.
Description
@neoma/managed-app simplifies NestJS E2E testing by providing a convenient utility to create, manage, and reuse application instances across your test suites. It eliminates boilerplate code, ensures proper cleanup, and supports dynamic module loading from configurable paths.
Features
- Singleton Pattern: Automatically manages a single application instance across tests
- Automatic Lifecycle Management: Built-in Jest hooks handle app initialization and cleanup
- Dynamic Module Loading: Load NestJS modules from configurable file paths via environment variables
- Type-Safe: Full TypeScript support with comprehensive type definitions
- Build Callback: Customise the
TestingModuleBuilderbefore compilation (override providers, guards, interceptors, etc.) - Configure Callback: Hook into the app instance before initialization (global prefix, view engines, etc.)
- NestJS Application Options: Pass
NestApplicationOptions(e.g.rawBody,cors) to the underlyingcreateNestApplication()call - Zero Configuration: Works out of the box with sensible defaults
Installation
npm install --save-dev @neoma/managed-appPeer Dependencies
This package requires NestJS v11.x or higher:
npm install @nestjs/common @nestjs/core @nestjs/testingQuick Start
import { INestApplication } from "@nestjs/common"
import * as request from "supertest"
import { managedAppInstance } from "@neoma/managed-app"
describe("My API", () => {
let app: INestApplication
beforeEach(async () => {
app = await managedAppInstance()
})
it("should return 200 OK", () => {
return request(app.getHttpServer())
.get("/health")
.expect(200)
})
})API Reference
managedAppInstance(options?)
Creates and returns a managed NestJS application instance. Supports multiple isolated instances based on different module paths, with automatic caching and cleanup.
async function managedAppInstance(
options?: string | ManagedAppOptions
): Promise<INestApplication<App>>Parameters:
options(optional): Either a module path string or aManagedAppOptionsobject:- As a string: Module path in format
path/to/module.ts#ExportedModuleName - As an object:
module(optional): Module path in the same format as abovebuild(optional): Callback invoked afterTest.createTestingModule()but beforecompile(). Receives and must return theTestingModuleBuilderfor overriding providers, guards, interceptors, etc.nestApplicationOptions(optional):NestApplicationOptionspassed tocreateNestApplication(). Merged on top of{ bufferLogs: true }, so consumer values take precedence over defaults.configure(optional): Callback invoked after app creation but beforeinit(). Can be sync or async.
- As a string: Module path in format
Returns: A Promise that resolves to a NestJS application instance
Module Path Resolution (in order of precedence):
- Function parameter: Direct module path passed to the function
- Environment variable:
NEOMA_MANAGED_APP_MODULE_PATH - Default:
src/application/application.module.ts#ApplicationModule
Multi-App Support:
- Each unique module path gets its own cached instance
- Different module paths = different isolated app instances
- Same module path = same cached instance (singleton per path)
Examples:
// Basic usage (default module)
describe("Default API Tests", () => {
let app: INestApplication
beforeEach(async () => {
app = await managedAppInstance()
})
it("should handle requests", () => {
return request(app.getHttpServer())
.get("/api/users")
.expect(200)
})
})
// Using environment variable
describe("Custom Module Tests", () => {
beforeEach(() => {
process.env.NEOMA_MANAGED_APP_MODULE_PATH = "src/test/test.module.ts#TestModule"
})
it("should load custom module", async () => {
const app = await managedAppInstance()
// Uses test module configuration
})
})
// Using direct parameter (highest precedence)
describe("Parameter Module Tests", () => {
it("should load specific module", async () => {
const app = await managedAppInstance("src/integration/integration.module.ts#IntegrationModule")
// Uses integration module regardless of env var
})
})
// Using a configure callback
describe("MPA Tests", () => {
it("should apply global prefix", async () => {
const app = await managedAppInstance({
module: "src/mpa/mpa.module.ts#MpaModule",
configure: (app) => {
app.setGlobalPrefix("api")
},
})
return request(app.getHttpServer())
.get("/api/users")
.expect(200)
})
})
// Using an async configure callback
describe("Async Configuration", () => {
it("should support async setup", async () => {
const app = await managedAppInstance({
configure: async (app) => {
app.setGlobalPrefix("v1")
app.enableCors()
},
})
// App is fully configured and initialized
})
})
// Using a build callback to override providers
describe("Provider Override Tests", () => {
it("should use the mock service", async () => {
const app = await managedAppInstance({
module: "src/app/app.module.ts#AppModule",
build: (builder) =>
builder.overrideProvider(MyService).useValue({
findAll: jest.fn().mockResolvedValue([]),
}),
})
return request(app.getHttpServer())
.get("/items")
.expect(200)
.expect([])
})
})
// Passing NestJS application options (e.g. rawBody for Stripe webhooks)
describe("Raw Body Tests", () => {
it("should enable raw body parsing", async () => {
const app = await managedAppInstance({
module: "src/app/app.module.ts#AppModule",
nestApplicationOptions: { rawBody: true },
})
return request(app.getHttpServer())
.post("/webhooks/stripe")
.send('{"type":"checkout.session.completed"}')
.expect(200)
})
})
// Multi-app scenario
describe("Multi-App Tests", () => {
it("should handle multiple app configurations", async () => {
const defaultApp = await managedAppInstance()
const testApp = await managedAppInstance("src/test/test.module.ts#TestModule")
// Different instances, different configurations
expect(defaultApp).not.toBe(testApp)
})
})ManagedAppOptions
interface ManagedAppOptions {
module?: string
nestApplicationOptions?: NestApplicationOptions
build?: (builder: TestingModuleBuilder) => TestingModuleBuilder
configure?: (app: INestApplication<App>) => void | Promise<void>
}| Property | Type | Description |
|--------------------------|--------------------------|--------------------------------------------------------------------------------------------------------------------------------|
| module | string | Module path in format path/to/module.ts#ExportedModuleName |
| nestApplicationOptions | NestApplicationOptions | Options passed to createNestApplication(). Merged on top of { bufferLogs: true } — consumer values take precedence. |
| build | function | Callback invoked before compile(). Receives the TestingModuleBuilder for overriding providers, guards, interceptors, etc. |
| configure | function | Callback invoked after app creation, before init(). Can be sync or async. |
Note:
build,nestApplicationOptions, andconfigureare only applied on the first call for a given module path. Subsequent calls return the cached instance.
nestJsApp(module, build?, nestApplicationOptions?)
A basic utility function that creates a NestJS test application instance from a provided module.
Note: This function does NOT provide managed features (no singleton pattern, no automatic cleanup). If you need managed lifecycle, use managedAppInstance() instead.
async function nestJsApp(
m: Type<any> | DynamicModule | Promise<DynamicModule> | ForwardReference,
build?: (builder: TestingModuleBuilder) => TestingModuleBuilder,
nestApplicationOptions?: NestApplicationOptions
): Promise<INestApplication<App>>Parameters:
m: The NestJS module to create the app frombuild(optional): Callback to customise theTestingModuleBuilderbefore compilationnestApplicationOptions(optional):NestApplicationOptionspassed tocreateNestApplication(). Merged on top of{ bufferLogs: true }.
Returns: A Promise that resolves to a NestJS application instance
Example:
const app = await nestJsApp(AppModule)
// Remember to manually close the app when done
await app.close()Configuration
Environment Variables
NEOMA_MANAGED_APP_MODULE_PATH
Specifies the path and export name of the module to load for managedAppInstance().
Format: path/to/module.ts#ExportedModuleName
Examples:
# Use a custom test module
export NEOMA_MANAGED_APP_MODULE_PATH=src/test/e2e.module.ts#E2ETestModule
# Use environment-specific module
export NEOMA_MANAGED_APP_MODULE_PATH=src/env/test.module.ts#TestModuleDefault: src/application/application.module.ts#ApplicationModule
Module Path Syntax
The module path uses a hash (#) to separate the file path from the export name:
src/my/module/path.ts#MyModuleName
└─────┬──────────────┘ └─────┬──────┘
File Path Export Name- File Path: Relative path from project root to the TypeScript module file
- Export Name: The named export from that file
Usage Patterns
Basic E2E Testing
import { INestApplication } from "@nestjs/common"
import * as request from "supertest"
import { managedAppInstance } from "@neoma/managed-app"
describe("Users API", () => {
let app: INestApplication
beforeEach(async () => {
app = await managedAppInstance()
})
it("should create a user", () => {
return request(app.getHttpServer())
.post("/users")
.send({ name: "John Doe" })
.expect(201)
})
it("should get all users", () => {
return request(app.getHttpServer())
.get("/users")
.expect(200)
})
})Overriding Providers in Tests
Use the build callback to override providers, guards, or interceptors before the module compiles — ideal for mocking services in e2e tests:
import { INestApplication } from "@nestjs/common"
import * as request from "supertest"
import { managedAppInstance } from "@neoma/managed-app"
import { MailService } from "../src/mail/mail.service"
describe("Mail API", () => {
let app: INestApplication
beforeEach(async () => {
app = await managedAppInstance({
module: "src/app/app.module.ts#AppModule",
build: (builder) =>
builder.overrideProvider(MailService).useValue({
send: jest.fn().mockResolvedValue({ success: true }),
}),
})
})
it("should send mail using the mock", () => {
return request(app.getHttpServer())
.post("/mail/send")
.send({ to: "[email protected]" })
.expect(200)
})
})Caveat: jest.resetModules() and Symbol-based Tokens
If your test setup calls jest.resetModules() globally (e.g. in a shared afterEach), Symbol-based injection tokens will break when used with the build callback.
Why this happens
This library loads modules dynamically via await import(). When jest.resetModules() clears Jest's module cache, the next dynamic import re-evaluates the module file — creating new Symbol instances for any Symbol-based tokens. A static import { MY_TOKEN } from "..." at the top of your test file still holds the original Symbol from the first evaluation. Since Symbol("X") !== Symbol("X"), overrideProvider(originalSymbol) silently fails to match the provider registered with the new Symbol.
How to avoid it
Option 1 (recommended): Scope jest.resetModules() to only the tests that need it, rather than applying it globally:
// Only in specs that genuinely need module re-evaluation
afterEach(() => {
jest.resetModules()
})Option 2: If you must use a global jest.resetModules(), dynamically import the token in each test so it matches the re-evaluated module:
import { resolve } from "path"
async function loadMyToken() {
const mod = await import(resolve("src/my/service"))
return mod.MY_TOKEN
}
it("should override the provider", async () => {
const TOKEN = await loadMyToken()
const app = await managedAppInstance({
build: (builder) =>
builder.overrideProvider(TOKEN).useValue(mockValue),
})
})Option 3: Use class-based or string-based tokens instead of Symbols — these survive module re-evaluation since their identity is based on value, not reference:
// String token — works across module reloads
const MY_TOKEN = "MY_TOKEN"
// Class token — identity is stable if statically imported
@Injectable()
class MyService { ... }Note: This issue is not specific to this library — it affects any NestJS testing setup that combines dynamic imports,
jest.resetModules(), and Symbol-based injection tokens.
Configuring the App Instance
Use the configure callback to set up the app before it initializes — useful for MPA setups, global prefixes, view engines, static assets, or CORS:
import { managedAppInstance } from "@neoma/managed-app"
import { NestExpressApplication } from "@nestjs/platform-express"
import { join } from "path"
describe("MPA E2E Tests", () => {
let app: INestApplication
beforeEach(async () => {
app = await managedAppInstance({
configure: (app) => {
app.setGlobalPrefix("api")
const expressApp = app as unknown as NestExpressApplication
expressApp.useStaticAssets(join(__dirname, "..", "public"))
expressApp.setBaseViewsDir(join(__dirname, "..", "views"))
expressApp.setViewEngine("hbs")
},
})
})
it("should serve the API under /api", () => {
return request(app.getHttpServer())
.get("/api/health")
.expect(200)
})
})Testing Different Environments
describe("Staging environment tests", () => {
beforeAll(() => {
process.env.NEOMA_MANAGED_APP_MODULE_PATH =
"src/env/staging.module.ts#StagingModule"
})
afterAll(() => {
delete process.env.NEOMA_MANAGED_APP_MODULE_PATH
})
it("should use staging configuration", async () => {
const app = await managedAppInstance()
// Test with staging environment config
})
})Integration with Database Testing
import { managedAppInstance } from "@neoma/managed-app"
import { DataSource } from "typeorm"
describe("Database Integration", () => {
let app: INestApplication
let dataSource: DataSource
beforeEach(async () => {
app = await managedAppInstance()
dataSource = app.get(DataSource)
})
afterEach(async () => {
// Clean up database after each test
await dataSource.synchronize(true)
})
it("should save entity to database", async () => {
// Your database test here
})
})Accessing Services in Tests
import { managedAppInstance } from "@neoma/managed-app"
import { UserService } from "../src/users/user.service"
describe("User Service", () => {
let app: INestApplication
let userService: UserService
beforeEach(async () => {
app = await managedAppInstance()
userService = app.get(UserService)
})
it("should find user by id", async () => {
const user = await userService.findById(1)
expect(user).toBeDefined()
})
})How It Works
Automatic Lifecycle Management
The module automatically registers a Jest afterEach hook at import time:
let appInstance: INestApplication | null = null
afterEach(async () => {
if (appInstance) {
await appInstance.close()
appInstance = null
}
})This ensures:
- No resource leaks between tests
- Clean slate for each test
- Proper shutdown of HTTP servers, database connections, etc.
Singleton Pattern
managedAppInstance() implements a singleton pattern that:
- Creates the app on first call and caches it
- Returns the cached instance on subsequent calls
- Resets after each test via the
afterEachhook
This reduces test overhead while maintaining isolation between test cases.
Error Handling
The module provides clear error messages for common issues:
Module Not Found
Error: Module not found: /path/to/src/missing/module.tsSolution: Verify the file path in NEOMA_MANAGED_APP_MODULE_PATH exists
Export Not Found
Error: Module found but no export named WrongName: /path/to/src/app/app.module.tsSolution: Ensure the export name after # matches the actual export in the file
Testing
Run the test suite:
npm run test:e2eRun with watch mode:
npm run test:e2e -- --watchRequirements
- Node.js >= 22.0.0
- NestJS >= 11.0.0
- TypeScript >= 5.0.0
- Jest (for test lifecycle hooks)
Roadmap
Potential future enhancements:
- Support passing a module directly to
managedAppInstance(module)for inline customization - Optional test isolation modes
License
MIT
Contributing
Contributions are welcome! Please feel free to submit a Pull Request.
Support
If you encounter any issues or have questions, please file an issue on GitHub.
Related Packages
- @neoma/route-model-binding - NestJS route model binding utilities
