Пошук уроків, статей та іншого контенту
Перевірите бізнес-логіку сервісів NestJS за допомогою модульних тестів і замоканих репозиторіїв.
Сервіс зазвичай містить бізнес-логіку застосунку:
перевіряє, чи існує сутність;
змінює її стан;
викликає репозиторій для читання або збереження даних;
повертає результат або викидає виняток.
Під час модульного тестування сервісу не потрібно підключати реальну базу даних. Репозиторій замінюють об’єктом із контрольованими методами jest.fn().
Так тест перевіряє саме логіку сервісу, а не роботу TypeORM чи бази даних.
Нехай у нас є замовлення, яке можна знайти за ідентифікатором або скасувати.
// order.entity.ts
import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm';
export type OrderStatus = 'new' | 'paid' | 'cancelled';
@Entity()
export class Order {
@PrimaryGeneratedColumn()
id: number;
@Column()
customerEmail: string;
@Column({
default: 'new',
})
status: OrderStatus;
}// orders.service.ts
import {
BadRequestException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Order } from './order.entity';
@Injectable()
export class OrdersService {
constructor(
@InjectRepository(Order)
private readonly ordersRepository: Repository<Order>,
) {}
async findById(id: number): Promise<Order> {
const order = await this.ordersRepository.findOne({
where: { id },
});
if (!order) {
throw new NotFoundException(`Замовлення з id ${id} не знайдено`);
}
return order;
}
async create(customerEmail: string): Promise<Order> {
const order = this.ordersRepository.create({
customerEmail,
status: 'new',
});
return this.ordersRepository.save(order);
}
async cancel(id: number): Promise<Order> {
const order = await this.findById(id);
if (order.status === 'cancelled') {
throw new BadRequestException('Замовлення вже скасоване');
}
order.status = 'cancelled';
return this.ordersRepository.save(order);
}
}У сервісі є кілька сценаріїв, які потрібно перевірити:
findById повертає замовлення, якщо воно існує.
findById викидає NotFoundException, якщо замовлення не знайдено.
create створює замовлення зі статусом new.
cancel змінює статус замовлення на cancelled.
cancel не дозволяє повторно скасувати замовлення.
Мок — це об’єкт, який імітує залежність сервісу.
У цьому прикладі сервіс використовує лише три методи репозиторію:
findOne;
create;
save.
Тому в тесті не потрібно реалізовувати весь API Repository. Достатньо створити тільки потрібні методи:
const repository = {
findOne: jest.fn(),
create: jest.fn(),
save: jest.fn(),
};Кожен метод є функцією Jest, тому можна:
задати значення, яке він повертає;
перевірити, з якими аргументами його викликали;
перевірити кількість викликів.
Наприклад:
repository.findOne.mockResolvedValue(order);
repository.save.mockResolvedValue(order);
repository.create.mockReturnValue(order);mockResolvedValue використовують для асинхронних методів, які повертають Promise.
mockReturnValue використовують для синхронних методів, таких як repository.create.
NestJS дозволяє створити тестовий модуль за допомогою Test.createTestingModule.
Оскільки сервіс отримує репозиторій через @InjectRepository(Order), у тестовому модулі потрібно замінити цей репозиторій за допомогою токена getRepositoryToken(Order).
// orders.service.spec.ts
import { Test, TestingModule } from '@nestjs/testing';
import { BadRequestException, NotFoundException } from '@nestjs/common';
import { getRepositoryToken } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Order } from './order.entity';
import { OrdersService } from './orders.service';
describe('OrdersService', () => {
let service: OrdersService;
let repository: {
findOne: jest.Mock;
create: jest.Mock;
save: jest.Mock;
};
beforeEach(async () => {
repository = {
findOne: jest.fn(),
create: jest.fn(),
save: jest.fn(),
};
const module: TestingModule = await Test.createTestingModule({
providers: [
OrdersService,
{
provide: getRepositoryToken(Order),
useValue: repository,
},
],
}).compile();
service = module.get<OrdersService>(OrdersService);
});
it('повертає замовлення за ідентифікатором', async () => {
const order: Order = {
id: 1,
customerEmail: 'user@example.com',
status: 'new',
};
repository.findOne.mockResolvedValue(order);
const result = await service.findById(1);
expect(result).toEqual(order);
expect(repository.findOne).toHaveBeenCalledWith({
where: { id: 1 },
});
});
it('викидає NotFoundException, якщо замовлення не знайдено', async () => {
repository.findOne.mockResolvedValue(null);
await expect(service.findById(999)).rejects.toThrow(NotFoundException);
expect(repository.findOne).toHaveBeenCalledWith({
where: { id: 999 },
});
});
it('створює нове замовлення зі статусом new', async () => {
const order: Order = {
id: 1,
customerEmail: 'user@example.com',
status: 'new',
};
repository.create.mockReturnValue(order);
repository.save.mockResolvedValue(order);
const result = await service.create('user@example.com');
expect(repository.create).toHaveBeenCalledWith({
customerEmail: 'user@example.com',
status: 'new',
});
expect(repository.save).toHaveBeenCalledWith(order);
expect(result).toEqual(order);
});
it('скасовує замовлення', async () => {
const order: Order = {
id: 1,
customerEmail: 'user@example.com',
status: 'paid',
};
repository.findOne.mockResolvedValue(order);
repository.save.mockResolvedValue({
...order,
status: 'cancelled',
});
const result = await service.cancel(1);
expect(order.status).toBe('cancelled');
expect(repository.save).toHaveBeenCalledWith(order);
expect(result.status).toBe('cancelled');
});
it('не дозволяє повторно скасувати замовлення', async () => {
const order: Order = {
id: 1,
customerEmail: 'user@example.com',
status: 'cancelled',
};
repository.findOne.mockResolvedValue(order);
await expect(service.cancel(1)).rejects.toThrow(BadRequestException);
expect(repository.save).not.toHaveBeenCalled();
});
});У цьому тесті немає підключення до бази даних. NestJS створює OrdersService і передає йому об’єкт repository, визначений у тесті.
Кожен тест бажано будувати за схемою Arrange — Act — Assert:
Arrange — підготувати дані та поведінку моків.
Act — викликати метод сервісу.
Assert — перевірити результат і взаємодію із залежностями.
Наприклад, тест створення замовлення:
it('створює нове замовлення зі статусом new', async () => {
// Arrange
const order = {
id: 1,
customerEmail: 'user@example.com',
status: 'new' as const,
};
repository.create.mockReturnValue(order);
repository.save.mockResolvedValue(order);
// Act
const result = await service.create('user@example.com');
// Assert
expect(result).toEqual(order);
expect(repository.create).toHaveBeenCalledWith({
customerEmail: 'user@example.com',
status: 'new',
});
});Такий поділ робить тест зрозумілим: із нього видно, які дані підготували, що викликали та який результат очікуємо.
Асинхронний метод, який викидає виняток, потрібно перевіряти через rejects.
await expect(service.findById(999)).rejects.toThrow(NotFoundException);Не потрібно використовувати try...catch для звичайної перевірки винятків. Запис через expect(...).rejects коротший і явно показує, що метод має завершитися відхиленою Promise.
Можна перевіряти не лише тип винятку, а й повідомлення:
await expect(service.findById(999)).rejects.toThrow(
'Замовлення з id 999 не знайдено',
);Модульний тест має перевіряти не тільки результат, а й важливі взаємодії сервісу з репозиторієм.
expect(repository.findOne).toHaveBeenCalledWith({
where: { id: 1 },
});Це гарантує, що сервіс шукає саме замовлення з потрібним ідентифікатором.
expect(repository.save).toHaveBeenCalledTimes(1);Якщо метод не повинен викликатися:
expect(repository.save).not.toHaveBeenCalled();У тесті для вже скасованого замовлення це перевіряє, що сервіс завершує роботу до виклику save.
expect(result).toEqual(order);toEqual порівнює значення об’єктів, а не лише посилання на них.
Кожен тест повинен мати незалежний стан. У прикладі новий мок і новий TestingModule створюються в beforeEach.
Це важливо, тому що Jest зберігає стан мок-функцій між викликами. Якщо створити мок лише один раз, виклики з попереднього тесту можуть вплинути на наступний.
Для вже створеного спільного мока можна очищати його виклики:
beforeEach(() => {
jest.clearAllMocks();
});Однак очищення викликів не завжди скидає задані значення mockResolvedValue або mockReturnValue. Тому створення нового об’єкта мока в beforeEach часто є простішим і надійнішим підходом.
У типовому NestJS-проєкті тест можна запустити командами:
npm test -- orders.service.spec.tsДля запуску тестів у режимі спостереження:
npm run test:watch -- orders.service.spec.tsДля перевірки покриття:
npm run test:covНазви скриптів визначаються у package.json, але в стандартному NestJS-проєкті використовуються саме такі команди.
Для модульного тесту сервісу база даних не потрібна. Підключення реальної БД:
уповільнює тести;
ускладнює налаштування;
робить результат залежним від зовнішнього стану;
перетворює тест на інтеграційний.
Замість цього потрібно передати мок через useValue.
Якщо сервіс використовує:
@InjectRepository(Order)у тестовому модулі потрібно зареєструвати:
{
provide: getRepositoryToken(Order),
useValue: repository,
}Реєстрація лише Repository або Order не замінить залежність, яку шукає NestJS.
awaitАсинхронний виклик потрібно очікувати:
const result = await service.findById(1);Для перевірки помилки також необхідно повернути або очікувати Promise:
await expect(service.findById(999)).rejects.toThrow(NotFoundException);Без await тест може завершитися раніше, ніж виконається перевірка.
Не кожен внутрішній виклик потрібно перевіряти. Варто перевіряти лише те, що має значення для бізнес-логіки:
правильний результат;
потрібний виняток;
важливі аргументи репозиторію;
відсутність небажаного збереження.
Надмірна перевірка кожного внутрішнього кроку робить тести крихкими: зміна реалізації може ламати тести, навіть якщо поведінка сервісу залишилася правильною.
Модульні тести сервісів перевіряють бізнес-логіку без реальної бази даних.
Залежності сервісу замінюють моками через useValue.
Для репозиторію TypeORM використовують getRepositoryToken(Entity).
Асинхронні методи мокають через mockResolvedValue, синхронні — через mockReturnValue.
Винятки перевіряють через rejects.toThrow.
Важливо перевіряти як результат роботи сервісу, так і правильну взаємодію з репозиторієм.
Кожен тест повинен бути ізольованим і незалежним від інших тестів.