Skip to content
NestJS ns testing 5 min read

Testing Controllers

Controllers in NestJS are thin: they map incoming requests to handler methods, extract parameters, and delegate the real work to providers. A good controller test reflects that boundary—it confirms that the right service method is called with the right arguments and that the handler returns what the service hands back. By mocking the service, you keep these tests fast, deterministic, and focused on routing and delegation rather than business logic or database access.

What a controller test should prove

Because the controller’s job is orchestration, your assertions should target three things:

  • The handler delegates to the correct service method.
  • The handler forwards parameters (path params, query, body) unchanged or correctly transformed.
  • The handler returns the service result (or maps it as documented).

Anything deeper—validation rules, persistence, complex computation—belongs in the service’s own unit tests. Keep the controller test honest by replacing the service with a mock.

The system under test

Here is a small controller and the service it depends on.

// users.service.ts
import { Injectable, NotFoundException } from '@nestjs/common';

export interface User {
  id: string;
  name: string;
}

@Injectable()
export class UsersService {
  async findOne(id: string): Promise<User> {
    // Real implementation talks to a repository.
    throw new NotFoundException(`User ${id} not found`);
  }

  async create(name: string): Promise<User> {
    return { id: 'generated', name };
  }
}
// users.controller.ts
import { Body, Controller, Get, Param, Post } from '@nestjs/common';
import { UsersService, User } from './users.service';

@Controller('users')
export class UsersController {
  constructor(private readonly usersService: UsersService) {}

  @Get(':id')
  getUser(@Param('id') id: string): Promise<User> {
    return this.usersService.findOne(id);
  }

  @Post()
  createUser(@Body('name') name: string): Promise<User> {
    return this.usersService.create(name);
  }
}

Building the testing module with a mock service

Test.createTestingModule builds an isolated DI container. Declare the real controller but register a mock object in place of the real UsersService using a value provider. The mock exposes Jest functions for every method the controller touches.

// users.controller.spec.ts
import { Test, TestingModule } from '@nestjs/testing';
import { UsersController } from './users.controller';
import { UsersService, User } from './users.service';

describe('UsersController', () => {
  let controller: UsersController;
  let service: jest.Mocked<UsersService>;

  beforeEach(async () => {
    const mockUsersService: Partial<jest.Mocked<UsersService>> = {
      findOne: jest.fn(),
      create: jest.fn(),
    };

    const module: TestingModule = await Test.createTestingModule({
      controllers: [UsersController],
      providers: [
        { provide: UsersService, useValue: mockUsersService },
      ],
    }).compile();

    controller = module.get<UsersController>(UsersController);
    service = module.get(UsersService);
  });

  afterEach(() => jest.clearAllMocks());
});

Resolving the same UsersService token from the module returns the mock, so you can both stub return values and assert on calls through the typed service reference.

Asserting delegation and return values

A controller test passes when the handler calls the service correctly and returns its result. Use mockResolvedValue to stub async methods, then assert the call signature.

it('delegates getUser to UsersService.findOne and returns the user', async () => {
  const expected: User = { id: '42', name: 'Ada' };
  service.findOne.mockResolvedValue(expected);

  const result = await controller.getUser('42');

  expect(service.findOne).toHaveBeenCalledTimes(1);
  expect(service.findOne).toHaveBeenCalledWith('42');
  expect(result).toBe(expected);
});

it('forwards the request body to UsersService.create', async () => {
  const created: User = { id: 'generated', name: 'Grace' };
  service.create.mockResolvedValue(created);

  const result = await controller.createUser('Grace');

  expect(service.create).toHaveBeenCalledWith('Grace');
  expect(result).toEqual(created);
});

Note toBe versus toEqual: toBe checks reference identity (proving the controller returned the exact object the service produced), while toEqual does a deep value comparison.

Verifying error propagation

Controllers do not usually catch service errors—exceptions bubble up to NestJS exception filters. Assert that a rejected service promise rejects the handler too.

import { NotFoundException } from '@nestjs/common';

it('lets NotFoundException propagate from the service', async () => {
  service.findOne.mockRejectedValue(new NotFoundException('missing'));

  await expect(controller.getUser('999')).rejects.toThrow(NotFoundException);
});

Spying instead of replacing

When you want the real service wired up but still need to observe a single method, register the real provider and attach a spy. This is useful when a handler depends on real service composition you do not want to reconstruct.

const realService = module.get(UsersService);
const spy = jest
  .spyOn(realService, 'findOne')
  .mockResolvedValue({ id: '7', name: 'Linus' });

await controller.getUser('7');
expect(spy).toHaveBeenCalledWith('7');

Prefer full mocks (useValue) for controller tests. A spy still runs real construction logic and can pull in dependencies you would rather not provide. Reach for spies only when you deliberately want real behavior on the side.

Running the suite produces output like this.

Output:

 PASS  src/users/users.controller.spec.ts
  UsersController
    ✓ delegates getUser to UsersService.findOne and returns the user (4 ms)
    ✓ forwards the request body to UsersService.create (1 ms)
    ✓ lets NotFoundException propagate from the service (3 ms)

Test Suites: 1 passed, 1 total
Tests:       3 passed, 3 total
Time:        1.402 s

Mock styles compared

StyleHow to registerWhen to use
Value mock{ provide: Service, useValue: mock }Default for controller tests; fully isolates the service
Factory mock{ provide: Service, useFactory: () => createMock() }Generating fresh mocks per test or sharing a factory helper
Spy on realjest.spyOn(realService, 'method')You need real service behavior but want to assert one call
Auto-mockuseMocker callback on the builderMany providers; mock them all without listing each

The useMocker option is handy when a controller has several dependencies:

const module = await Test.createTestingModule({
  controllers: [UsersController],
})
  .useMocker((token) => {
    if (token === UsersService) {
      return { findOne: jest.fn(), create: jest.fn() };
    }
  })
  .compile();

Best practices

  • Register services with useValue so controller tests stay isolated and fast—never hit a real database or HTTP client.
  • Assert both the call (toHaveBeenCalledWith) and the return value; testing only one leaves delegation half-verified.
  • Clear mocks in afterEach with jest.clearAllMocks() to prevent call counts leaking between tests.
  • Type your mock as jest.Mocked<Service> (or Partial<jest.Mocked<Service>>) for autocomplete and compile-time safety.
  • Test error propagation with rejects.toThrow; controllers should pass service exceptions through untouched.
  • Keep business-rule and persistence assertions in the service’s own unit tests, not the controller’s.
  • Use end-to-end tests when you need to verify real routing, guards, pipes, and serialization through the HTTP layer.
Last updated June 14, 2026
Was this helpful?