Пошук уроків, статей та іншого контенту
Застосуєте моки, стаби та spy-об’єкти для заміни зовнішніх залежностей у тестах.
Під час модульного тестування потрібно перевіряти поведінку конкретного класу, а не всієї системи одночасно. Якщо сервіс залежить від бази даних, платіжного шлюзу або HTTP-клієнта, ці залежності краще замінити тестовими об’єктами.
Це дає змогу:
не виконувати реальні HTTP-запити;
не підключатися до бази даних;
керувати результатом залежності;
перевіряти, з якими аргументами її викликали;
окремо тестувати успішні та помилкові сценарії.
У NestJS залежності зазвичай передаються через конструктор, тому їх зручно замінювати через TestingModule.
У тестах ці терміни часто використовують разом, але вони описують різні цілі.
Стаб — це об’єкт або функція, які повертають заздалегідь визначений результат.
Наприклад, платіжний сервіс можна замінити стабом, який завжди підтверджує оплату:
{
charge: async () => ({ transactionId: 'test-transaction' }),
}Стаб потрібен, коли важливий результат залежності, а не сам факт її виклику.
Мок — це тестова заміна залежності, яка також запам’ятовує виклики. У Jest мок-функцію зазвичай створюють за допомогою jest.fn().
const charge = jest.fn().mockResolvedValue({
transactionId: 'test-transaction',
});Тепер можна перевірити:
чи викликали функцію;
скільки разів її викликали;
з якими аргументами;
яке значення вона повернула.
Spy стежить за реальною функцією або методом уже наявного об’єкта.
const spy = jest.spyOn(paymentService, 'charge');За замовчуванням метод продовжує виконувати свою реальну реалізацію. За потреби поведінку можна замінити:
spy.mockResolvedValue({
transactionId: 'test-transaction',
});Spy зручно використовувати, коли потрібно протестувати реальний об’єкт, але проконтролювати окремий метод.
Нехай OrdersService створює замовлення та передає суму до платіжного сервісу.
import { Injectable } from '@nestjs/common';
export interface PaymentResult {
transactionId: string;
}
@Injectable()
export class PaymentService {
async charge(
userId: string,
amount: number,
): Promise<PaymentResult> {
// У реальному застосунку тут був би запит до платіжного провайдера
return {
transactionId: `transaction-${userId}-${amount}`,
};
}
}
@Injectable()
export class OrdersService {
constructor(
private readonly paymentService: PaymentService,
) {}
async createOrder(userId: string, amount: number) {
const payment = await this.paymentService.charge(userId, amount);
return {
userId,
amount,
transactionId: payment.transactionId,
status: 'paid',
};
}
}Під час тестування OrdersService не потрібно викликати справжній PaymentService. Тест має перевіряти логіку замовлення, а не роботу платіжного провайдера.
useValueНайпростіший спосіб передати мок у NestJS — використати overrideProvider() і useValue().
import { Test, TestingModule } from '@nestjs/testing';
import { OrdersService, PaymentService } from './orders.service';
describe('OrdersService', () => {
let ordersService: OrdersService;
let paymentMock: {
charge: jest.Mock;
};
beforeEach(async () => {
paymentMock = {
charge: jest.fn(),
};
const module: TestingModule = await Test.createTestingModule({
providers: [
OrdersService,
PaymentService,
],
})
.overrideProvider(PaymentService)
.useValue(paymentMock)
.compile();
ordersService = module.get<OrdersService>(OrdersService);
});
afterEach(() => {
jest.clearAllMocks();
});
it('створює оплачене замовлення після успішної оплати', async () => {
paymentMock.charge.mockResolvedValue({
transactionId: 'transaction-123',
});
const result = await ordersService.createOrder('user-1', 250);
expect(result).toEqual({
userId: 'user-1',
amount: 250,
transactionId: 'transaction-123',
status: 'paid',
});
expect(paymentMock.charge).toHaveBeenCalledTimes(1);
expect(paymentMock.charge).toHaveBeenCalledWith('user-1', 250);
});
it('передає помилку платіжного сервісу далі', async () => {
paymentMock.charge.mockRejectedValue(
new Error('Payment declined'),
);
await expect(
ordersService.createOrder('user-1', 250),
).rejects.toThrow('Payment declined');
expect(paymentMock.charge).toHaveBeenCalledWith('user-1', 250);
});
});У цьому прикладі:
OrdersService залишається справжнім об’єктом.
PaymentService замінюється об’єктом paymentMock.
charge є Jest-моком.
Результат charge задається окремо для кожного тесту.
Перевіряється як результат роботи, так і виклик залежності.
Важливо викликати overrideProvider() до compile(). Після компіляції модуль уже створений, тому замінювати провайдер запізно.
Jest дозволяє задавати різну поведінку мок-функції.
const repositoryMock = {
findById: jest.fn().mockReturnValue({
id: 'order-1',
status: 'created',
}),
};const paymentMock = {
charge: jest.fn().mockResolvedValue({
transactionId: 'transaction-123',
}),
};const paymentMock = {
charge: jest.fn().mockRejectedValue(
new Error('Payment declined'),
),
};const charge = jest
.fn()
.mockResolvedValueOnce({ transactionId: 'transaction-1' })
.mockResolvedValueOnce({ transactionId: 'transaction-2' });Перший виклик поверне transaction-1, а другий — transaction-2.
Для невеликих залежностей можна описати мок вручну:
const paymentMock: Pick<PaymentService, 'charge'> = {
charge: jest.fn(),
};Але TypeScript у такому випадку може не знати, що charge — це Jest-мок із методами mockResolvedValue або toHaveBeenCalledWith.
Можна використати тип jest.Mocked<T>:
const paymentMock = {
charge: jest.fn(),
} as jest.Mocked<PaymentService>;
paymentMock.charge.mockResolvedValue({
transactionId: 'transaction-123',
});Тип jest.Mocked<PaymentService> повідомляє TypeScript, що методи PaymentService замінені на Jest-моки.
Для великої залежності іноді зручніше створювати лише потрібну частину:
const paymentMock = {
charge: jest.fn(),
} as Pick<jest.Mocked<PaymentService>, 'charge'>;Це допомагає не створювати непотрібні методи, які конкретний тест не використовує.
Spy створюють для вже отриманого екземпляра залежності.
У наступному прикладі PaymentService не замінюється через useValue. NestJS створює справжній екземпляр сервісу, а тест стежить за методом charge.
import { Test, TestingModule } from '@nestjs/testing';
import { OrdersService, PaymentService } from './orders.service';
describe('OrdersService with spy', () => {
let ordersService: OrdersService;
let paymentService: PaymentService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
OrdersService,
PaymentService,
],
}).compile();
ordersService = module.get<OrdersService>(OrdersService);
paymentService = module.get<PaymentService>(PaymentService);
});
afterEach(() => {
jest.restoreAllMocks();
});
it('викликає платіжний сервіс із правильними даними', async () => {
const chargeSpy = jest
.spyOn(paymentService, 'charge')
.mockResolvedValue({
transactionId: 'spy-transaction',
});
const result = await ordersService.createOrder('user-2', 500);
expect(result.transactionId).toBe('spy-transaction');
expect(chargeSpy).toHaveBeenCalledTimes(1);
expect(chargeSpy).toHaveBeenCalledWith('user-2', 500);
});
});У цьому тесті:
paymentService є реальним екземпляром NestJS;
jest.spyOn() замінює лише метод charge;
mockResolvedValue() не дає виконати оригінальну реалізацію;
jest.restoreAllMocks() відновлює оригінальні методи після тесту.
Якщо не викликати mockResolvedValue(), spy лише спостерігатиме за реальною реалізацією:
const chargeSpy = jest.spyOn(paymentService, 'charge');
await ordersService.createOrder('user-2', 500);
expect(chargeSpy).toHaveBeenCalledWith('user-2', 500);Такий підхід підходить, коли реальна реалізація проста, безпечна для тесту й не має небажаних зовнішніх ефектів.
Jest має кілька методів для роботи зі станом моків.
jest.clearAllMocks()Очищає інформацію про виклики, але не змінює реалізацію моків.
afterEach(() => {
jest.clearAllMocks();
});Після очищення Jest більше не пам’ятає попередні виклики, але налаштований mockResolvedValue залишиться.
jest.resetAllMocks()Очищає виклики та скидає налаштовану поведінку моків.
afterEach(() => {
jest.resetAllMocks();
});Після цього мок-функція більше не матиме попередніх значень, заданих через mockResolvedValue або mockImplementation.
jest.restoreAllMocks()Відновлює оригінальні реалізації методів, замінених через jest.spyOn().
afterEach(() => {
jest.restoreAllMocks();
});Цей метод особливо важливий для spy, щоб зміна одного тесту не впливала на наступні.
Не всі залежності реєструються в NestJS за класом. Для інтерфейсів зазвичай використовують рядковий або символьний токен.
export const PAYMENT_GATEWAY = Symbol('PAYMENT_GATEWAY');
export interface PaymentGateway {
charge(userId: string, amount: number): Promise<string>;
}Сервіс отримує залежність через @Inject():
import { Inject, Injectable } from '@nestjs/common';
import { PAYMENT_GATEWAY, PaymentGateway } from './payment-gateway';
@Injectable()
export class OrdersService {
constructor(
@Inject(PAYMENT_GATEWAY)
private readonly paymentGateway: PaymentGateway,
) {}
async createOrder(userId: string, amount: number) {
const transactionId = await this.paymentGateway.charge(
userId,
amount,
);
return {
userId,
amount,
transactionId,
status: 'paid',
};
}
}У тесті потрібно перевизначати саме цей токен:
import { Test } from '@nestjs/testing';
import {
OrdersService,
PAYMENT_GATEWAY,
} from './orders.service';
describe('OrdersService', () => {
it('використовує підмінений платіжний шлюз', async () => {
const gatewayMock = {
charge: jest.fn().mockResolvedValue('transaction-456'),
};
const module = await Test.createTestingModule({
providers: [
OrdersService,
{
provide: PAYMENT_GATEWAY,
useValue: gatewayMock,
},
],
}).compile();
const service = module.get(OrdersService);
await expect(
service.createOrder('user-3', 100),
).resolves.toEqual({
userId: 'user-3',
amount: 100,
transactionId: 'transaction-456',
status: 'paid',
});
expect(gatewayMock.charge).toHaveBeenCalledWith(
'user-3',
100,
);
});
});Для токенів важливо, щоб значення в provide, @Inject() і overrideProvider() були тим самим токеном:
.overrideProvider(PAYMENT_GATEWAY)
.useValue(gatewayMock)Заміна класу PaymentService у такій ситуації не спрацює, бо NestJS шукає залежність за PAYMENT_GATEWAY.
Мок не повинен перетворювати тест на перевірку внутрішньої реалізації. Зазвичай достатньо перевірити:
результат методу;
виклик залежності в потрібному сценарії;
аргументи виклику;
реакцію на успішний результат;
реакцію на помилку залежності;
кількість викликів, якщо повторний виклик має значення.
Наприклад, перевірка конкретного виклику:
expect(paymentMock.charge).toHaveBeenCalledWith(
'user-1',
250,
);Перевірка, що залежність не викликалася:
expect(paymentMock.charge).not.toHaveBeenCalled();Перевірка кількості викликів:
expect(paymentMock.charge).toHaveBeenCalledTimes(1);Якщо важливий лише сам факт виклику, не потрібно перевіряти кожну внутрішню деталь обробки даних.
Неправильно замінювати OrdersService моком і потім тестувати його результат. У такому випадку тест не перевіряє код OrdersService.
Підміняти потрібно його залежності:
OrdersService -> PaymentServiceOrdersService має залишатися справжнім.
Якщо залежність інжектиться через символ або рядок, потрібно замінити саме цей токен, а не клас реалізації.
.overrideProvider(PAYMENT_GATEWAY)
.useValue(gatewayMock)Поведінку мока потрібно налаштувати до виконання коду, який його викликає:
paymentMock.charge.mockResolvedValue({
transactionId: 'transaction-123',
});
const result = await ordersService.createOrder('user-1', 250);mockResolvedValue і синхронного результатуДля методу, який повертає Promise, використовуйте mockResolvedValue або mockRejectedValue:
paymentMock.charge.mockResolvedValue(result);
paymentMock.charge.mockRejectedValue(error);mockReturnValue доречний для синхронних методів.
Якщо spy змінює реальний метод і не відновлюється, наступні тести можуть отримати вже змінену поведінку.
afterEach(() => {
jest.restoreAllMocks();
});Не варто створювати один змінний мок на весь набір тестів, якщо кожен тест змінює його поведінку. Створюйте мок у beforeEach() або повністю очищайте його стан після кожного тесту.
Мокування ізолює сервіс від зовнішніх залежностей.
Стаб повертає заздалегідь визначений результат.
Мок додатково запам’ятовує виклики та їхні аргументи.
Spy спостерігає за методом уже наявного об’єкта.
У NestJS залежність можна замінити через overrideProvider().useValue().
Для асинхронних методів використовуйте mockResolvedValue() і mockRejectedValue().
Для залежностей із @Inject() замінюйте правильний токен.
Після тестів очищайте або відновлюйте моки, щоб вони не впливали один на одного.