Пошук уроків, статей та іншого контенту
Протестуєте обробку запитів контролерами, виклики сервісів і формування відповідей.
Контролер відповідає за:
приймання параметрів запиту;
передавання цих параметрів сервісу;
повернення результату сервісу;
вибір HTTP-методу та маршруту;
обробку помилкових параметрів, якщо використовуються пайпи.
У модульних тестах контролера не потрібно перевіряти бізнес-логіку сервісу. Замість реального сервісу використовують mock-об’єкт і перевіряють:
чи викликав контролер правильний метод сервісу;
чи передав правильні аргументи;
чи повернув результат сервісу;
як контролер працює через HTTP-маршрут.
Нехай застосунок має контролер для роботи із завданнями.
// create-task.dto.ts
export class CreateTaskDto {
title: string;
}// tasks.service.ts
import { Injectable } from '@nestjs/common';
import { CreateTaskDto } from './create-task.dto';
export interface Task {
id: number;
title: string;
}
@Injectable()
export class TasksService {
async findAll(): Promise<Task[]> {
return [];
}
async findOne(id: number): Promise<Task> {
return {
id,
title: `Task ${id}`,
};
}
async create(dto: CreateTaskDto): Promise<Task> {
return {
id: Date.now(),
title: dto.title,
};
}
}// tasks.controller.ts
import {
Body,
Controller,
Get,
Param,
ParseIntPipe,
Post,
} from '@nestjs/common';
import { CreateTaskDto } from './create-task.dto';
import { Task, TasksService } from './tasks.service';
@Controller('tasks')
export class TasksController {
constructor(private readonly tasksService: TasksService) {}
@Get()
findAll(): Promise<Task[]> {
return this.tasksService.findAll();
}
@Get(':id')
findOne(@Param('id', ParseIntPipe) id: number): Promise<Task> {
return this.tasksService.findOne(id);
}
@Post()
create(@Body() dto: CreateTaskDto): Promise<Task> {
return this.tasksService.create(dto);
}
}Метод findOne використовує ParseIntPipe. Тому значення параметра id буде перетворено з рядка URL на число ще до виклику методу контролера.
Для модульного тесту створюють окремий TestingModule. Реальний TasksService замінюють mock-об’єктом.
const tasksServiceMock = {
findAll: jest.fn(),
findOne: jest.fn(),
create: jest.fn(),
};Кожен метод є функцією Jest, тому можна:
задати значення, яке він повертає;
перевірити кількість викликів;
перевірити аргументи виклику.
const moduleRef = await Test.createTestingModule({
controllers: [TasksController],
providers: [
{
provide: TasksService,
useValue: tasksServiceMock,
},
],
}).compile();
const controller = moduleRef.get(TasksController);У результаті Nest створить TasksController, але передасть йому не справжній сервіс, а tasksServiceMock.
Ось повний приклад тесту методів контролера.
// tasks.controller.spec.ts
import { Test, TestingModule } from '@nestjs/testing';
import { CreateTaskDto } from './create-task.dto';
import { TasksController } from './tasks.controller';
import { Task, TasksService } from './tasks.service';
describe('TasksController', () => {
let controller: TasksController;
const tasksServiceMock = {
findAll: jest.fn(),
findOne: jest.fn(),
create: jest.fn(),
};
beforeEach(async () => {
jest.clearAllMocks();
const moduleRef: TestingModule = await Test.createTestingModule({
controllers: [TasksController],
providers: [
{
provide: TasksService,
useValue: tasksServiceMock,
},
],
}).compile();
controller = moduleRef.get<TasksController>(TasksController);
});
it('повертає всі завдання', async () => {
const tasks: Task[] = [
{ id: 1, title: 'Вивчити NestJS' },
{ id: 2, title: 'Написати тест' },
];
tasksServiceMock.findAll.mockResolvedValue(tasks);
await expect(controller.findAll()).resolves.toEqual(tasks);
expect(tasksServiceMock.findAll).toHaveBeenCalledTimes(1);
expect(tasksServiceMock.findAll).toHaveBeenCalledWith();
});
it('передає id сервісу та повертає одне завдання', async () => {
const task: Task = {
id: 10,
title: 'Перевірити контролер',
};
tasksServiceMock.findOne.mockResolvedValue(task);
await expect(controller.findOne(10)).resolves.toEqual(task);
expect(tasksServiceMock.findOne).toHaveBeenCalledTimes(1);
expect(tasksServiceMock.findOne).toHaveBeenCalledWith(10);
});
it('передає DTO сервісу під час створення завдання', async () => {
const dto: CreateTaskDto = {
title: 'Нове завдання',
};
const createdTask: Task = {
id: 20,
title: dto.title,
};
tasksServiceMock.create.mockResolvedValue(createdTask);
await expect(controller.create(dto)).resolves.toEqual(createdTask);
expect(tasksServiceMock.create).toHaveBeenCalledTimes(1);
expect(tasksServiceMock.create).toHaveBeenCalledWith(dto);
});
});Методи контролера повертають Promise, тому результат можна перевіряти двома способами.
Через resolves:
await expect(controller.findAll()).resolves.toEqual(tasks);Або через await:
const result = await controller.findAll();
expect(result).toEqual(tasks);Варіант із resolves одразу показує, що тест перевіряє асинхронний результат.
Метод toHaveBeenCalledWith перевіряє, з якими аргументами було викликано mock-функцію:
expect(tasksServiceMock.findOne).toHaveBeenCalledWith(10);Це важливо, оскільки контролер може повернути правильний результат навіть у разі неправильного передавання параметрів, якщо mock налаштований занадто загально.
Прямий виклик controller.findOne(10) перевіряє метод класу, але не перевіряє повний HTTP-шлях:
чи правильно оголошено @Get(':id');
чи працює маршрут /tasks/:id;
чи виконується ParseIntPipe;
який HTTP-статус отримує клієнт;
яке тіло відповіді формується NestJS.
Для цього можна створити Nest-застосунок у тесті та виконати запити через supertest.
// tasks.controller.http.spec.ts
import { INestApplication } from '@nestjs/common';
import { Test, TestingModule } from '@nestjs/testing';
import * as request from 'supertest';
import { TasksController } from './tasks.controller';
import { TasksService } from './tasks.service';
describe('TasksController HTTP', () => {
let app: INestApplication;
const tasksServiceMock = {
findAll: jest.fn(),
findOne: jest.fn(),
create: jest.fn(),
};
beforeAll(async () => {
const moduleRef: TestingModule = await Test.createTestingModule({
controllers: [TasksController],
providers: [
{
provide: TasksService,
useValue: tasksServiceMock,
},
],
}).compile();
app = moduleRef.createNestApplication();
await app.init();
});
beforeEach(() => {
jest.clearAllMocks();
});
afterAll(async () => {
await app.close();
});
it('GET /tasks повертає список завдань', async () => {
const tasks = [
{ id: 1, title: 'Перше завдання' },
{ id: 2, title: 'Друге завдання' },
];
tasksServiceMock.findAll.mockResolvedValue(tasks);
await request(app.getHttpServer())
.get('/tasks')
.expect(200)
.expect(tasks);
expect(tasksServiceMock.findAll).toHaveBeenCalledTimes(1);
});
it('GET /tasks/:id передає числовий id сервісу', async () => {
const task = {
id: 7,
title: 'Завдання номер сім',
};
tasksServiceMock.findOne.mockResolvedValue(task);
await request(app.getHttpServer())
.get('/tasks/7')
.expect(200)
.expect(task);
expect(tasksServiceMock.findOne).toHaveBeenCalledWith(7);
});
it('POST /tasks передає тіло запиту сервісу', async () => {
const dto = {
title: 'Створити нове завдання',
};
const createdTask = {
id: 8,
title: dto.title,
};
tasksServiceMock.create.mockResolvedValue(createdTask);
await request(app.getHttpServer())
.post('/tasks')
.send(dto)
.expect(201)
.expect(createdTask);
expect(tasksServiceMock.create).toHaveBeenCalledWith(dto);
});
it('GET /tasks/:id повертає 400 для нечислового id', async () => {
await request(app.getHttpServer())
.get('/tasks/not-a-number')
.expect(400);
expect(tasksServiceMock.findOne).not.toHaveBeenCalled();
});
});У тесті запиту GET /tasks/7 перевіряються одразу кілька рівнів:
маршрут @Get(':id') правильно зіставляється з URL;
ParseIntPipe перетворює "7" на 7;
контролер викликає tasksService.findOne(7);
результат сервісу стає тілом HTTP-відповіді;
застосунок повертає статус 200.
У тесті POST /tasks NestJS за замовчуванням повертає статус 201, оскільки метод позначений декоратором @Post().
Контролер зазвичай не перехоплює помилки сервісу без потреби. Якщо сервіс повертає відхилену обіцянку з NestJS-помилкою, Nest перетворює її на відповідну HTTP-відповідь.
Наприклад, сервіс може викинути NotFoundException:
import { Injectable, NotFoundException } from '@nestjs/common';
@Injectable()
export class TasksService {
async findOne(id: number) {
if (id === 999) {
throw new NotFoundException('Завдання не знайдено');
}
return {
id,
title: `Task ${id}`,
};
}
}Такий сценарій можна перевірити на рівні HTTP-контролера, налаштувавши mock:
it('GET /tasks/:id повертає 404, якщо сервіс не знайшов завдання', async () => {
tasksServiceMock.findOne.mockRejectedValue(
new NotFoundException('Завдання не знайдено'),
);
await request(app.getHttpServer())
.get('/tasks/999')
.expect(404);
expect(tasksServiceMock.findOne).toHaveBeenCalledWith(999);
});Цей тест не перевіряє, чому сервіс не знайшов завдання. Він перевіряє поведінку контролера та HTTP-шару, коли залежність повертає відому помилку.
Ці типи тестів мають різну мету.
Модульний тест напряму викликає метод контролера:
const result = await controller.findOne(10);Переваги:
швидке виконання;
проста ізоляція;
зручно перевіряти аргументи та результат;
не потрібен HTTP-сервер.
Обмеження:
декоратори маршрутів не виконуються;
параметри вже передані у готовому вигляді;
пайпи та HTTP-статуси не перевіряються.
HTTP-тест виконує реальний запит до тестового Nest-застосунку:
await request(app.getHttpServer())
.get('/tasks/7')
.expect(200);Переваги:
перевіряються маршрути;
перевіряються пайпи;
перевіряються HTTP-статуси;
перевіряється тіло відповіді.
Такі тести повільніші, але вони краще показують поведінку контролера з погляду клієнта.
Jest надає кілька способів налаштувати результат mock-функції.
Для синхронного результату:
tasksServiceMock.findOne.mockReturnValue({
id: 1,
title: 'Завдання',
});Для успішного асинхронного результату:
tasksServiceMock.findOne.mockResolvedValue({
id: 1,
title: 'Завдання',
});Для помилки асинхронного методу:
tasksServiceMock.findOne.mockRejectedValue(
new Error('Помилка сервісу'),
);Для перевірки послідовних викликів:
tasksServiceMock.findOne
.mockResolvedValueOnce({ id: 1, title: 'Перше' })
.mockResolvedValueOnce({ id: 2, title: 'Друге' });Після цього перший виклик поверне перше завдання, а другий — друге.
Якщо підключити реальний TasksService, тест контролера почне залежати від його логіки, бази даних або інших залежностей.
Для модульного тесту сервіс потрібно замінити:
{
provide: TasksService,
useValue: tasksServiceMock,
}Недостатньо перевірити лише тіло відповіді:
await expect(controller.findOne(10)).resolves.toEqual(task);Потрібно також перевірити, що сервіс отримав правильний аргумент:
expect(tasksServiceMock.findOne).toHaveBeenCalledWith(10);Інакше тест може не помітити помилку в передаванні параметрів.
У такому тесті:
await controller.findOne('10' as never);ParseIntPipe не виконується. Ви напряму викликаєте метод класу, тому Nest не обробляє параметр URL.
Перевірку перетворення рядка на число потрібно виконувати HTTP-тестом:
await request(app.getHttpServer())
.get('/tasks/10')
.expect(200);
expect(tasksServiceMock.findOne).toHaveBeenCalledWith(10);Виклики mock-функцій зберігаються між тестами. Якщо їх не очищати, один тест може впливати на інший.
Для цього використовують:
beforeEach(() => {
jest.clearAllMocks();
});Тест контролера не повинен перевіряти, як сервіс шукає дані або зберігає їх. Контролерний тест має перевіряти лише взаємодію:
контролер отримав дані;
викликав потрібний метод;
передав правильні аргументи;
повернув результат або дозволив помилці сформувати HTTP-відповідь.
Для тестування контролера створюють TestingModule.
Залежності контролера замінюють mock-об’єктами через useValue.
mockResolvedValue використовують для асинхронних успішних результатів.
toHaveBeenCalledWith перевіряє аргументи виклику сервісу.
Прямий виклик методу підходить для швидких модульних тестів.
HTTP-тести через supertest перевіряють маршрути, пайпи, статуси та тіло відповіді.
Бізнес-логіку сервісу потрібно тестувати окремо від контролера.