Главная / Статьи / Тестирование в 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-интерфейсами?
  • Проверка всех пяти аспектов: При необходимости проверяете ли вы значения, возвращаемые функциями, изменения в базе данных, вызовы третьих сторон, генерируемые события и поведение логгинга?
  • Отсутствие общего состояния: Генерируют ли фабрики изолированные записи для каждого теста вместо того, чтобы тесты использовали общие конфигурации?
  • Связанные материалы