Тестирование в Enterprise Node.js: номенклатура AAA, покрытие и фабрики тестов
Узнайте, как структурировать тесты Node.js с использованием шаблона AAA, находить баланс между тестированием модулей и интеграции, проверять пять ключевых результатов в рамках доменной логики и изолировать тесты с помощью фабрик.
Даже самая тщательно спроектированная база кода со временем будет деградировать, если никто не будет автоматически её проверять. По мере роста команд и расширения набора функций надежный набор тестов становится основной линией защиты от появления регрессий в продакшене.
В этой части рассматриваются практики тестирования корпоративного уровня для 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...?
Связанные материалы
- Замена Jest на встроенный тестовый движок Node в Node 24 — пример реальной миграции показывает, как встроенный тестовый движок Node 24 и поддержка TypeScript сокращают время CI, устраняя четыре зависимости.
- Проектирование API на Node.js с использованием слоев: от громоздких контроллеров к чистой архитектуре — Узнайте, как переписать API на Node.js с использованием слоев контроллеров, сервисов и доступа к данным для устранения запутанной бизнес-логики, неоднородных ошибок и проблем с масштабированием.