Тестування сервісів та провайдерів
Тестування сервісів та провайдерів
Test.createTestingModule() та підходи до мокування розглянуто у лекції Тестування контролерів.У цій лекції ми зосередимося на специфіці тестування бізнес-логіки сервісів та ізоляції складних залежностей.Короткий зміст
- Створення тестового модуля для сервісу
- Test.createTestingModule(): налаштування DI для тестів
- Мокування залежностей: useValue, useClass, useFactory
- Jest mock functions: jest.fn() для підміни методів
- Тестування методів сервісу без реальних залежностей
- Spy на методи: jest.spyOn() для перевірки викликів
- Перевірка результатів: expect().toBe(), toEqual(), toHaveBeenCalled()
- Тестування асинхронних методів: async/await у тестах
- Приклад: тестування UsersService з мок-репозиторієм
- Best practices: тестувати бізнес-логіку, ізолювати залежності
Створення тестового модуля для сервісу
Для тестування сервісів NestJS використовує утиліту Test.createTestingModule(), яка створює ізольований DI-контейнер з можливістю підміни залежностей:
// users/users.service.spec.ts
import { Test, TestingModule } from '@nestjs/testing';
import { UsersService } from './users.service';
import { UsersRepository } from './users.repository';
describe('UsersService', () => {
let service: UsersService;
let repository: UsersRepository;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
UsersService,
{
provide: UsersRepository,
useValue: {
// Мок-об'єкт репозиторію
findAll: jest.fn(),
findById: jest.fn(),
create: jest.fn(),
update: jest.fn(),
delete: jest.fn()
}
}
]
}).compile();
service = module.get<UsersService>(UsersService);
repository = module.get<UsersRepository>(UsersRepository);
});
it('should be defined', () => {
expect(service).toBeDefined();
expect(repository).toBeDefined();
});
});
🎯 Мета лекції
- Опанувати створення тестових модулів для сервісів
- Навчитися мокувати складні залежності (репозиторії, зовнішні API)
- Практикувати тестування бізнес-логіки у ізоляції
- Використовувати Jest spies для перевірки взаємодій
- Застосовувати best practices для unit-тестування сервісів
🔑 Ключові терміни
- Unit Test — тест окремої одиниці коду (сервісу) в ізоляції
- Mock — підміна реальної залежності фіктивним об'єктом
- Spy — інструмент для відстеження викликів методів
- Test Double — узагальнений термін для заглушок, моків, шпигунів
- Isolation — ізоляція тестованого компонента від зовнішніх залежностей
Мокування залежностей: useValue, useClass, useFactory
Підхід 1: useValue (найпростіший)
Передача готового мок-об'єкта через useValue:
// products/products.service.spec.ts
import { Test } from '@nestjs/testing';
import { ProductsService } from './products.service';
import { ProductsRepository } from './products.repository';
import { CacheService } from '../cache/cache.service';
describe('ProductsService', () => {
let service: ProductsService;
let mockRepository: any;
let mockCache: any;
beforeEach(async () => {
// Створення мок-об'єктів
mockRepository = {
findAll: jest.fn(),
findById: jest.fn().mockResolvedValue({
id: 1,
name: 'Test Product',
price: 100
})
};
mockCache = {
get: jest.fn(),
set: jest.fn()
};
const module = await Test.createTestingModule({
providers: [
ProductsService,
{ provide: ProductsRepository, useValue: mockRepository },
{ provide: CacheService, useValue: mockCache }
]
}).compile();
service = module.get<ProductsService>(ProductsService);
});
it('should return cached product if available', async () => {
const productId = 1;
const cachedProduct = { id: 1, name: 'Cached Product', price: 150 };
// Налаштування поведінки моків
mockCache.get.mockReturnValue(cachedProduct);
const result = await service.getProduct(productId);
expect(result).toEqual(cachedProduct);
expect(mockCache.get).toHaveBeenCalledWith(`product:${productId}`);
expect(mockRepository.findById).not.toHaveBeenCalled(); // Репозиторій не викликався
});
it('should fetch from repository if cache is empty', async () => {
const productId = 1;
// Кеш порожній
mockCache.get.mockReturnValue(null);
const result = await service.getProduct(productId);
expect(mockCache.get).toHaveBeenCalledWith(`product:${productId}`);
expect(mockRepository.findById).toHaveBeenCalledWith(productId);
expect(mockCache.set).toHaveBeenCalledWith(
`product:${productId}`,
result,
3600
);
});
});
Підхід 2: useClass (мок-клас)
Створення повноцінного мок-класу для складних залежностей:
// orders/orders.service.spec.ts
import { Test } from '@nestjs/testing';
import { OrdersService } from './orders.service';
import { PaymentService } from '../payment/payment.service';
import { EmailService } from '../email/email.service';
// Мок-клас для PaymentService
class MockPaymentService {
async processPayment(amount: number) {
return { transactionId: 'mock_transaction', status: 'success' };
}
async refund(transactionId: string) {
return { refundId: 'mock_refund', status: 'refunded' };
}
}
// Мок-клас для EmailService
class MockEmailService {
async sendEmail(to: string, subject: string, body: string) {
return { messageId: 'mock_message_id' };
}
}
describe('OrdersService', () => {
let service: OrdersService;
let paymentService: MockPaymentService;
let emailService: MockEmailService;
beforeEach(async () => {
const module = await Test.createTestingModule({
providers: [
OrdersService,
{ provide: PaymentService, useClass: MockPaymentService },
{ provide: EmailService, useClass: MockEmailService }
]
}).compile();
service = module.get<OrdersService>(OrdersService);
paymentService = module.get<PaymentService>(PaymentService) as any;
emailService = module.get<EmailService>(EmailService) as any;
});
it('should create order and send confirmation email', async () => {
jest.spyOn(paymentService, 'processPayment');
jest.spyOn(emailService, 'sendEmail');
const orderData = {
userId: 1,
items: [{ productId: 1, quantity: 2 }],
totalAmount: 200
};
const result = await service.createOrder(orderData);
expect(result).toBeDefined();
expect(paymentService.processPayment).toHaveBeenCalledWith(200);
expect(emailService.sendEmail).toHaveBeenCalledWith(
expect.any(String),
'Order Confirmation',
expect.any(String)
);
});
});
Підхід 3: useFactory (динамічне створення моків)
// auth/auth.service.spec.ts
import { Test } from '@nestjs/testing';
import { AuthService } from './auth.service';
import { UsersService } from '../users/users.service';
import { JwtService } from '@nestjs/jwt';
describe('AuthService', () => {
let service: AuthService;
beforeEach(async () => {
const module = await Test.createTestingModule({
providers: [
AuthService,
{
provide: UsersService,
useFactory: () => ({
findByEmail: jest.fn(),
validatePassword: jest.fn()
})
},
{
provide: JwtService,
useFactory: () => ({
sign: jest.fn().mockReturnValue('mock_jwt_token'),
verify: jest.fn()
})
}
]
}).compile();
service = module.get<AuthService>(AuthService);
});
it('should return JWT token on successful login', async () => {
const mockUser = { id: 1, email: 'test@example.com', password: 'hashed_password' };
const usersService = module.get<UsersService>(UsersService) as any;
usersService.findByEmail.mockResolvedValue(mockUser);
usersService.validatePassword.mockResolvedValue(true);
const result = await service.login('test@example.com', 'password123');
expect(result).toEqual({ access_token: 'mock_jwt_token' });
});
});
Jest mock functions: jest.fn() для підміни методів
Jest надає потужні інструменти для створення та налаштування мок-функцій:
Базове використання jest.fn()
describe('UsersService - jest.fn() examples', () => {
it('should demonstrate mock function behavior', () => {
const mockFn = jest.fn();
mockFn('arg1', 'arg2');
mockFn('arg3');
// Перевірка викликів
expect(mockFn).toHaveBeenCalledTimes(2);
expect(mockFn).toHaveBeenCalledWith('arg1', 'arg2');
expect(mockFn).toHaveBeenLastCalledWith('arg3');
// Перевірка всіх викликів
expect(mockFn.mock.calls).toEqual([
['arg1', 'arg2'],
['arg3']
]);
});
});
Налаштування повернених значень
it('should configure mock return values', () => {
const mockRepository = {
findById: jest.fn()
};
// Одноразове повернення значення
mockRepository.findById.mockReturnValueOnce({ id: 1, name: 'User 1' });
mockRepository.findById.mockReturnValueOnce({ id: 2, name: 'User 2' });
expect(mockRepository.findById()).toEqual({ id: 1, name: 'User 1' });
expect(mockRepository.findById()).toEqual({ id: 2, name: 'User 2' });
expect(mockRepository.findById()).toBeUndefined(); // Більше немає значень
});
Асинхронні моки
it('should mock async functions', async () => {
const mockRepository = {
findAll: jest.fn().mockResolvedValue([
{ id: 1, name: 'User 1' },
{ id: 2, name: 'User 2' }
])
};
const result = await mockRepository.findAll();
expect(result).toHaveLength(2);
expect(result[0]).toHaveProperty('id', 1);
});
it('should mock rejected promises', async () => {
const mockRepository = {
findById: jest.fn().mockRejectedValue(new Error('User not found'))
};
await expect(mockRepository.findById(999)).rejects.toThrow('User not found');
});
Spy на методи: jest.spyOn() для перевірки викликів
jest.spyOn() дозволяє відстежувати виклики реальних методів без їх повної заміни:
// users/users.service.spec.ts
describe('UsersService - jest.spyOn() examples', () => {
let service: UsersService;
let repository: any;
beforeEach(async () => {
repository = {
findById: jest.fn(),
create: jest.fn(),
update: jest.fn()
};
const module = await Test.createTestingModule({
providers: [
UsersService,
{ provide: UsersRepository, useValue: repository }
]
}).compile();
service = module.get<UsersService>(UsersService);
});
it('should call repository methods with correct arguments', async () => {
const userData = { name: 'John Doe', email: 'john@example.com' };
repository.create.mockResolvedValue({ id: 1, ...userData });
// Створюємо spy на приватний метод (якщо він публічний)
const validateSpy = jest.spyOn(service as any, 'validateEmail');
await service.createUser(userData);
expect(repository.create).toHaveBeenCalledWith(userData);
expect(validateSpy).toHaveBeenCalledWith(userData.email);
validateSpy.mockRestore(); // Відновлюємо оригінальний метод
});
it('should handle validation errors', async () => {
const invalidData = { name: 'John', email: 'invalid-email' };
await expect(service.createUser(invalidData)).rejects.toThrow(
'Invalid email format'
);
expect(repository.create).not.toHaveBeenCalled();
});
});
Тестування бізнес-логіки: повний приклад
Розглянемо повний приклад тестування сервісу з комплексною бізнес-логікою:
// orders/orders.service.ts (реалізація)
import { Injectable, BadRequestException, NotFoundException } from '@nestjs/common';
import { OrdersRepository } from './orders.repository';
import { UsersService } from '../users/users.service';
import { ProductsService } from '../products/products.service';
import { PaymentService } from '../payment/payment.service';
export interface CreateOrderDto {
userId: number;
items: { productId: number; quantity: number }[];
}
@Injectable()
export class OrdersService {
constructor(
private readonly ordersRepository: OrdersRepository,
private readonly usersService: UsersService,
private readonly productsService: ProductsService,
private readonly paymentService: PaymentService
) {}
async createOrder(dto: CreateOrderDto) {
// 1. Перевірка користувача
const user = await this.usersService.findById(dto.userId);
if (!user) {
throw new NotFoundException(`User with ID ${dto.userId} not found`);
}
// 2. Перевірка наявності продуктів
const products = await Promise.all(
dto.items.map(item => this.productsService.findById(item.productId))
);
if (products.some(p => !p)) {
throw new BadRequestException('Some products not found');
}
// 3. Обчислення загальної суми
const totalAmount = dto.items.reduce((sum, item, index) => {
return sum + products[index].price * item.quantity;
}, 0);
// 4. Мінімальна сума замовлення
if (totalAmount < 100) {
throw new BadRequestException('Minimum order amount is 100 грн');
}
// 5. Обробка оплати
const payment = await this.paymentService.processPayment({
amount: totalAmount,
userId: dto.userId
});
// 6. Створення замовлення
const order = await this.ordersRepository.create({
userId: dto.userId,
items: dto.items,
totalAmount,
paymentId: payment.transactionId,
status: 'confirmed'
});
return order;
}
}
// orders/orders.service.spec.ts (тести)
import { Test } from '@nestjs/testing';
import { BadRequestException, NotFoundException } from '@nestjs/common';
import { OrdersService } from './orders.service';
import { OrdersRepository } from './orders.repository';
import { UsersService } from '../users/users.service';
import { ProductsService } from '../products/products.service';
import { PaymentService } from '../payment/payment.service';
describe('OrdersService', () => {
let service: OrdersService;
let ordersRepository: any;
let usersService: any;
let productsService: any;
let paymentService: any;
beforeEach(async () => {
ordersRepository = {
create: jest.fn()
};
usersService = {
findById: jest.fn()
};
productsService = {
findById: jest.fn()
};
paymentService = {
processPayment: jest.fn()
};
const module = await Test.createTestingModule({
providers: [
OrdersService,
{ provide: OrdersRepository, useValue: ordersRepository },
{ provide: UsersService, useValue: usersService },
{ provide: ProductsService, useValue: productsService },
{ provide: PaymentService, useValue: paymentService }
]
}).compile();
service = module.get<OrdersService>(OrdersService);
});
describe('createOrder', () => {
it('should successfully create an order', async () => {
const dto = {
userId: 1,
items: [
{ productId: 1, quantity: 2 },
{ productId: 2, quantity: 1 }
]
};
const mockUser = { id: 1, email: 'user@example.com' };
const mockProducts = [
{ id: 1, name: 'Product 1', price: 100 },
{ id: 2, name: 'Product 2', price: 150 }
];
const mockPayment = { transactionId: 'txn_123', status: 'success' };
const mockOrder = {
id: 1,
userId: 1,
items: dto.items,
totalAmount: 350,
status: 'confirmed'
};
usersService.findById.mockResolvedValue(mockUser);
productsService.findById
.mockResolvedValueOnce(mockProducts[0])
.mockResolvedValueOnce(mockProducts[1]);
paymentService.processPayment.mockResolvedValue(mockPayment);
ordersRepository.create.mockResolvedValue(mockOrder);
const result = await service.createOrder(dto);
expect(result).toEqual(mockOrder);
expect(usersService.findById).toHaveBeenCalledWith(dto.userId);
expect(productsService.findById).toHaveBeenCalledTimes(2);
expect(paymentService.processPayment).toHaveBeenCalledWith({
amount: 350,
userId: dto.userId
});
expect(ordersRepository.create).toHaveBeenCalledWith({
userId: dto.userId,
items: dto.items,
totalAmount: 350,
paymentId: 'txn_123',
status: 'confirmed'
});
});
it('should throw NotFoundException if user does not exist', async () => {
const dto = {
userId: 999,
items: [{ productId: 1, quantity: 1 }]
};
usersService.findById.mockResolvedValue(null);
await expect(service.createOrder(dto)).rejects.toThrow(NotFoundException);
await expect(service.createOrder(dto)).rejects.toThrow(
'User with ID 999 not found'
);
expect(productsService.findById).not.toHaveBeenCalled();
expect(paymentService.processPayment).not.toHaveBeenCalled();
});
it('should throw BadRequestException if products not found', async () => {
const dto = {
userId: 1,
items: [
{ productId: 1, quantity: 1 },
{ productId: 999, quantity: 1 }
]
};
usersService.findById.mockResolvedValue({ id: 1 });
productsService.findById
.mockResolvedValueOnce({ id: 1, price: 100 })
.mockResolvedValueOnce(null); // Продукт не знайдено
await expect(service.createOrder(dto)).rejects.toThrow(BadRequestException);
await expect(service.createOrder(dto)).rejects.toThrow('Some products not found');
expect(paymentService.processPayment).not.toHaveBeenCalled();
});
it('should throw BadRequestException if total amount is less than 100', async () => {
const dto = {
userId: 1,
items: [{ productId: 1, quantity: 1 }]
};
usersService.findById.mockResolvedValue({ id: 1 });
productsService.findById.mockResolvedValue({ id: 1, price: 50 });
await expect(service.createOrder(dto)).rejects.toThrow(BadRequestException);
await expect(service.createOrder(dto)).rejects.toThrow(
'Minimum order amount is 100 грн'
);
expect(paymentService.processPayment).not.toHaveBeenCalled();
});
it('should handle payment failure', async () => {
const dto = {
userId: 1,
items: [{ productId: 1, quantity: 2 }]
};
usersService.findById.mockResolvedValue({ id: 1 });
productsService.findById.mockResolvedValue({ id: 1, price: 100 });
paymentService.processPayment.mockRejectedValue(
new Error('Payment gateway unavailable')
);
await expect(service.createOrder(dto)).rejects.toThrow(
'Payment gateway unavailable'
);
expect(ordersRepository.create).not.toHaveBeenCalled();
});
});
});
Best Practices для тестування сервісів
🎯 Тестуйте бізнес-логіку
Принцип: Фокусуйтеся на тестуванні бізнес-правил та алгоритмів
Рекомендації:
- Тестуйте всі шляхи виконання (успішні та помилкові)
- Покривайте граничні випадки
- Перевіряйте валідацію та обробку помилок
🔒 Ізолюйте залежності
Принцип: Сервіс має тестуватися у повній ізоляції
Рекомендації:
- Мокуйте всі зовнішні залежності
- Не використовуйте реальні БД, API, файлову систему
- Кожен тест має бути незалежним від інших
📊 Перевіряйте взаємодії
Принцип: Переконайтеся, що сервіс правильно викликає залежності
Рекомендації:
- Використовуйте
expect().toHaveBeenCalledWith() - Перевіряйте порядок викликів при необхідності
- Переконайтеся, що певні методи НЕ викликалися у помилкових сценаріях
🧪 Називайте тести зрозуміло
Принцип: Назва тесту має описувати сценарій
Рекомендації:
it('should ...')для позитивних сценаріївit('should throw ... when ...')для помилок- Уникайте назв типу
it('test1'),it('works')
Висновки
Тестування сервісів є критичною частиною розробки надійних NestJS застосунків. Ключові принципи:
- Ізоляція: мокуйте всі залежності для тестування у чистому середовищі
- Мокування: використовуйте
jest.fn(),useValue,useClassдля підміни залежностей - Перевірка викликів:
jest.spyOn()таtoHaveBeenCalled()для верифікації взаємодій - Покриття: тестуйте всі шляхи виконання, включаючи помилкові сценарії
- Best practices: зрозумілі назви, незалежні тести, фокус на бізнес-логіці
На цьому завершується тема провайдерів, сервісів та модульної системи NestJS. У наступних темах ми розглянемо інші аспекти фреймворку: middleware, guards, interceptors, pipes та багато іншого!