Головна / Статті / Тестування Enterprise Node.js: найменування AAA, охоплення та фабрики тестів

Тестування Enterprise Node.js: найменування AAA, охоплення та фабрики тестів

Дізнайтеся, як структурувати тести Node.js за схемою AAA, збалансувати охоплення модульними та інтеграційними тестами, перевірити п’ять основних результатів роботи системи та ізолювати тести за допомогою фабрик.

1401 слів

Навіть найретельніше структурована база коду з часом почне деградувати, якщо ніхто не буде її автоматично перевіряти. У міру збільшення команд та розширення набору функцій міцний набір тестів стає основним захистом від появи регресій у продакшені.

У цьому розділі ми зосередимося на практиках тестування корпоративного рівня для Node.js: як писати тести, які є зрозумілими для читання, як забезпечувати незалежність тестів один від одного, як знаходити правильний баланс між тестуванням окремих елементів та інтеграцією, а також як перевіряти п’ять основних результатів, які може дати будь-яка логіка домену.

1. Структура та найменування тестів: шаблон AAA

Розглядайте свої тести як живу документацію бізнес-правил, які вони охоплюють. Коли один із тестів зазнає невдачі в процесі CI/CD посеред ночі, той, хто знаходиться на чергуванні, має негайно зрозуміти що саме не спрацювало, за яких обставин та що мало статися замість цього.

Шаблон AAA (Arrange-Act-Assert)

Структуруйте кожен тест так, щоб він складався з трьох чітко розділених етапів:

┌─────────────────────────────────────────────────────────┐
│ 1. ARRANGE                                              │
│    Set up preconditions, create inputs, mock dependencies│
└───────────────────────────┬─────────────────────────────┘
                            │
                            ▼
┌─────────────────────────────────────────────────────────┐
│ 2. ACT                                                  │
│    Execute the single domain operation being tested      │
└───────────────────────────┬─────────────────────────────┘
                            │
                            ▼
┌─────────────────────────────────────────────────────────┐
│ 3. ASSERT                                               │
│    Verify returned results, DB state, and side-effects  │
└─────────────────────────────────────────────────────────┘

Стандарт найменування описових тестів

Уникайте нечітких позначень на кшталт it('works') або it('should test user service'). Натомість використовуйте правила найменування, які з самого початку чітко визначають мету тесту:

given [попередні умови/контекст], when [дія], then [очікуваний результат]

Поганий підхід: загадкові визначення тестів

// orders.service.test.js
describe('orders service', () => {
  it('creates order', async () => {
    // Everything mashed together, no context
    const res = await orderService.createOrder({ userId: '123', items: [] });
    expect(res).toBeDefined();
  });
});

Правильний підхід: самодокументуючі тести AAA

// components/orders/orders.service.test.js
const { orderService } = require('./orders.service');
const { ValidationError } = require('../../shared/errors/AppError');
const userFactory = require('../../../test/factories/user.factory');

describe('OrderService.createOrder', () => {
  it('given an empty cart, when creating an order, then it throws a ValidationError', async () => {
    // ARRANGE
    const user = await userFactory.build();
    const payload = { userId: user.id, items: [] };

    // ACT & ASSERT
    await expect(orderService.createOrder(payload))
      .rejects
      .toThrow(ValidationError);
  });
});

2. Тести одиниці та інтеграції: як знайти правильний баланс

