Testing in NestJS isn't hard once you understand that everything goes through the DI container. The @nestjs/testing package lets you build a minimal version of your app - just the providers you care about, with real or mocked dependencies - and run tests against it.
The key question upfront: unit test or e2e?
Most projects need both. Unit tests for logic, e2e for the critical paths.
Say you have an OrdersService that depends on a PaymentsService:
@Injectable()
export class OrdersService {
constructor(private readonly paymentsService: PaymentsService) {}
async placeOrder(itemId: string, amount: number) {
const charged = await this.paymentsService.charge(amount);
if (!charged) throw new Error("Payment failed"
Occasional notes on software, tools, and things I learn. No spam.
Unsubscribe anytime.
To test this without spinning up the full app:
import { Test, TestingModule } from "@nestjs/testing";
import { OrdersService } from "./orders.service";
import { PaymentsService } from "../payments/payments.service";
describe("OrdersService", () => {
let service: OrdersService;
let paymentsService: jest.Mocked<PaymentsService>;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
OrdersService,
{
provide: PaymentsService,
useValue: { charge: jest.fn() },
},
],
}).compile();
service = module.get<OrdersService>(OrdersService);
paymentsService = module.get(PaymentsService);
});
it("confirms order when payment succeeds", async () => {
paymentsService.charge.mockResolvedValue(true);
const result = await service.placeOrder("item-1", 100);
expect(result.status).toBe("confirmed");
});
it("throws when payment fails", async () => {
paymentsService.charge.mockResolvedValue(false);
await expect(service.placeOrder("item-1", 100)).rejects.toThrow(
"Payment failed",
);
});
});Test.createTestingModule() builds a minimal NestJS module. { provide: PaymentsService, useValue: ... } replaces the real service with your mock for everything in that module. module.get() retrieves the instance from the container.
Controllers depend on services, so the setup is the same - mock the services, register the controller:
describe("OrdersController", () => {
let controller: OrdersController;
let ordersService: jest.Mocked<OrdersService>;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [OrdersController],
providers: [
{
provide: OrdersService,
useValue: { placeOrder: jest.fn() },
},
],
}).compile();
controller = module.get<OrdersController>(OrdersController);
ordersService = module.get(OrdersService);
});
it("returns order from service", async () => {
ordersService.placeOrder.mockResolvedValue({
itemId: "item-1",
status: "confirmed",
});
const result = await controller.placeOrder({
itemId: "item-1",
amount: 100,
});
expect(result.status).toBe("confirmed");
});
});Unit testing a controller is useful for checking it calls the right service method with the right arguments. For testing the HTTP layer - status codes, validation pipes, guards - e2e is a better fit.
useValue is the simplest. Pass a plain object with jest.fn() methods. Use this for most unit tests.
useFactory is useful when the mock needs to read from another provider:
{
provide: PaymentsService,
useFactory: (config: ConfigService) => ({
charge: jest.fn().mockResolvedValue(config.get('MOCK_CHARGE_RESULT')),
}),
inject: [ConfigService],
}jest.spyOn is for when you want the real implementation but need to intercept specific calls:
jest.spyOn(paymentsService, "charge").mockResolvedValue(true);Decision rule: use useValue by default. Use useFactory when the mock depends on an injected value. Use spyOn when you want to partially mock a real instance.
Mocking ConfigService comes up constantly. A quick pattern that covers most cases:
{
provide: ConfigService,
useValue: {
get: jest.fn((key: string) => ({ DATABASE_URL: 'postgres://localhost/test' })[key]),
},
}For context on how ConfigService is typically wired up with Zod validation, this post on NestJS env vars with Zod covers the setup you'd be mocking here.
E2e tests boot the actual NestJS app and send HTTP requests through it. Instead of scattering moduleRef.get() calls and loose let declarations across a describe block, put the whole setup in a factory function that returns everything the test file needs:
// test/helpers/create-orders-app.ts
import { Test } from "@nestjs/testing";
import { INestApplication, ValidationPipe } from "@nestjs/common";
import { AppModule } from "../../src/app.module";
import { OrdersService } from "../../src/orders/orders.service";
export async function createOrdersTestApp() {
const moduleRef = await Test.createTestingModule({
imports: [AppModule],
}).compile();
const app = moduleRef.createNestApplication();
app.useGlobalPipes(new ValidationPipe()); // match your main.ts setup
await app.init();
return {
app,
moduleRef,
ordersService: moduleRef.get<OrdersService>(OrdersService),
};
}The test file stays clean - destructure what you need, nothing more:
import * as request from "supertest";
import { INestApplication } from "@nestjs/common";
import { OrdersService } from "../src/orders/orders.service";
import { createOrdersTestApp } from "./helpers/create-orders-app";
describe("Orders (e2e)", () => {
let app: INestApplication;
let ordersService: OrdersService;
beforeAll(async () => {
({ app, ordersService } = await createOrdersTestApp());
});
afterAll(async () => {
await app.close();
});
it("POST /orders returns 201", () => {
return request(app.getHttpServer())
.post("/orders")
.send({ itemId: "item-1", amount: 100 })
.expect(201)
.expect((res) => {
expect(res.body.status).toBe("confirmed");
});
});
});app.useGlobalPipes() and other global setup from main.ts won't be applied automatically in tests - add it manually in the factory.
Database: real vs mocked
Real test database: spin up a separate Postgres instance (Docker works well), run migrations before the suite, truncate tables between tests. You get confidence that your queries actually work. The tradeoff is setup complexity and slower CI.
Mocking the repository: override the TypeORM/Drizzle/Prisma provider before compiling. Faster and simpler, but you're not testing the actual queries.
For TypeORM, .overrideProvider() slots into the factory cleanly:
export async function createOrdersTestApp() {
const moduleRef = await Test.createTestingModule({
imports: [AppModule],
})
.overrideProvider(getRepositoryToken(Order))
.useValue({
find: jest.fn().mockResolvedValue([]),
save: jest.fn().mockResolvedValue({ id: 1, status: "confirmed" }),
})
.compile();
const app = moduleRef.createNestApplication();
app.useGlobalPipes(new ValidationPipe());
await app.init();
return { app, moduleRef };
}For Drizzle, override whichever provider wraps your db instance.
A pragmatic split: mock the DB in most e2e tests, keep one or two integration tests that hit a real database for the critical data paths.
Override the guard inside the factory before compiling:
export async function createOrdersTestApp() {
const moduleRef = await Test.createTestingModule({
imports: [AppModule],
})
.overrideGuard(AuthGuard)
.useValue({ canActivate: () => true })
.compile();
const app = moduleRef.createNestApplication();
await app.init();
return { app, moduleRef };
}If you want guards active - to test the real auth flow - keep them in and either generate a real JWT in beforeAll or mock the JwtService via .overrideProvider() in the factory.
Testing an interceptor in isolation:
import { of, lastValueFrom } from "rxjs";
const interceptor = new LoggingInterceptor();
const mockContext = createMock<ExecutionContext>(); // @golevelup/ts-jest
const mockNext = { handle: () => of("response") };
const result = await lastValueFrom(
interceptor.intercept(mockContext, mockNext),
);
expect(result).toBe("response");createMock from @golevelup/ts-jest saves a lot of boilerplate when you need a typed mock of ExecutionContext or ArgumentsHost - they have many methods to stub otherwise.
If you're testing a NestJS microservice setup specifically - with ClientProxy mocks on the client side and @MessagePattern handlers on the server - the NestJS microservices post shows the full two-service architecture you'd be writing tests against.