Testing Controllers
A controller method is a plain method: call it directly, assert on what it returns, and know which pipeline steps you skipped.
await Test.createTestingModule({ controllers: [OrdersController], providers: [...] }).compile();A controller is a class whose methods are bound to routes. In a test you can skip the routing entirely and call the method yourself, which makes the test fast and the assertions obvious: you check the return value.
What you are skipping
For a real request, Nest runs a fixed order of steps: middleware (raw request and response objects, before routing is resolved), then guards (return true or false to allow the request), then interceptors (code before and after the handler), then pipes (transform and validate each handler argument), then your handler, then the tail of those interceptors, then exception filters (turn a thrown error into a response).
A direct call runs only the handler. Nothing decorated with @UseGuards, @UseInterceptors or @UsePipes executes. Parameter decorators do nothing either: @Param('id') id: string is just the first parameter, so you pass 'o1' positionally. The route decorators take a path and nothing else — Get(path?: string | string[]), so @Get(':id') or @Get([':id', ':slug']), never an object — and the path does not matter here either, because no URL is matched.
How to write it
Put the controller in controllers, bind its service to a double, and call methods on the instance from moduleRef.get(). Assert on the returned value, and on the arguments the service received.
What trips people up
A controller test proves wiring, not protocol. Status codes, validation errors, serialisation and auth all come from the pipeline, so they belong in an end-to-end test.
Example
// src/orders/orders.controller.spec.ts
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { Test, TestingModule } from '@nestjs/testing';
import { NotFoundException } from '@nestjs/common';
import { OrdersController } from './orders.controller.js';
import { OrdersService } from './orders.service.js';
import { CreateOrderDto } from './dto/create-order.dto.js';
// The controller under test declares:
// @Get(':id') findOne(@Param('id') id: string)
// @Post() create(@Body() dto: CreateOrderDto)
describe('OrdersController', () => {
let controller: OrdersController;
const orders = { findOne: vi.fn(), create: vi.fn() };
beforeEach(async () => {
vi.clearAllMocks();
const moduleRef: TestingModule = await Test.createTestingModule({
controllers: [OrdersController],
providers: [{ provide: OrdersService, useValue: orders }],
}).compile();
controller = moduleRef.get(OrdersController);
});
it('returns whatever the service found', async () => {
orders.findOne.mockResolvedValue({ id: 'o1', total: 2500 });
// No URL is parsed, so @Param('id') does nothing: arguments are positional,
// in the order the handler declares them.
await expect(controller.findOne('o1')).resolves.toEqual({ id: 'o1', total: 2500 });
expect(orders.findOne).toHaveBeenCalledWith('o1');
});
it('passes the body through to the service unchanged', async () => {
// A hand-built DTO. ValidationPipe is part of the request pipeline and is not
// running here, so this object is never validated or transformed.
const dto: CreateOrderDto = { sku: 'A-1', quantity: 2 };
orders.create.mockResolvedValue({ id: 'o2', ...dto });
await controller.create(dto);
expect(orders.create).toHaveBeenCalledWith(dto);
});
it('lets a service error escape as an exception, not a response', async () => {
orders.findOne.mockRejectedValue(new NotFoundException('no such order'));
// Exception filters run after the handler in a real request. Nothing converts
// this into a 404 body here, so assert on the thrown exception itself.
await expect(controller.findOne('missing')).rejects.toThrow(NotFoundException);
});
});When to use it
- Check that a handler forwards the right arguments to its service and returns the service's value.
- Assert a handler maps a service result into the response shape the API documents.
- Cover every branch of a handler that chooses between two services, without booting the app.
- Confirm a renamed service method breaks the controller at compile time, by keeping the real class and spying on it.
More examples
A handler that takes a custom parameter decorator
Shows the boundary clearly: a custom parameter decorator is a function Nest calls while building handler arguments, so a direct call bypasses it and you supply the value. Use this when the handler logic is worth testing but the extraction is trivial. If the extraction itself is the risky part - reading a header, defaulting a missing claim - test it over HTTP instead.
// src/auth/current-user.decorator.ts
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
export const CurrentUser = createParamDecorator(
(_data: unknown, ctx: ExecutionContext) => ctx.switchToHttp().getRequest().user,
);
// src/orders/my-orders.controller.ts
import { Controller, Get } from '@nestjs/common';
import { CurrentUser } from '../auth/current-user.decorator.js';
import { OrdersService } from './orders.service.js';
@Controller('my-orders')
export class MyOrdersController {
constructor(private readonly orders: OrdersService) {}
@Get()
list(@CurrentUser() user: { id: string }) {
return this.orders.findForUser(user.id);
}
}
// src/orders/my-orders.controller.spec.ts
import { expect, it, vi } from 'vitest';
import { Test } from '@nestjs/testing';
import { MyOrdersController } from './my-orders.controller.js';
import { OrdersService } from './orders.service.js';
it('asks for the orders of the current user', async () => {
const orders = { findForUser: vi.fn().mockResolvedValue([]) };
const moduleRef = await Test.createTestingModule({
controllers: [MyOrdersController],
providers: [{ provide: OrdersService, useValue: orders }],
}).compile();
const controller = moduleRef.get(MyOrdersController);
// The decorator's factory never runs: it needs an ExecutionContext, which only
// exists during a request. Pass the value it would have produced.
await controller.list({ id: 'u1' });
expect(orders.findForUser).toHaveBeenCalledWith('u1');
});Spying on a real service instead of replacing it
This is the pattern in the NestJS docs. It keeps the real class, so the test breaks if findAll is renamed, and mockResolvedValue is type-checked against the real return type - an object literal double is not. The cost is that CatsService must be constructible: its own dependencies have to resolve, which pulls the test towards an integration test.
// src/cats/cats.controller.spec.ts
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { Test, TestingModule } from '@nestjs/testing';
import { CatsController } from './cats.controller.js';
import { CatsService } from './cats.service.js';
describe('CatsController', () => {
let controller: CatsController;
let service: CatsService;
beforeEach(async () => {
const moduleRef: TestingModule = await Test.createTestingModule({
controllers: [CatsController],
providers: [CatsService], // the real provider is constructed
}).compile();
// Both come out of the same container, so the instance the controller holds
// is the instance returned here. Spying on it affects the controller.
service = moduleRef.get(CatsService);
controller = moduleRef.get(CatsController);
});
it('returns the list the service produced', async () => {
const result = [{ name: 'Luna' }];
vi.spyOn(service, 'findAll').mockResolvedValue(result);
await expect(controller.findAll()).resolves.toBe(result);
});
});Why the status code is not here
Keep this distinction in mind when deciding where a test belongs. @HttpCode, @Header, @Redirect, guards, interceptors and filters are all pipeline behaviour and are invisible to a direct call. Anything expressed as a decorator on the handler, rather than as code inside it, is covered end to end or not at all.
// src/orders/orders.controller.ts
import { Body, Controller, HttpCode, Post, UseGuards } from '@nestjs/common';
import { ApiKeyGuard } from '../auth/api-key.guard.js';
import { CreateOrderDto } from './dto/create-order.dto.js';
import { OrdersService } from './orders.service.js';
@Controller('orders')
export class OrdersController {
constructor(private readonly orders: OrdersService) {}
@Post('drafts')
@HttpCode(202) // response metadata, applied by the HTTP layer
@UseGuards(ApiKeyGuard) // runs before the handler, in a real request only
draft(@Body() dto: CreateOrderDto) {
return this.orders.draft(dto);
}
}
// src/orders/orders.controller.spec.ts (excerpt)
import { expect, it, vi } from 'vitest';
it('creates a draft', async () => {
// A direct call to controller.draft(dto) returns the service's value.
// It cannot observe 202, and ApiKeyGuard is never consulted, so a missing or
// wrong API key still 'passes' here. Both facts need an end-to-end test:
// await request(app.getHttpServer()).post('/orders/drafts').send(dto).expect(202);
expect(true).toBe(true);
});
Discussion