Testing
DolphJS ships an official testing package, @dolphjs/testing, built specifically around the framework’s Dependency Injection model. It’s a devDependency only — nothing in it ships to production — and every app scaffolded by dolph new already has it installed and wired up.
Why DolphJS testing looks a little different
Section titled “Why DolphJS testing looks a little different”Most DI-based frameworks resolve services per-request, through a container you can swap out for a test run. DolphJS doesn’t work that way. A service is resolved once — the moment the module declaring its @Component is first imported — and cached forever in a process-wide singleton registry (GlobalServiceRegistry, see Components).
That’s a deliberately simple design, but it changes what “mocking a service” means in practice: your mock has to already be sitting in the registry before the component module is imported for the first time. If the real service gets there first, it wins, and your mock is never consulted.
Everything below is designed around that one constraint — either by avoiding it entirely (most of your suite should never touch the registry) or by sequencing around it correctly when you do.
Installation
Section titled “Installation”If your project was scaffolded with dolph new, this is already done — skip to The Three Testing Tiers. Otherwise:
pnpm add -D @dolphjs/testing jest ts-jest @types/jest supertest @types/supertestThe Three Testing Tiers
Section titled “The Three Testing Tiers”Write specs at the cheapest tier that can prove the behavior. In a healthy DolphJS test suite, most of your tests should be Tier 1.
Tier 1 — Service unit tests
Section titled “Tier 1 — Service unit tests”A service is a plain class. DolphServiceHandler adds nothing but a name field, and no decoration is required to construct one directly — @Component only matters once a service needs to be resolved through the registry, which a unit test never needs to do. Just new it with mocked collaborators.
import { CatsService } from './cats.service';
describe('CatsService', () => { it('returns every cat found by the model', async () => { // catModel is normally injected by @InjectMongo — here we hand it in directly const mockCatModel = { find: jest.fn().mockResolvedValue([{ name: 'Whiskers' }]) }; const service = new CatsService(); (service as any).catModel = mockCatModel;
const result = await service.findAll();
expect(result).toEqual([{ name: 'Whiskers' }]); expect(mockCatModel.find).toHaveBeenCalledWith({}); });});No @dolphjs/testing import, no Express, no registry. This is the fastest tier — sub-millisecond per test — and where the bulk of your business-logic coverage should live.
Tier 2 — Controller unit tests
Section titled “Tier 2 — Controller unit tests”Prefer constructor injection over field injection on controllers you plan to unit test. DolphJS supports both — @Component populates a controller’s dependencies either by matching constructor parameter types or, if the controller has no constructor parameters, by defining a property on the instance for every service in the component. That fallback property is named after the service class itself (e.g. a property named exactly CatsService, not catsService), which means unit-testing a field-injected controller means reaching past TypeScript to set that property by hand:
// works at runtime via @Component, but awkward to unit test directlyexport class CatsController extends DolphControllerHandler<Dolph> { private CatsService!: CatsService; // property name must match the class name exactly}Constructor injection sidesteps this entirely:
@Route('cats')export class CatsController extends DolphControllerHandler<Dolph> { constructor(private readonly catsService: CatsService) { super(); }
@Get() async findAll() { return this.catsService.findAll(); // auto-return — see Controllers }}import { CatsController } from './cats.controller';
describe('CatsController', () => { it('delegates to the injected service', async () => { const mockService = { findAll: jest.fn().mockResolvedValue([{ name: 'Whiskers' }]) }; const controller = new CatsController(mockService as any);
await expect(controller.findAll()).resolves.toEqual([{ name: 'Whiskers' }]); });});If your handler writes to res directly instead of using an auto-return (see Controllers), mock the pieces of DResponse your handler actually calls:
const res = { set: jest.fn().mockReturnThis(), status: jest.fn().mockReturnThis(), json: jest.fn().mockReturnThis(),} as unknown as DResponse;
await controller.findAll(req, res);expect(res.status).toHaveBeenCalledWith(200);Still no Express, no DolphFactory, no registry — just the controller class and a mock.
Tier 3 — Component e2e tests
Section titled “Tier 3 — Component e2e tests”For the one thing Tiers 1 and 2 deliberately skip — real routing, real @Route/@Get metadata, real middleware — use createTestingApp. This is the only tier that needs @dolphjs/testing’s runtime helpers, and the only one where the decoration-time DI constraint from the top of this page actually matters.
import request from 'supertest';import { createTestingApp, TestingApp } from '@dolphjs/testing';import { CatsService } from './cats.service';
describe('CatsComponent', () => { let app: TestingApp;
beforeAll(async () => { app = await createTestingApp({ components: [() => import('./cats.component').then((m) => m.CatsComponent)], overrides: [ { service: CatsService, useValue: { findAll: jest.fn().mockResolvedValue([{ name: 'Whiskers' }]) } }, ], }); });
afterAll(() => app.close());
it('serves the real route, backed by the mocked service', async () => { const res = await request(app.engine).get('/cats');
expect(res.status).toBe(200); expect(res.body.data).toEqual([{ name: 'Whiskers' }]); });});Two details here are load-bearing, not stylistic:
componentstakes a lazy loader, not a static import. Writingimport { CatsComponent } from './cats.component'at the top of the file runs@Componentimmediately — beforebeforeAllgets a chance to calloverrides— so the realCatsServicewould already be built and cached by the time your mock arrives.createTestingAppseedsoverridesinto the registry first, then awaits your loader, so the import happens only after the mock is in place.app.engineis a real, already-listening server, not a bare Express app.createTestingAppnever callsDolphFactory#start(), so none of its process-levelSIGTERM/uncaughtExceptionhandlers get installed — but it does bind one ephemeral port viahttp.createServer(engine).listen(0), reused for every request in the spec. supertest binds a fresh ephemeral server on every single call when handed a bare Express app instead of one already listening — fine for a one-off request, expensive for a spec with several.app.close()(async — awaits the server closing) tears it down.
API Reference
Section titled “API Reference”TestingRegistry
Section titled “TestingRegistry”A typed facade over the framework’s GlobalServiceRegistry, for cases createTestingApp’s overrides option doesn’t cover.
| Method | Description |
|---|---|
TestingRegistry.override(ServiceClass, mock) |
Seeds a mock instance so the next @Component resolution for ServiceClass picks it up instead of constructing the real service. |
TestingRegistry.get(ServiceClass) |
Reads whatever is currently cached — the real instance or a mock. |
TestingRegistry.has(ServiceClass) |
Checks whether anything has been cached for this class yet. |
TestingRegistry.reset() |
Clears every cached service, process-wide. Call between test files or describe blocks that each build their own app — never mid-file while another in-flight test still needs its services resolved. |
createTestingApp(options)
Section titled “createTestingApp(options)”interface TestingAppOptions { components: ComponentRef[]; // component classes, or `() => import('./x.component')` loaders overrides?: { service: Ctor; useValue: any }[]; middlewares?: RequestHandler[];}
interface TestingApp { engine: Server; // a real, listening server — pass straight to supertest get<T>(ServiceClass: Ctor<T>): T | undefined; close(): Promise<void>; // closes the server, then clears the registry — call in afterAll}Database helpers
Section titled “Database helpers”Each helper lazily require()s its driver, so an app that only uses Mongoose never resolves typeorm/sequelize, and vice versa — install only what your app actually uses.
createSqliteTestDataSource({ entities, synchronize? }) — an in-memory, better-sqlite3-backed TypeORM DataSource. Entity classes are passed directly (no glob strings, no dolph_config.yaml involved):
import { createSqliteTestDataSource } from '@dolphjs/testing';import { Cat } from './cat.entity';
const dataSource = await createSqliteTestDataSource({ entities: [Cat] });const repo = dataSource.getRepository(Cat);createSqliteTestSequelize() — an in-memory, sqlite3-backed Sequelize instance. Hands back a bare, connected instance; call Model.init({...}, { sequelize }) against it in your test setup, since Sequelize models bind to an instance at init time rather than being re-attachable afterward.
createMongoMemoryTestServer() — spins up a real, ephemeral MongoDB instance via mongodb-memory-server. Returns { uri, stop } — pass uri to mongoose.connect() (or autoInitMongo({ url: uri })), and call stop() in teardown.
import { createMongoMemoryTestServer } from '@dolphjs/testing';import mongoose from 'mongoose';
let mongo: Awaited<ReturnType<typeof createMongoMemoryTestServer>>;
beforeAll(async () => { mongo = await createMongoMemoryTestServer(); await mongoose.connect(mongo.uri);});
afterAll(async () => { await mongoose.disconnect(); await mongo.stop();});Jest configuration
Section titled “Jest configuration”dolph new writes this automatically for TypeScript projects, but if you’re wiring it up by hand:
module.exports = { preset: 'ts-jest', testEnvironment: 'node', // Jest's default testMatch picks up `*.spec.ts` / `*.test.ts`, but NOT // `*.e2e-spec.ts` — there's no dot before "spec" for the default pattern // to match. List it explicitly or your e2e specs silently never run. testMatch: ['**/*.spec.ts', '**/*.e2e-spec.ts'], testPathIgnorePatterns: ['/node_modules/', '/app/', '/dist/'],};Why createTestingApp never calls .start()
Section titled “Why createTestingApp never calls .start()”DolphFactory#start() does two things beyond wiring the Express app: it binds a real OS port, and it installs process-level SIGTERM/uncaughtException/unhandledRejection handlers. The first is fine to repeat; the second isn’t — registering those handlers once per spec file, inside the same Jest worker, is exactly why a .start()-based suite typically needs jest --forceExit --detectOpenHandles to exit cleanly.
createTestingApp calls DolphFactory#engine() instead, which returns the app with routing, middleware, the 404 fallback, and any custom setGlobalExceptionHandler() fully wired — everything start() does short of the process-level handlers — then binds one ephemeral port itself via http.createServer(engine).listen(0), closed by app.close(). A suite built entirely on Tier 1–3 as described above should exit on its own, with neither Jest flag.
Generating tests with the CLI
Section titled “Generating tests with the CLI”dolph generate can scaffold both the controller and service spec for a component in one step, matching whatever the generators for those files already produced:
dolph generate --test users# or, as part of a full feature:dolph generate --all userssrc/components/users/├── users.component.ts├── users.controller.ts├── users.controller.spec.ts # generated├── users.service.ts├── users.service.spec.ts # generated└── users.dto.tsSee the CLI reference for the full flag table.
Enterprise notes
Section titled “Enterprise notes”-
Co-locate by default.
*.spec.tsnext to the file it tests keeps unit coverage visible in the same PR diff as the code it covers — the convention this page (and the CLI generator) uses throughout. -
Reserve
*.e2e-spec.tsfor the routing/middleware path. It’s the only tier that pays forDolphFactory,@Componentresolution, and (optionally) a real database — keep it a deliberately small slice of the suite. -
Enforce coverage thresholds in CI, the same way the framework’s own suite does:
jest.config.js coverageThreshold: {global: { branches: 40, functions: 50, lines: 60, statements: 60 },}, -
TestingRegistry.reset()between test files, not mid-file. It clears the entire process-wide registry — safe between isolateddescribeblocks or files, unsafe while a test in the same file still expects a service to already be resolved.