Тема 8. Провайдери, сервіси та модульна система NestJS

Тестування сервісів та провайдерів

Unit-тести для сервісів, мокування залежностей

Тестування сервісів та провайдерів

Базові концепції Jest, створення тестового модуля через 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: зрозумілі назви, незалежні тести, фокус на бізнес-логіці
Добре протестовані сервіси — це основа стабільного застосунку. Інвестуйте час у написання якісних unit-тестів, і це окупиться зменшенням кількості багів у продакшні.

На цьому завершується тема провайдерів, сервісів та модульної системи NestJS. У наступних темах ми розглянемо інші аспекти фреймворку: middleware, guards, interceptors, pipes та багато іншого!

Copyright © 2026