Тема 7. Фреймворк NestJS. Контролери та маршрутизація

Тестування контролерів

Unit-тести для контролерів, мокування сервісів, Test.createTestingModule

Тестування контролерів

🎯 Мета лекції

  • Зрозуміти важливість unit-тестування контролерів для надійності застосунку
  • Опанувати створення тестового модуля через Test.createTestingModule()
  • Навчитися мокувати залежності (сервіси) за допомогою Jest
  • Практикувати написання тестів для всіх CRUD-операцій
  • Вивчити перевірку викликів методів та аргументів через Jest matchers
  • Засвоїти тестування обробки помилок (NotFoundException, ConflictException)
  • Ознайомитися з coverage-звітами для оцінки покриття коду
  • Навчитися відокремлювати тести контролера від тестів сервісу

🔑 Ключові терміни

  • Unit Test (модульний тест): тестування окремого компонента в ізоляції
  • Mock (мок, заглушка): замінник реального об'єкта для контрольованої поведінки
  • Test Suite (тестовий набір): група пов'язаних тестів у блоці describe()
  • Test Case (тестовий випадок): одиничний тест у блоці it() або test()
  • Assertion (твердження): перевірка очікуваного результату через expect()
  • Test Coverage (покриття коду): відсоток коду, що виконується під час тестів
  • Arrange-Act-Assert: структура тесту (підготовка → дія → перевірка)

Філософія тестування у NestJS

Тестування — невід'ємна частина професійної розробки, що гарантує надійність, підтримуваність та впевненість при рефакторингу коду. NestJS має вбудовану підтримку тестування через фреймворк Jest, що дозволяє писати unit-тести, integration-тести та e2e-тести.

Чому потрібне тестування контролерів

Loading diagram...
graph TB
    subgraph "Без тестів"
        NT1["Зміна коду"]
        NT2["Запуск застосунку"]
        NT3["Ручне тестування через Postman"]
        NT4["❌ Помилка у продакшн"]
        
        NT1 --> NT2 --> NT3 --> NT4
        
        style NT4 fill:#ef4444,stroke:#b91c1c,color:#ffffff
    end
    
    subgraph "З тестами"
        T1["Зміна коду"]
        T2["Запуск тестів (npm test)"]
        T3["✅ Тести проходять"]
        T4["🚀 Деплой з впевненістю"]
        
        T1 --> T2 --> T3 --> T4
        
        style T3 fill:#22c55e,stroke:#15803d,color:#ffffff
        style T4 fill:#22c55e,stroke:#15803d,color:#ffffff
    end

Переваги тестування:

  1. Швидкий зворотний зв'язок — тести виконуються за секунди замість хвилин ручного тестування
  2. Впевненість при рефакторингу — зміни коду не ламають існуючу функціональність
  3. Документація коду — тести показують, як має працювати компонент
  4. Виявлення регресій — автоматичне виявлення помилок при додаванні нового коду
  5. CI/CD інтеграція — автоматична перевірка перед деплоєм

Unit vs Integration vs E2E тести

Тип тестуЩо тестуєШвидкістьІзоляціяВикористання
UnitОкремий компонент (контролер, сервіс)⚡️⚡️⚡️ Найшвидші✅ ПовнаЛогіка методів
IntegrationВзаємодія кількох компонентів⚡️⚡️ Середні⚠️ ЧастковаІнтеграція з БД
E2EВесь застосунок через HTTP⚡️ Повільні❌ ВідсутняРеальні сценарії

У цій лекції ми зосередимося на unit-тестуванні контролерів — найшвидшому та найізольованішому типі тестів.

Структура тестового файлу

NestJS CLI автоматично створює тестовий файл *.spec.ts для кожного компонента. Розглянемо базову структуру:

// src/users/users.controller.spec.ts
import { Test, TestingModule } from '@nestjs/testing';
import { UsersController } from './users.controller';
import { UsersService } from './users.service';

