Прежде чем писать тест, стоит ответить на вопрос, что именно он проверяет. Профессиональный ответ - наблюдаемое поведение: то, что видит вызывающий код через публичную границу. Тест на приватную деталь (внутреннее поле, порядок частных вызовов) ломается при любом рефакторинге, даже если поведение не менялось, и превращается из страховки в помеху. Проверяйте контракт, а не устройство.
Единица теста - не обязательно одна функция. Это кусок кода с ясной границей вход-выход: чистая функция, метод сервиса, обработчик запроса. Чем чётче граница, тем проще тест: подаёте вход, читаете выход, сравниваете с ожиданием. Хороший кандидат для первого теста - чистая функция вроде calculateDelivery: у неё нет скрытого состояния, и результат зависит только от аргументов.
Структура теста держится на трёх фазах - Arrange, Act, Assert. Arrange готовит данные и окружение, Act выполняет одно проверяемое действие, Assert сравнивает результат с ожиданием. Разделять фазы стоит явно: так тест читается сверху вниз без прыжков, и сразу видно, что готовилось, что вызывалось и что проверяется. Смешанные фазы - первый признак теста, который трудно понять при падении.
Один тест описывает одно поведение. Несколько assertions допустимы, если все они об одном исходе - например, проверяют разные поля одного результата. Но пять разных сценариев в одном test - это пять причин упасть под одним именем: непонятно, что именно сломалось. Разные исходы разносят по отдельным тестам с говорящими именами.
Кейсы выбирают осознанно, а не по одному счастливому пути. Полезно взять четыре класса: обычный (normal), граничный (boundary - ровно 5000 рублей, ноль, пустой список), недопустимый (invalid - отрицательная сумма) и отказ (failure - функция обязана бросить). Именно границы ловят большинство ошибок: наивный код часто верен в середине диапазона и неверен ровно на краю. Полезно сформулировать ожидание для каждого класса до написания кода - тогда тесты ведут реализацию, а не подгоняются под уже готовое.
Когда один и тот же сценарий проверяется на разных входах, помогает параметризация через test.each. Таблица входов и ожиданий убирает копипаст и делает граничные случаи явными: их видно списком, а не прячет в цикле. Каждая строка таблицы - отдельный тест со своим именем, поэтому падение указывает на конкретный случай, а не на весь набор.
Имя теста стоит писать как предложение об условии и наблюдаемом исходе: 'делает доставку бесплатной от 5000 рублей'. Тогда список тестов читается как спецификация поведения модуля, а не как набор технических меток. Это дисциплинирует и автора: если исход сложно назвать одной фразой, скорее всего тест проверяет сразу несколько вещей и его пора разбить.
Наконец, не каждый код заслуживает теста. Тривиальный геттер, тонкая обёртка без логики или поведение чужой библиотеки тестировать смысла мало: вы проверяете не свой код и тратите время на хрупкие тесты. Ценность теста - в пойманном регрессе, а не в числе строк или проценте покрытия. Пишите тест там, где ошибка вероятна и дорога, и опускайте там, где проверять нечего.
describe('calculateDelivery', () => {
test('делает доставку бесплатной от 5000 рублей', () => {
const order = orderBuilder().withTotal(5_000).build() // Arrange
const price = calculateDelivery(order) // Act
expect(price).toBe(0) // Assert
})
})test.each([
{ total: 1_000, expected: 300 }, // normal
{ total: 4_999, expected: 300 }, // boundary
{ total: 5_000, expected: 0 }, // boundary
])('стоит $expected при сумме $total', ({ total, expected }) => {
expect(calculateDelivery(orderBuilder().withTotal(total).build())).toBe(expected)
})