Тестовый код — это полноценная часть инженерной кодовой базы, к которой предъявляются те же строгие требования к поддерживаемости, читаемости и архитектуре, что и к продакшен-коду. Главная причина деградации тестовых наборов в крупных проектах — превращение тестов в неструктурированные спагетти-скрипты, где подготовка данных, множественные мутации состояния и хаотичные проверки перемешаны в едином блоке. При падении такого теста инженер тратит часы на выяснение того, какое именно действие вызвало сбой.
Для стандартизации структуры автоматизированных тестов в программной инженерии применяется классический архитектурный паттерн Arrange-Act-Assert (AAA).
Паттерн AAA накладывает жесткие правила на поток управления теста:
if/else, циклов for/while и блоков switch внутри теста. Если в тесте появляется ветвление, это признак того, что один тест пытается проверить несколько различных сценариев одновременно.В BDD-подходе (Behavior-Driven Development) эквивалентом AAA выступает триада Given-When-Then (Дано — Когда — Тогда), используемая в огуречных синтаксисах (Cucumber) и спецификациях бизнес-требований.
Внутренняя структура чистого модульного теста подчиняется трехфазной модели:
┌────────────────────────────────────────────────────────┐
│ 1. ARRANGE (Подготовка) │
│ - Инициализация входных параметров │
│ - Создание чистых экземпляров классов и структур │
│ - Настройка изолированных моков и окружения │
└──────────────────────────┬─────────────────────────────┘
│
▼
┌────────────────────────────────────────────────────────┐
│ 2. ACT (Действие) │
│ - Вызов ровно одной целевой тестируемой функции │
│ - Фиксация возвращенного значения или сайд-эффекта │
└──────────────────────────┬─────────────────────────────┘
│
▼
┌────────────────────────────────────────────────────────┐
│ 3. ASSERT (Проверка) │
│ - Проверка инвариантов через утверждения expect() │
│ - Сравнение ожидаемого значения с фактическим │
└────────────────────────────────────────────────────────┘Иерархическая организация спецификаций строится с помощью функций describe (группировка по модулям, сценариям или состояниям) и it / test (конкретный неделимый тест-кейс).
Определение имени теста в блоке it(...) в формате: 'должен [ожидаемое поведение], если [условие/контекст]'.
Инициализация минимально достаточного набора входных аргументов, фабрик тестовых данных и чистых структур в памяти.
Вызов тестируемой функции или метода с сохранением результата в константу result или промис.
Проверка возвращенного значения с помощью точных матчеров (toBe для примитивов, toStrictEqual для объектов, toThrow для ошибок).
Почему в фазе Act рекомендуется выполнять ровно одно действие (один вызов тестируемой функции/метода)?
ValidationError, NotFoundError), а не неконтролируемый TypeError.В библиотеке утверждений Vitest функция expect(actual) возвращает объект-обертку со множеством специализированных матчеров (matcher).
Понимание фундаментальных различий между матчерами равенства критически важно для предотвращения скрытых багов:
[!NOTE] В Vitest также существует матчер
toEqual(). В отличие отtoStrictEqual(),toEqual()игнорирует свойства со значениемundefined(считает{ a: undefined }равным{}) и игнорирует разницу между прототипами классов. В современном TypeScript-коде рекомендуется отдавать предпочтениеtoBe()для примитивов иtoStrictEqual()для объектов и массивов.
Создадим класс доменной корзины заказов, инкапсулирующий бизнес-логику добавления товаров и валидации:
export interface CartProduct {
readonly id: string;
readonly name: string;
readonly priceInCents: number;
}
export class CartItemValidationError extends Error {
constructor(message: string) {
super(message);
this.name = 'CartItemValidationError';
}
}
export class ShoppingCart {
private readonly items = new Map<string, { product: CartProduct; quantity: number }>();
public addItem(product: CartProduct, quantity: number): void {
if (quantity <= 0 || !Number.isInteger(quantity)) {
throw new CartItemValidationError('Количество товара должно быть целым положительным числом');
}
if (product.priceInCents < 0) {
throw new CartItemValidationError('Цена товара не может быть отрицательной');
}
const existing = this.items.get(product.id);
if (existing) {
existing.quantity += quantity;
} else {
this.items.set(product.id, { product, quantity });
}
}
public getItems(): ReadonlyArray<{ product: CartProduct; quantity: number }> {
return Array.from(this.items.values());
}
public getTotalCostInCents(): number {
let total = 0;
for (const item of this.items.values()) {
total += item.product.priceInCents * item.quantity;
}
return total;
}
public clear(): void {
this.items.clear();
}
}import { describe, it, expect, beforeEach } from 'vitest';
import { ShoppingCart, CartItemValidationError, type CartProduct } from './Cart';
describe('ShoppingCart (Доменная модель)', () => {
let cart: ShoppingCart;
// Изоляция: создаем чистый экземпляр перед каждым тестом
beforeEach(() => {
cart = new ShoppingCart();
});
describe('addItem', () => {
it('добавляет новый товар в пустую корзину', () => {
// 1. Arrange
const product: CartProduct = {
id: 'prod-100',
name: 'Клавиатура Mechanical V4',
priceInCents: 12000,
};
// 2. Act
cart.addItem(product, 1);
// 3. Assert
const items = cart.getItems();
expect(items).toHaveLength(1);
expect(items[0]).toStrictEqual({
product,
quantity: 1,
});
expect(cart.getTotalCostInCents()).toBe(12000);
});
it('увеличивает количество товара, если он уже присутствует в корзине', () => {
// 1. Arrange
const product: CartProduct = {
id: 'prod-100',
name: 'Клавиатура Mechanical V4',
priceInCents: 12000,
};
cart.addItem(product, 2);
// 2. Act
cart.addItem(product, 3);
// 3. Assert
const items = cart.getItems();
expect(items).toHaveLength(1);
expect(items[0]?.quantity).toBe(5);
expect(cart.getTotalCostInCents()).toBe(60000);
});
it('выбрасывает CartItemValidationError при попытке добавить товар с неположительным количеством', () => {
// 1. Arrange
const product: CartProduct = {
id: 'prod-200',
name: 'Коврик для мыши',
priceInCents: 1500,
};
// 2. Act & 3. Assert
// ВАЖНО: вызов с ошибкой оборачивается в анонимную стрелочную функцию
expect(() => cart.addItem(product, 0)).toThrow(CartItemValidationError);
expect(() => cart.addItem(product, -5)).toThrow(
'Количество товара должно быть целым положительным числом'
);
});
});
describe('clear', () => {
it('полностью очищает все элементы корзины и сбрасывает итоговую стоимость в 0', () => {
// 1. Arrange
cart.addItem({ id: 'p1', name: 'Item 1', priceInCents: 100 }, 2);
cart.addItem({ id: 'p2', name: 'Item 2', priceInCents: 200 }, 1);
// 2. Act
cart.clear();
// 3. Assert
expect(cart.getItems()).toHaveLength(0);
expect(cart.getTotalCostInCents()).toBe(0);
});
});
});Однократная инициализация тяжелых ресурсов для всего блока describe (поднятие Docker-контейнера, генерация сертификатов).
Выполняется ровно один раз перед первым тестом блока.
Создание свежих экземпляров тестируемых сущностей, сброс моков (vi.clearAllMocks()) и очистка хранилищ.
Гарантирует полную изоляцию контекста каждого отдельного теста.
Исполнение фаз Arrange, Act и Assert в изолированном фрейме стека.
Сбой в утверждении expect прерывает данный тест, но не останавливает весь набор.
Очистка таймеров, закрытие открытых файловых дескрипторов и сетевых сокетов для предотвращения утечек памяти.
Предотвращает зависание процессов Node.js после прогона тестов.
Почему при проверке генерации исключения через expect(...).toThrow(...) проверяемый вызов необходимо оборачивать в анонимную функцию (() => fn()), а не вызывать функцию напрямую?
Самая частая ошибка новичков при проверке исключений:
// ❌ Ошибка: cart.addItem выбросит ошибку до того, как expect успеет ее перехватить!
// Тест упадет с необработанным исключением, даже если тип ошибки верный.
expect(cart.addItem(invalidProduct, -1)).toThrow();
// ✅ Правильное решение: передать стрелочную функцию-колбэк
expect(() => cart.addItem(invalidProduct, -1)).toThrow(CartItemValidationError);Объявление изменяемой переменной на уровне файла без сброса в beforeEach:
// ❌ Опасно: общий экземпляр для всех тестов
const sharedCart = new ShoppingCart();
it('тест 1: добавляет товар', () => {
sharedCart.addItem(product, 1);
expect(sharedCart.getItems()).toHaveLength(1);
});
it('тест 2: проверяет пустую корзину', () => {
// Тест упадет, если запустится после теста 1, так как корзина уже не пуста!
expect(sharedCart.getItems()).toHaveLength(0);
});
// ✅ Правильное решение: создавать новый экземпляр в beforeEach() или внутри каждого тестаМатчер toBe сравнивает операнды по ссылке через Object.is. Даже если два объекта имеют полностью идентичные свойства, в памяти это разные ссылки:
// ❌ Ошибка: разные ссылки в куче (Heap), тест всегда будет падать
const actualUser = { id: 1, name: 'Alice' };
expect(actualUser).toBe({ id: 1, name: 'Alice' });
// ✅ Правильное решение: использовать toStrictEqual для сравнения структур данных
expect(actualUser).toStrictEqual({ id: 1, name: 'Alice' });Появление if, switch или циклов в теле теста делает сам тест подверженным багам и трудным для чтения:
// ❌ Антипаттерн: тест тестирует сам себя через условные переходы
it('проверяет скидки', () => {
for (let i = 0; i < 5; i++) {
if (i > 2) {
expect(getDiscount(i)).toBe(10);
} else {
expect(getDiscount(i)).toBe(0);
}
}
});
// ✅ Правильное решение: параметризованные тесты через test.each (рассматриваются в следующем уроке)