describe('UsersController', () => {
  let controller: UsersController;
  let service: UsersService;

  beforeEach(async () => {
    const module: TestingModule = await Test.createTestingModule({
      controllers: [UsersController],
      providers: [UsersService],
    }).compile();

    controller = module.get<UsersController>(UsersController);
    service = module.get<UsersService>(UsersService);
  });

  it('should be defined', () => {
    expect(controller).toBeDefined();
  });
});

Анатомія тестового файлу

1. describe() — тестовий набір (test suite)

describe('UsersController', () => {
  // Група пов'язаних тестів
});

2. beforeEach() — підготовка перед кожним тестом

beforeEach(async () => {
  // Створення чистого тестового модуля
  // Виконується перед КОЖНИМ тестом
});

3. it() або test() — тестовий випадок

it('should return all users', () => {
  // Один конкретний тест
});

4. expect() — твердження (assertion)

expect(result).toEqual(expectedValue);
beforeEach vs beforeAll:
  • beforeEach() — виконується перед кожним тестом (свіжий стан)
  • beforeAll() — виконується один раз перед усіма тестами (спільний стан)
Для unit-тестів завжди використовуйте beforeEach(), щоб уникнути залежностей між тестами.

Створення тестового модуля

Test.createTestingModule() — утиліта NestJS для створення ізольованого модуля з мокованими залежностями.

Базовий приклад

import { Test, TestingModule } from '@nestjs/testing';
import { UsersController } from './users.controller';
import { UsersService } from './users.service';

describe('UsersController', () => {
  let controller: UsersController;
  let service: UsersService;

  beforeEach(async () => {
    // Створення тестового модуля
    const module: TestingModule = await Test.createTestingModule({
      controllers: [UsersController],
      providers: [UsersService],
    }).compile();

    // Витягування екземплярів з DI контейнера
    controller = module.get<UsersController>(UsersController);
    service = module.get<UsersService>(UsersService);
  });

  // Тести...
});

Що відбувається:

  1. Test.createTestingModule() створює мінімальний NestJS-модуль
  2. .compile() компілює модуль та ініціалізує залежності
  3. module.get<T>() витягує екземпляр з DI контейнера
Loading diagram...
@startuml
skinparam style plain
skinparam backgroundColor #FFFFFF

package "Test Module" {
    class "Test.createTestingModule()" as TestModule #DBEAFE
    class "UsersController" as Controller #DCFCE7
    class "UsersService (Mock)" as Service #FEF3C7
    
    TestModule --> Controller : створює
    TestModule --> Service : створює (мок)
    Controller --> Service : залежить від
}

note right of Service
  Реальний сервіс замінено
  на мок-об'єкт для ізоляції
end note

@enduml

Мокування залежностей

Для unit-тестування контролера потрібно ізолювати його від реального сервісу. Мокування дозволяє контролювати поведінку залежностей без виконання реальної бізнес-логіки.

Мокування сервісу через Jest

import { Test, TestingModule } from '@nestjs/testing';
import { UsersController } from './users.controller';
import { UsersService } from './users.service';

describe('UsersController', () => {
  let controller: UsersController;
  let service: UsersService;

  // Мок-об'єкт для UsersService
  const mockUsersService = {
    create: jest.fn(),
    findAll: jest.fn(),
    findOne: jest.fn(),
    update: jest.fn(),
    remove: jest.fn(),
  };

  beforeEach(async () => {
    const module: TestingModule = await Test.createTestingModule({
      controllers: [UsersController],
      providers: [
        {
          provide: UsersService,
          useValue: mockUsersService, // Замінюємо реальний сервіс на мок
        },
      ],
    }).compile();

    controller = module.get<UsersController>(UsersController);
    service = module.get<UsersService>(UsersService);
  });

  // Очищення моків перед кожним тестом
  afterEach(() => {
    jest.clearAllMocks();
  });

  // Тести...
});