Одне з постійних питань у тестуванні Node.js — як розподілити зусилля між тестами одиниці, які виконуються швидко та сильно залежать від моків, та тестами інтеграції, які виконуються повільніше, але працюють з реальними базами даних та мережевими запитами.

                      ┌──────────────────────────┐
                      │    End-to-End (E2E)      │  ~10% of tests
                      │  (Full stack / Cypress)  │  (Slowest, high confidence)
                      └────────────┬─────────────┘
                                   │
                      ┌────────────┴─────────────┐
                      │    Integration Tests     │  ~40% of tests
                      │ (Real DB / HTTP Endpoints│  (Medium speed, real wiring)
                      └────────────┬─────────────┘
                                   │
                      ┌────────────┴─────────────┐
                      │       Unit Tests         │  ~50% of tests
                      │ (Isolated Domain Logic)  │  (Fastest, instant feedback)
                      └──────────────────────────┘

Тести одиниці: чиста логіка домену

Тести одиниці призначені для перевірки бізнес-логіки, яка знаходиться всередині сервісів домену, у повній ізоляції. Усе, що виходить назовні, таке як репозиторії баз даних чи зовнішні клієнти API, слід замінити на стаби чи моки.

  • Що вони охоплюють: сервіси домену, логіку обчислень та спільні допоміжні функції.
  • Як швидко вони виконуються: зазвичай менше мілісекунди на тест.
  • Що заборонено: заборонено торкатися мережі, використовувати живу базу даних чи отримувати доступ до файлової системи.
  • Тести інтеграції: реальна підключення та інфраструктура

    Тести інтеграції перевіряють, чи ваш код правильно взаємодіє з реальними зовнішніми системами (PostgreSQL, Redis, RabbitMQ) та з веб-фреймворком, який ви використовуєте (Express або Fastify).

    • Що вони охоплюють: запити до репозиторіїв, кінцеві точки API (за допомогою supertest) та процеси обробки черг.
    • Як швидко вони виконуються: приблизно від десятків до сотень мілісекунд кожен.
    • На чому вони ґрунтуються: на базах даних у контейнерах, створених за допомогою інструментів на кшталт Testcontainers чи Docker Compose, щоб перевірялась реальна експекуція SQL чи NoSQL.

    3. 5 основних результатів роботи доменних сервісів

    Коли ви пишете тести на одиницю чи інтеграцію для методу бізнес-сервісу, ця функція домену може генерувати аж п’ять різних типів результатів. Ретельна набір тестів має охоплювати кожен результат, який стосується тестової операції:

    ┌────────────────────────────────────────────────────────────────────────┐
    │                        Domain Service Operation                        │
    └──────┬──────────────┬──────────────┬──────────────────┬────────────────┘
           │              │              │                  │
           ▼              ▼              ▼                  ▼
    ┌─────────────┐┌─────────────┐┌─────────────┐┌─────────────────────┐┌──────────────┐
    │  1. Return  ││  2. State   ││ 3. Outgoing ││ 4. Events Published ││ 5. Telemetry │
    │    Value    ││   Changes   ││  API Calls  ││  (Broker / Queue)   ││   / Logs     │
    └─────────────┘└─────────────┘└─────────────┘└─────────────────────┘└──────────────┘
    

    Приклад: тестування всіх 5 результатів

    Розгляньте, як би ви тестували повну операцію домену, таку як orderService.checkoutOrder.

    // components/orders/orders.service.test.js
    
    describe('OrderService.checkoutOrder', () => {
      it('given a valid order, when checkout occurs, then fulfills all 5 outcomes', async () => {
        // -------------------------------------------------------------
        // ARRANGE: Setup Mocks & Dependencies
        // -------------------------------------------------------------
        const mockOrderRepo = {
          findById: jest.fn().mockResolvedValue({ id: 'ord_123', status: 'PENDING', total: 100 }),
          updateStatus: jest.fn().mockResolvedValue({ id: 'ord_123', status: 'PAID', total: 100 })
        };
    
        const mockPaymentGateway = {
          charge: jest.fn().mockResolvedValue({ transactionId: 'txn_999', success: true })
        };
    
        const mockEventBus = {
          publish: jest.fn().mockResolvedValue(true)
        };
    
        const mockLogger = {
          info: jest.fn()
        };
    
        const orderService = createOrderService({
          orderRepo: mockOrderRepo,
          paymentGateway: mockPaymentGateway,
          eventBus: mockEventBus,
          logger: mockLogger
        });
    
        // -------------------------------------------------------------
        // ACT: Execute Domain Action
        // -------------------------------------------------------------
        const result = await orderService.checkoutOrder({ orderId: 'ord_123', paymentToken: 'tok_visa' });
    
        // -------------------------------------------------------------
        // ASSERT: Verify All 5 Outcomes
        // -------------------------------------------------------------
    
        // Outcome 1: Verify Return Value
        expect(result).toEqual(expect.objectContaining({
          id: 'ord_123',
          status: 'PAID'
        }));
    
        // Outcome 2: Verify Database State Change
        expect(mockOrderRepo.updateStatus).toHaveBeenCalledWith('ord_123', 'PAID');
    
        // Outcome 3: Verify Outgoing Third-Party Call
        expect(mockPaymentGateway.charge).toHaveBeenCalledWith({
          amount: 100,
          token: 'tok_visa'
        });
    
        // Outcome 4: Verify Message Queue / Event Emission
        expect(mockEventBus.publish).toHaveBeenCalledWith(
          'order.completed',
          expect.objectContaining({ orderId: 'ord_123' })
        );
    
        // Outcome 5: Verify Telemetry / Observability
        expect(mockLogger.info).toHaveBeenCalledWith(
          expect.stringContaining('Order ord_123 successfully checked out')
        );
      });
    });
    

    4. Запобігання взаємозалежності тестів за допомогою фабрик тестів

    Однією з основних причин ненадійних наборів тестів є спільний змінний стан — тести, які залежать від глобальних конфігурацій, спільних записів у базі даних чи залишків з попереднього запуску тесту.

    Проблема зі спільними конфігураціями

    // ❌ BAD: Hardcoded shared static data across test files
    const testUser = { id: '123', email: 'john@example.com' };
    
    // If Test A mutates testUser.email, Test B fails unpredictably!
    

    Рішення: динамічні фабрики тестів

    Рішення полягає у використанні фабрик тестів, які генерують свіжі, унікальні дані з кожного запуску, замість повторного використання статичних об’єктів у різних файлах.

    // test/factories/user.factory.js
    const { crypto } = require('crypto');
    
    class UserFactory {
      static build(overrides = {}) {
        const randomId = Math.random().toString(36).substring(7);
    
        return {
          id: `usr_${randomId}`,
          email: `user_${randomId}@example.com`,
          role: 'CUSTOMER',
          createdAt: new Date(),
          ...overrides // Allow callers to override specific properties
        };
      }
    
      static async create(dbClient, overrides = {}) {
        const user = this.build(overrides);
        await dbClient.query(
          'INSERT INTO users (id, email, role, created_at) VALUES ($1, $2, $3, $4)',
          [user.id, user.email, user.role, user.createdAt]
        );
        return user;
      }
    }
    
    module.exports = UserFactory;
    

    Використання у інтеграційних тестах

    Завдяки цьому підходу кожен тест отримує власну ізольовану базу даних, що дозволяє засобам виконання тестів, таким як Jest, Vitest чи вбудований Node Test Runner, запускати тести паралельно без виникнення конфліктів:

    it('given an admin user, when fetching reports, then returns data', async () => {
      // Generates fresh, isolated database record with admin role override
      const adminUser = await UserFactory.create(dbClient, { role: 'ADMIN' });
    
      const response = await request(app)
        .get('/api/v1/reports')
        .set('x-user-id', adminUser.id);
    
      expect(response.status).toBe(200);
    });
    

    Чек-лист архітектури для частини 3

    Перш ніж вважати налаштування тестів завершеними, перевірте свою кодову базу за наступним чек-листом:

    • Структура AAA: Чи чітко розділений кожен тест на секції Arrange, Act та Assert?
    • Описове найменування: Чи описують назви тестів контекст, дію та очікуваний результат у форматі given... when... then...?
  • Збалансована піраміда: Чи спираєтесь ви на швидкі тести одиниць для логіки домену, залишаючи тести інтеграції для роботи з базою даних та HTTP-межами?
  • Перевірка усіх п’яти результатів: Чи перевіряєтесь ви, за потреби, значення, що повертаються, зміни в базі даних, зовнішні виклики до сторонніх сервісів, генеровані події та поведінку логування?
  • Відсутність спільного стану: Чи генерують фабрики ізольовані записи для кожного тесту замість того, щоб тести використовували спільні глобальні конфігурації?
  • Пов’язана література