Структура теста должна объяснять поведение, а не прятать его за техническими деталями. Два инструмента группировки - describe и test - несут смысл, а не украшение. describe собирает тесты вокруг одной единицы или сценария и даёт им общий заголовок в отчёте; test описывает один конкретный исход. Когда структура повторяет поведение системы, список тестов читается как её спецификация.
Внутри теста держат три фазы - Arrange, Act, Assert - и разделяют их явно. Arrange готовит данные через builder или фабрику, Act выполняет одно проверяемое действие, Assert сравнивает результат с ожиданием. Явные фазы позволяют читать тест сверху вниз без прыжков: сразу видно, что подготовлено, что вызвано и что проверяется. Это особенно важно при падении, когда нужно быстро понять, где разошлось.
Имена тестов пишут на языке поведения. 'применяет 10% к premium-заказу' говорит и об условии, и об исходе, а 'works' или 'test1' не говорят ни о чём. Осмысленное имя дисциплинирует автора: если исход сложно назвать одной фразой, тест, скорее всего, проверяет сразу несколько вещей, и его пора разбить на отдельные.
Разница между хрупким и устойчивым тестом - в том, к чему он привязан. Хрупкий лезет в приватную деталь, например в приватное поле через service['_discount'], и ломается при любом переименовании, даже без смены поведения. Устойчивый проверяет публичный контракт - service.total(premiumOrder) - и переживает рефакторинг внутренностей, пока обещание наружу не изменилось.
Один тест может содержать несколько assertions, если все они описывают один исход: например, проверяют разные поля одного результата-контракта. Дробить такой объект на пять тестов с одинаковым Arrange не стоит - это шум без пользы. И наоборот, пять разных сценариев в одном тесте - пять причин упасть под одним именем, поэтому разные исходы разносят по отдельным тестам. Правило 'один assert на тест' - грубое упрощение: ориентир не одно утверждение, а один исход, который эти утверждения вместе описывают.
Вложенные describe удобны для контекста: 'при пустой корзине', 'для premium-пользователя'. Но у вложенности есть предел: когда контекст держится на цепочке неявных beforeEach в трёх уровнях describe, тест перестаёт читаться локально, и понять его состояние можно только собрав всю иерархию в голове. Лучше явный локальный setup, чем лабиринт неявных hooks.
Файлы тестов располагают предсказуемо: рядом с модулем (delivery.ts и delivery.test.ts) или в tests по соседству. Имя файла повторяет имя модуля, чтобы по красному тесту в CI сразу было видно, какой участок кода затронут. Единая конвенция важнее конкретного выбора: главное, чтобы место теста выводилось из места кода без раздумий.
Типичный провал - структура, отражающая устройство, а не поведение: тесты разложены по методам класса или по слоям реализации. Такая раскладка рассыпается при рефакторинге и не отвечает на вопрос, что система обещает пользователю. Отталкивайтесь от сценариев и исходов - тогда структура переживёт смену внутренней реализации и останется читаемой спецификацией.
describe('calculateDelivery', () => {
test('применяет 10% к premium-заказу', () => {
const order = orderBuilder().withTotal(9_000).premium().build() // Arrange
const total = service.total(order) // Act
expect(total).toBe(8_100) // Assert
})
})// Хрупко: привязка к приватной детали.
test('works', () => {
expect(service['_discount'](order)).toBe(0.1)
})
// Устойчиво: публичный контракт.
test('применяет 10% к premium-заказу', () => {
expect(service.total(premiumOrder)).toBe(8_100)
})