Ключові моменти:

  1. jest.fn() — створює мок-функцію, що записує всі виклики
  2. provide: UsersService, useValue: mockUsersService — заміна реального сервісу
  3. jest.clearAllMocks() — очищення історії викликів між тестами

Налаштування поведінки моків

// Мок повертає конкретне значення
mockUsersService.findAll.mockReturnValue([
  { id: 1, name: 'Alice', email: 'alice@example.com', role: 'user' },
  { id: 2, name: 'Bob', email: 'bob@example.com', role: 'admin' },
]);

// Мок повертає Promise (для async методів)
mockUsersService.findOne.mockResolvedValue({
  id: 1,
  name: 'Alice',
  email: 'alice@example.com',
  role: 'user',
});

// Мок кидає помилку
mockUsersService.findOne.mockRejectedValue(
  new NotFoundException('User not found'),
);

Jest мок-методи:

МетодПризначення
mockReturnValue(value)Синхронне повернення значення
mockResolvedValue(value)Повернення Promise.resolve(value)
mockRejectedValue(error)Повернення Promise.reject(error)
mockImplementation(fn)Кастомна реалізація функції

Тестування GET-ендпоінтів

Розглянемо тестування методів читання даних (READ operations).

Тест: отримання всіх користувачів

describe('findAll', () => {
  it('should return an array of users', async () => {
    // Arrange: Підготовка моків
    const mockUsers = [
      {
        id: 1,
        email: 'alice@example.com',
        name: 'Alice Johnson',
        role: 'admin' as const,
        createdAt: new Date(),
        updatedAt: new Date(),
      },
      {
        id: 2,
        email: 'bob@example.com',
        name: 'Bob Smith',
        role: 'user' as const,
        createdAt: new Date(),
        updatedAt: new Date(),
      },
    ];

    mockUsersService.findAll.mockResolvedValue(mockUsers);

    // Act: Виклик методу контролера
    const result = await controller.findAll();

    // Assert: Перевірка результату
    expect(result).toEqual(mockUsers);
    expect(service.findAll).toHaveBeenCalled();
    expect(service.findAll).toHaveBeenCalledTimes(1);
  });

  it('should pass role filter to service', async () => {
    // Arrange
    const mockAdmins = [
      {
        id: 1,
        email: 'alice@example.com',
        name: 'Alice Johnson',
        role: 'admin' as const,
        createdAt: new Date(),
        updatedAt: new Date(),
      },
    ];

    mockUsersService.findAll.mockResolvedValue(mockAdmins);

    // Act
    const result = await controller.findAll('admin');

    // Assert
    expect(result).toEqual(mockAdmins);
    expect(service.findAll).toHaveBeenCalledWith('admin');
  });
});

Структура Arrange-Act-Assert:

  1. Arrange — налаштування моків та тестових даних
  2. Act — виклик методу, що тестується
  3. Assert — перевірка результату через expect()

Тест: отримання одного користувача

describe('findOne', () => {
  it('should return a user by id', async () => {
    // Arrange
    const mockUser = {
      id: 1,
      email: 'alice@example.com',
      name: 'Alice Johnson',
      role: 'admin' as const,
      createdAt: new Date(),
      updatedAt: new Date(),
    };

    mockUsersService.findOne.mockResolvedValue(mockUser);

    // Act
    const result = await controller.findOne(1);

    // Assert
    expect(result).toEqual(mockUser);
    expect(service.findOne).toHaveBeenCalledWith(1);
  });

  it('should throw NotFoundException for non-existent user', async () => {
    // Arrange
    mockUsersService.findOne.mockRejectedValue(
      new NotFoundException('User with ID 999 not found'),
    );

    // Act & Assert
    await expect(controller.findOne(999)).rejects.toThrow(NotFoundException);
    await expect(controller.findOne(999)).rejects.toThrow(
      'User with ID 999 not found',
    );
  });
});
expect().rejects.toThrow() — спеціальний matcher для тестування асинхронних помилок. Для синхронних функцій використовуйте expect(() => fn()).toThrow().

