How to Unit Test a NestJS Controller with Jest
September 11, 2026
This is the written companion to my video NestJS Controller Unit Testing. It builds directly on How to Unit Test a NestJS Service with Jest — read that first if describe/it/beforeEach, the TestingModule, or mocking with jest.fn() are new to you; this post assumes them. Same repo, same branch: HaiderMalik12/nestjs-unit-testing, branch 02-unit-test-testing-module.
The controller we're testing
UsersController wraps UsersService behind two routes — GET /users/:id and POST /users:
@Controller("users")
export class UsersController {
constructor(private readonly userService: UsersService) {}
@Get(":id")
findById(@Param("id") id: string) {
return this.userService.findById(Number("1"));
}
@Post()
createUser(
@Body()
body: {
name: string;
email: string;
},
) {
return this.userService.createUser(body.name, body.email);
}
}One thing worth being upfront about: findById doesn't actually forward the route's id param to the service call — it's hardcoded to Number("1"). That's the real, currently-committed code, not a copy error here. For what this post is testing (mocking the service dependency, verifying the controller calls through correctly), it doesn't get in the way — the test below happens to call the route with id: "1", so it passes either way. It's worth fixing before this ships for real, and it's a good illustration of something unit tests don't automatically catch: a test only proves what it actually asserts. If a test here asserted toHaveBeenCalledWith(999) after calling the route with a different id, it would catch this immediately.
Testing a controller is the same technique, different dependency
Nothing new conceptually: a controller test mocks whatever the controller directly depends on — here, UsersService — the same way the service's own tests mocked UsersRepository in the previous post. The one structural difference is how the TestingModule is built: a controller under test goes in controllers, not providers:
describe("UsersController", () => {
let controller: UsersController;
const mockUsersService = {
findById: jest.fn(),
createUser: jest.fn(),
};
beforeEach(async () => {
const module = await Test.createTestingModule({
controllers: [UsersController],
providers: [
{ provide: UsersService, useValue: mockUsersService },
UsersRepository,
],
}).compile();
controller = module.get<UsersController>(UsersController);
});
it("should be defined", () => {
expect(controller).toBeDefined();
});
});mockUsersService only stubs the two methods the controller actually calls — findById and createUser — not every method UsersService has. A controller test isn't responsible for the service's full surface, only the slice it uses.
UsersRepository is also listed as a provider here, even though UsersService is fully replaced by mockUsersService and nothing should need the real repository at all. It's harmless — nothing resolves it — but it's not doing anything either; a leaner version of this setup could drop it.
The should be defined test is a cheap first check worth keeping as a habit: if dependency injection is wired wrong, this fails immediately with a clear stack trace, instead of every real test failing later with a more confusing error.
Testing GET /users/:id
Mock what findById resolves to, call the controller method directly (not through HTTP — this is a unit test, not an integration test), and check the result:
it("should return a user by id", async () => {
mockUsersService.findById.mockResolvedValue({
id: "1",
name: "John",
email: "john@example.com",
});
const result = await controller.findById("1");
expect(result).toEqual({
id: "1",
name: "John",
email: "john@example.com",
});
expect(mockUsersService.findById).toHaveBeenCalledWith(1);
});
it("should return undefined when user is not found", async () => {
mockUsersService.findById.mockResolvedValue(undefined);
const result = await controller.findById("8888");
expect(result).toBeUndefined();
});toHaveBeenCalledWith(1) — a number, even though the route param id arrives as the string "1" — checks that the controller's Number(...) conversion actually happened before the call reached the service. This is the kind of check that's easy to skip and easy to get quietly wrong; asserting on the converted value, not just that the mock was called at all, is what actually verifies the conversion logic.
Testing POST /users
Same pattern, this time mocking createUser:
it("should create a new user", async () => {
mockUsersService.createUser.mockResolvedValue({
id: 1,
name: "John",
email: "john@gmail.com",
});
const result = await controller.createUser({
name: "John",
email: "john@gmail.com",
});
expect(result).toEqual({
id: 1,
name: "John",
email: "john@gmail.com",
});
});Testing what happens when the service throws
A controller test should also cover the service failing, not just succeeding — mockRejectedValue simulates that without needing the service to actually be in a failing state:
it("should throw an error when the services fails", async () => {
mockUsersService.createUser.mockRejectedValue(
new Error("something went wrong"),
);
await expect(
controller.createUser({ name: "jane", email: "ja@gmail.com" }),
).rejects.toThrow("something went wrong");
});
it("should throw an error when findById fails", async () => {
mockUsersService.findById.mockRejectedValue(
new Error("something went wrong"),
);
await expect(controller.findById("1")).rejects.toThrow(
"something went wrong",
);
});Both follow the same shape as the error test in the service post: rejects.toThrow(...) on the call itself, matcher chained directly onto the expect(...) rather than awaited separately. It's easy to write await expect(await controller.findById("1")).rejects... by instinct — that awaits the rejection before expect ever sees it, and the test breaks. expect(promise).rejects.toThrow(...) needs the promise, not its resolved value.
What this does and doesn't prove
A controller unit test with the service mocked proves the controller calls the right service method, with the right (converted) arguments, and handles both what the service returns and what it throws. It does not prove the service and controller work correctly together, and it does not touch HTTP, routing, validation pipes, or guards at all — controller.findById("1") is a plain method call, not a request. That's genuinely a different kind of test — an integration or e2e test — and it's why a NestJS project usually has both kinds, not one instead of the other. NestJS Integration Testing covers exactly that: this same controller and service, wired to a real repository, with nothing mocked.
Full source for this post: users.controller.ts and users.controller.spec.ts on branch 02-unit-test-testing-module.