InteractiveFrameworks

Controllers and Guards

Unit-test a controller by spying on its service, and a guard by handing it a hand-made ExecutionContext with real metadata on a fixture class.

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

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 (?): Reflector is 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 undefined for the open handler: getHandler() returns a function without metadata, and the guard is not the one from Basics. Check what reflector.get returns for openHandler.
  • "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 Reflector and called with a hand-made context, cast through unknown.
  • Put real metadata on a fixture class so the Reflector reads 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'].