Тестування POST/PATCH/DELETE-ендпоінтів

Тестування операцій зміни даних (CREATE, UPDATE, DELETE).

Тест: створення користувача

describe('create', () => {
  it('should create a new user', async () => {
    // Arrange
    const createUserDto = {
      email: 'charlie@example.com',
      name: 'Charlie Brown',
      role: 'user' as const,
    };

    const mockCreatedUser = {
      id: 3,
      ...createUserDto,
      createdAt: new Date(),
      updatedAt: new Date(),
    };

    mockUsersService.create.mockResolvedValue(mockCreatedUser);

    // Act
    const result = await controller.create(createUserDto);

    // Assert
    expect(result).toEqual(mockCreatedUser);
    expect(service.create).toHaveBeenCalledWith(createUserDto);
    expect(service.create).toHaveBeenCalledTimes(1);
  });

  it('should throw ConflictException for duplicate email', async () => {
    // Arrange
    const createUserDto = {
      email: 'alice@example.com', // Email вже існує
      name: 'Another Alice',
      role: 'user' as const,
    };

    mockUsersService.create.mockRejectedValue(
      new ConflictException('User with email alice@example.com already exists'),
    );

    // Act & Assert
    await expect(controller.create(createUserDto)).rejects.toThrow(
      ConflictException,
    );
  });
});

Тест: оновлення користувача

describe('update', () => {
  it('should update a user', async () => {
    // Arrange
    const updateUserDto = {
      name: 'Alice Updated',
    };

    const mockUpdatedUser = {
      id: 1,
      email: 'alice@example.com',
      name: 'Alice Updated',
      role: 'admin' as const,
      createdAt: new Date('2024-01-01'),
      updatedAt: new Date(),
    };

    mockUsersService.update.mockResolvedValue(mockUpdatedUser);

    // Act
    const result = await controller.update(1, updateUserDto);

    // Assert
    expect(result).toEqual(mockUpdatedUser);
    expect(service.update).toHaveBeenCalledWith(1, updateUserDto);
  });

  it('should throw NotFoundException for non-existent user', async () => {
    // Arrange
    const updateUserDto = { name: 'Updated Name' };

    mockUsersService.update.mockRejectedValue(
      new NotFoundException('User with ID 999 not found'),
    );

    // Act & Assert
    await expect(controller.update(999, updateUserDto)).rejects.toThrow(
      NotFoundException,
    );
  });
});

Тест: видалення користувача

describe('remove', () => {
  it('should remove a user', async () => {
    // Arrange
    mockUsersService.remove.mockResolvedValue(undefined);

    // Act
    const result = await controller.remove(1);

    // Assert
    expect(result).toBeUndefined(); // DELETE повертає void
    expect(service.remove).toHaveBeenCalledWith(1);
    expect(service.remove).toHaveBeenCalledTimes(1);
  });

  it('should throw NotFoundException for non-existent user', async () => {
    // Arrange
    mockUsersService.remove.mockRejectedValue(
      new NotFoundException('User with ID 999 not found'),
    );

    // Act & Assert
    await expect(controller.remove(999)).rejects.toThrow(NotFoundException);
  });
});

Перевірка викликів методів та аргументів

Jest надає потужні matchers для перевірки взаємодії з моками.

Основні Jest matchers

// 1. Перевірка, що метод був викликаний
expect(service.findAll).toHaveBeenCalled();

// 2. Перевірка кількості викликів
expect(service.findAll).toHaveBeenCalledTimes(1);

// 3. Перевірка аргументів виклику
expect(service.create).toHaveBeenCalledWith(createUserDto);

// 4. Перевірка конкретного виклику (якщо метод викликався кілька разів)
expect(service.findOne).toHaveBeenNthCalledWith(1, 1); // 1-й виклик з аргументом 1
expect(service.findOne).toHaveBeenNthCalledWith(2, 2); // 2-й виклик з аргументом 2

