Skip to content

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.

If your project was scaffolded with dolph new, this is already done — skip to The Three Testing Tiers. Otherwise:

Terminal window
pnpm add -D @dolphjs/testing jest ts-jest @types/jest supertest @types/supertest

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.

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.

cats.service.spec.ts
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.

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 directly
export class CatsController extends DolphControllerHandler<Dolph> {
private CatsService!: CatsService; // property name must match the class name exactly
}

Constructor injection sidesteps this entirely:

cats.controller.ts
@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
}
}
cats.controller.spec.ts
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.

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.

cats.component.e2e-spec.ts
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:

  • components takes a lazy loader, not a static import. Writing import { CatsComponent } from './cats.component' at the top of the file runs @Component immediately — before beforeAll gets a chance to call overrides — so the real CatsService would already be built and cached by the time your mock arrives. createTestingApp seeds overrides into the registry first, then awaits your loader, so the import happens only after the mock is in place.
  • app.engine is a real, already-listening server, not a bare Express app. createTestingApp never calls DolphFactory#start(), so none of its process-level SIGTERM/uncaughtException handlers get installed — but it does bind one ephemeral port via http.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.

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.
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
}

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();
});

dolph new writes this automatically for TypeScript projects, but if you’re wiring it up by hand:

jest.config.js
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/'],
};

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.

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:

Terminal window
dolph generate --test users
# or, as part of a full feature:
dolph generate --all users
src/components/users/
├── users.component.ts
├── users.controller.ts
├── users.controller.spec.ts # generated
├── users.service.ts
├── users.service.spec.ts # generated
└── users.dto.ts

See the CLI reference for the full flag table.

  • Co-locate by default. *.spec.ts next 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.ts for the routing/middleware path. It’s the only tier that pays for DolphFactory, @Component resolution, 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 isolated describe blocks or files, unsafe while a test in the same file still expects a service to already be resolved.