Files
What you'll learn
- Test a controller as a class: spy on the injected service and assert what the handler passed on and returned
- Test a guard in isolation with a fake ExecutionContext and real metadata read through the Reflector
- Know what a unit test of a controller cannot see, and leave routing, pipes and status codes to end-to-end tests
Controllers and Guards
A controller is a class whose methods happen to have decorators. Under a unit test the decorators do nothing: no request arrives, no pipe runs, no guard is consulted, @Param and @Body are inert. What is left is a method that takes arguments, calls a service and returns a value, and that is what a unit test of a controller checks. The same goes for the classes around a handler: a guard is a class with canActivate(), a pipe a class with transform(), and each can be built and called directly, with a stand-in for the context Nest would have passed.
A controller is a thin class
The docs' controller test is the pattern:
describe('OrdersController', () => {
let controller: OrdersController;
let service: OrdersService;
beforeEach(async () => {
const moduleRef = await Test.createTestingModule({
controllers: [OrdersController],
providers: [OrdersService],
}).compile();
controller = moduleRef.get(OrdersController);
service = moduleRef.get(OrdersService);
});
it('returns what the service finds', () => {
const orders = [{ id: 1 }];
jest.spyOn(service, 'findAll').mockReturnValue(orders);
expect(controller.findAll()).toBe(orders);
});
});
controllers in the testing module works as in @Module(), and get() returns the controller with its service injected. The real service is provided and then spied on, so each test scripts only the method it needs. Two things are worth stating in such a test: what the handler returned, and what it passed on. create(dto) should call service.create(dto.name, dto.age), in that order, which is the whole logic of a handler that unpacks a DTO, and toHaveBeenCalledWith is the assertion for it. toBe on the returned array is right here for once: the handler should return the service's array itself, not a copy.
What such a test cannot tell you: whether @Post() is on the right method, whether ParseIntPipe turns '2' into 2, what status code goes out, whether the guard is applied. Those live in the decorators, which only an application exercises, and the next lesson tests them through real requests. A unit test that tries to check routing by reading decorator metadata is testing Nest, not your code.
A guard is a class with one method
@Injectable()
export class RolesGuard implements CanActivate {
constructor(private readonly reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean { ... }
}
Build it with the testing module, providing the Reflector it injects (a plain provider from @nestjs/core), and call canActivate() with a context of your own making. ExecutionContext is an interface with many methods; the guard uses three. A fake needs exactly those, cast to the interface:
function contextFor(handler: () => void, user?: { roles: string[] }): ExecutionContext {
return {
getHandler: () => handler,
getClass: () => Fixture,
switchToHttp: () => ({ getRequest: () => ({ user }) }),
} as unknown as ExecutionContext;
}
The cast through unknown is deliberate: the object is not a complete ExecutionContext, and the test is saying so. Libraries such as @golevelup/ts-jest generate such fakes; for three methods a literal is clearer.
Real metadata on a fixture
The guard reads roles through the Reflector, which reads what @Roles() stored on a method. Faking the reflector would test nothing, so give it real metadata: a small class in the spec, with one decorated method and one bare method:
class Fixture {
@Roles(['admin', 'staff'])
protectedHandler() {}
openHandler() {}
}
Fixture.prototype.protectedHandler is a function carrying the metadata, exactly as a controller's method would, and getHandler() returns it. Now every branch of the guard is reachable from a test: no metadata, metadata and no user, a user with one of the roles, a user with none. Choose the fixture's roles so that "one of them" and "all of them" differ, or a guard that demands every role passes the same tests as one that demands any.
Your task
Two spec files. In cats.controller.spec.ts, the testing module is built; write the three tests: findAll() returns what the service's findAll() returns, create() hands the DTO's name and age to the service in that order and returns the created cat, findOne(2) asks the service for cat 2. In roles.guard.spec.ts, complete contextFor() and write the three tests it describes.
Five bugs to catch: two in the controller, three in the guard.
When it fails
Nest can't resolve dependencies of the RolesGuard (?):Reflectoris not in the testing module's providers.context.switchToHttp is not a function: the fake context lacks a method the guard calls. Add it; the cast hides the gap from the compiler, not from the runtime.- The guard returns
undefinedfor the open handler:getHandler()returns a function without metadata, and the guard is not the one from Basics. Check whatreflector.getreturns foropenHandler. - "demands every role" escapes: the test user holds all the roles the fixture requires. Give the fixture two roles and the user one.
Remember
- A controller under unit test is a class: spy on the service, assert what the handler passed on and returned.
- Decorators are inert in a unit test; routing, pipes, guards and status codes are tested through requests.
- A guard is built with the
Reflectorand called with a hand-made context, cast throughunknown. - Put real metadata on a fixture class so the
Reflectorreads something true.
Stuck? Show a hint
Controller: jest.spyOn(service, 'findAll').mockReturnValue(cats) then expect(controller.findAll()).toBe(cats); for create, spy without scripting and expect(spy).toHaveBeenCalledWith('Tom', 3). Guard: contextFor returns { getHandler: () => handler, getClass: () => Fixture, switchToHttp: () => ({ getRequest: () => ({ user }) }) } as unknown as ExecutionContext; call guard.canActivate with Fixture.prototype.openHandler and Fixture.prototype.protectedHandler and users with roles ['staff'] and ['reader'].
Press Run tests to start the app. Its log appears here.Tests
- cats/cats.controller.spec.tsrun to see its tests
- auth/roles.guard.spec.tsrun to see its tests
Bugs your tests must catch
Each of these replaces one file with a buggy version and runs your tests again. A bug is caught when at least one test fails against it. Tests that pass whatever the code does are not tests.
- create passes age 0 to the servicecats/cats.controller.ts
- findOne always asks for cat 1cats/cats.controller.ts
- a missing user is refused with 403 instead of 401auth/roles.guard.ts
- the guard demands every required role, not one of themauth/roles.guard.ts
- a handler without @Roles is treated as admin-onlyauth/roles.guard.ts