// 5. Перевірка останнього виклику
expect(service.update).toHaveBeenLastCalledWith(1, updateUserDto);

// 6. Перевірка, що метод НЕ викликався
expect(service.remove).not.toHaveBeenCalled();

Таблиця Jest matchers

MatcherПризначення
toHaveBeenCalled()Метод був викликаний принаймні раз
toHaveBeenCalledTimes(n)Метод викликався рівно n разів
toHaveBeenCalledWith(...args)Метод викликався з конкретними аргументами
toHaveBeenNthCalledWith(n, ...args)n-й виклик мав конкретні аргументи
toHaveBeenLastCalledWith(...args)Останній виклик мав конкретні аргументи
not.toHaveBeenCalled()Метод НЕ викликався

Приклад складної перевірки

it('should handle multiple findOne calls', async () => {
  // Arrange
  const user1 = { id: 1, name: 'Alice', email: 'alice@example.com', role: 'admin' as const };
  const user2 = { id: 2, name: 'Bob', email: 'bob@example.com', role: 'user' as const };

  mockUsersService.findOne
    .mockResolvedValueOnce(user1) // Перший виклик повертає user1
    .mockResolvedValueOnce(user2); // Другий виклик повертає user2

  // Act
  const result1 = await controller.findOne(1);
  const result2 = await controller.findOne(2);

  // Assert
  expect(result1).toEqual(user1);
  expect(result2).toEqual(user2);
  
  expect(service.findOne).toHaveBeenCalledTimes(2);
  expect(service.findOne).toHaveBeenNthCalledWith(1, 1);
  expect(service.findOne).toHaveBeenNthCalledWith(2, 2);
});

Запуск тестів та аналіз coverage

Команди запуску тестів

npm run test

Вивід тестів у терміналі

npm run test users.controller.spec.ts
$ npm run test users.controller.spec.ts
PASS src/users/users.controller.spec.ts
UsersController
✓ should be defined (3 ms)
findAll
✓ should return an array of users (2 ms)
✓ should pass role filter to service (1 ms)
findOne
✓ should return a user by id (1 ms)
✓ should throw NotFoundException for non-existent user (2 ms)
create
✓ should create a new user (1 ms)
✓ should throw ConflictException for duplicate email (1 ms)
update
✓ should update a user (1 ms)
✓ should throw NotFoundException for non-existent user (1 ms)
remove
✓ should remove a user (1 ms)
✓ should throw NotFoundException for non-existent user (1 ms)
Test Suites: 1 passed, 1 total
Tests: 11 passed, 11 total
Time: 1.234 s

Coverage звіт

npm run test:cov
$ npm run test:cov
-------------------|---------|----------|---------|---------|-------------------
File | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s
-------------------|---------|----------|---------|---------|-------------------
All files | 98.50 | 95.00 | 100.00 | 98.33 |
users | 100.00 | 100.00 | 100.00 | 100.00 |
users.controller | 100.00 | 100.00 | 100.00 | 100.00 |
users.service | 97.50 | 90.00 | 100.00 | 97.00 | 45-47
-------------------|---------|----------|---------|---------|-------------------
Coverage report generated in: coverage/lcov-report/index.html

Метрики coverage:

  • Statements — відсоток виконаних інструкцій
  • Branches — відсоток виконаних гілок (if/else, switch)
  • Functions — відсоток викликаних функцій
  • Lines — відсоток виконаних рядків коду
Цільові показники: Для критичних компонентів (контролери, сервіси) прагніть до 80-100% coverage. Проте coverage — це не самоціль: краще мати 80% якісних тестів, ніж 100% формальних.

Best Practices тестування контролерів

Правило 1. Тестуйте контролер, а не сервіс

❌ Погано — тестування логіки сервісу:

