Хороший тест решает две задачи разом: проверяет поведение и объясняет его тому, кто откроет файл через полгода. Отсюда язык структуры. describe группирует тесты одного элемента системы; test (он же it) описывает один сценарий; тело теста складывается в три фазы - Arrange, Act, Assert, то есть подготовка данных, вызов проверяемого кода и сверка результата. На сквозном примере это функция calculateDelivery: она считает стоимость доставки по заказу, и её поведение мы хотим зафиксировать так, чтобы тест читался как спецификация.
Естественная, но обманчивая привычка - писать тесты как получится. Имя test('works'), одна проверка на тест по догме 'один assert - один тест', обращение к приватным полям сервиса, чтобы 'проверить всё'. Но такой тест ничего не объясняет: имя не говорит о поведении, дробление размывает исход по пяти файлам, а привязка к внутренностям делает набор хрупким.
Начните с фаз. Arrange готовит вход - здесь собирают заказ через билдер, чтобы намерение было видно. Act - ровно один вызов проверяемого кода, без логики вокруг. Assert сверяет исход. Разделение на три части - не украшение: оно показывает читателю, что именно является входом, что действием, а что проверяемым результатом, и мгновенно выдаёт тест, где действий несколько или проверка подмешана в подготовку.
Теперь имена. describe несёт имя элемента - calculateDelivery, а test формулирует поведение на языке предметной области, а не реализации. 'Делает доставку бесплатной от 5 000 рублей' - это утверждение о правиле бизнеса, которое поймёт и не-программист. Такое имя переживает рефакторинг: пока правило действует, тест осмыслен, как бы ни менялись внутренности функции.
Один тест может иметь несколько проверок, если они описывают один исход. Это прямо противоречит догме 'один assert на тест', и правильно противоречит: если результат calculateDelivery - объект с ценой, сроком и флагом, проверить все три поля в одном тесте честнее, чем расколоть на три теста с одинаковым Arrange. Дробление контракта на пять тестов с идентичной подготовкой не добавляет строгости - только шум.
Отдельно - искушение проверять приватное. Тест, лезущий в service['_discount'], привязывается к деталям реализации, которые не являются контрактом. Он сообщает лишь, что приватный метод вернул число, но не о наблюдаемом поведении сервиса. Стоит переименовать или встроить этот метод при рефакторинге - и зелёный тест краснеет, хотя поведение системы не изменилось. Это ложный сигнал, а ложные сигналы дороже отсутствия теста.
test('works', () => {
expect(service['_discount'](x)).toBe(10)
})Сравните с проверкой через публичную границу. Тот же смысл - скидка premium-заказу - выражается через наблюдаемый результат: total(premiumOrder) равен 900. Здесь проверяется поведение, а не механизм; реализацию можно переписывать как угодно, пока публичный итог остаётся верным. Имя теста при этом остаётся утверждением о правиле, а не о приватном методе.
test('применяет 10% к premium-заказу', () => {
expect(service.total(premiumOrder)).toBe(900)
})Цена структуры невелика - чуть больше слов в имени и дисциплина трёх фаз, - а выгода в том, что набор тестов читается как живая спецификация и не ломается от рефакторинга. Есть и обратный перекос - чрезмерное дробление и погоня за DRY в ущерб читаемости: в тестах ясность важнее устранения дублирования, немного повтора ради наглядности нормально. Типичный провал плохой структуры: разработчик переименовал приватный метод, половина набора покраснела, полдня ушло на починку тестов, которые проверяли реализацию вместо поведения.
describe('calculateDelivery', () => {
test('делает доставку бесплатной от 5 000 ₽', () => {
const order = orderBuilder().withTotal(5_000).build() // Подготовка
const price = calculateDelivery(order) // Действие
expect(price).toBe(0) // Проверка
})
})