it('should validate email format', async () => {
  const invalidDto = { email: 'not-an-email', name: 'Test', role: 'user' };
  
  // ❌ Тестуємо валідацію всередині сервісу
  await expect(controller.create(invalidDto)).rejects.toThrow(BadRequestException);
});

✅ Добре — тестування делегації:

it('should call service.create with correct arguments', async () => {
  const createDto = { email: 'test@example.com', name: 'Test', role: 'user' };
  
  mockUsersService.create.mockResolvedValue({ id: 1, ...createDto });
  
  await controller.create(createDto);
  
  // ✅ Перевіряємо, що контролер правильно делегує
  expect(service.create).toHaveBeenCalledWith(createDto);
});

Правило 2. Один тест — одна перевірка

❌ Погано — множинні перевірки в одному тесті:

it('should handle user operations', async () => {
  const user = await controller.create(createDto);
  expect(user.id).toBeDefined();
  
  const users = await controller.findAll();
  expect(users).toHaveLength(1);
  
  await controller.remove(user.id);
  expect(service.remove).toHaveBeenCalled();
});

✅ Добре — окремі тести:

it('should create a user with generated id', async () => {
  const user = await controller.create(createDto);
  expect(user.id).toBeDefined();
});

it('should return all users', async () => {
  const users = await controller.findAll();
  expect(users).toHaveLength(1);
});

it('should remove a user', async () => {
  await controller.remove(1);
  expect(service.remove).toHaveBeenCalledWith(1);
});

Правило 3. Описові назви тестів

❌ Погано:

it('test1', () => { });
it('works', () => { });
it('findAll', () => { });

✅ Добре:

it('should return an array of users', () => { });
it('should throw NotFoundException when user does not exist', () => { });
it('should pass role filter to service.findAll', () => { });

Правило 4. Очищення моків

afterEach(() => {
  jest.clearAllMocks(); // Очищення історії викликів
});

afterAll(() => {
  jest.restoreAllMocks(); // Відновлення оригінальних реалізацій
});

Правило 5. Уникайте дублювання тестових даних

// ❌ Погано — дублювання у кожному тесті
it('test 1', () => {
  const user = { id: 1, name: 'Alice', email: 'alice@example.com', role: 'admin' };
  // ...
});

it('test 2', () => {
  const user = { id: 1, name: 'Alice', email: 'alice@example.com', role: 'admin' };
  // ...
});

// ✅ Добре — фабричні функції
const createMockUser = (overrides = {}) => ({
  id: 1,
  name: 'Alice',
  email: 'alice@example.com',
  role: 'admin' as const,
  createdAt: new Date(),
  updatedAt: new Date(),
  ...overrides,
});

it('test 1', () => {
  const user = createMockUser();
  // ...
});

it('test 2', () => {
  const admin = createMockUser({ role: 'admin' });
  // ...
});

Резюме

🧪 Створення тестів

  • Test.createTestingModule() для модуля
  • Мокування сервісів через jest.fn()
  • beforeEach() для свіжого стану
  • afterEach() для очищення моків

✅ Перевірки

  • expect(result).toEqual(expected)
  • expect(service.method).toHaveBeenCalled()
  • expect().toHaveBeenCalledWith(args)
  • expect().rejects.toThrow(Exception)

📊 Coverage

  • npm run test:cov для звіту
  • Цільові показники: 80-100%
  • Statements, Branches, Functions, Lines
  • HTML-звіт у coverage/lcov-report/

🎯 Best Practices

  • Тестуйте делегацію, не логіку сервісу
  • Один тест — одна перевірка
  • Описові назви тестів
  • Фабричні функції для тестових даних
Підсумкова рекомендація: Unit-тестування контролерів забезпечує швидкий зворотний зв'язок та впевненість при рефакторингу. Мокуйте всі залежності, перевіряйте делегацію методів та обробку помилок. Прагніть до високого coverage, але пріоритизуйте якість тестів над кількістю.
Copyright © 2026