Что делает модульный тест хорошим
Что это такое
Хороший модульный тест — короткий фрагмент кода, который утверждает одно ожидание об одном модуле и проваливается ровно по одной причине. Состояние он готовит себе сам, затем вызывает модуль и утверждает об исходе, который мог бы наблюдать вызывающий. Как модуль устроен внутри, тест не знает — и знать не хочет: именно поэтому модуль потом можно отрефакторить, не трогая тест.
Почему это важно
Тесты — код, который вы сопровождаете вечно, и плохо написанный тест хуже отсутствующего. Он проваливается по посторонним причинам, он пришпиливает деталь реализации, мешающую улучшению, и он приучает команду игнорировать красный. Однако разница между набором, которому доверяют, и набором, который пропускают, не в покрытии. Она в том, значит ли что-нибудь провал. Чтобы этого добиться, нужны несколько конкретных привычек, и их дёшево завести, пока набор маленький.
Ключевые идеи
Тестируйте поведение, а не реализацию
Утверждайте о том, что модуль производит, а не о том, как он это произвёл. Тест, который утверждает конкретную последовательность внутренних вызовов, сегодня проходит, а завтра ломается, стоит выделить вспомогательную функцию. Видимого для вызывающего не изменилось при этом ничего. Сигнал, за которым стоит следить, — тест, требующий правки при каждом рефакторинге: он описывает реализацию и будет сопротивляться каждому улучшению.
// хрупко: проверяет «как»
expect(repo.save).toHaveBeenCalledWith(expect.objectContaining({ id: 7 }));
// устойчиво: проверяет «что»
expect(result).toEqual({ id: 7, status: "saved" });
Одна причина для провала
Тест, который утверждает пять вещей сразу, даёт одно сообщение о провале и прячет остальные четыре проблемы. Держите подготовку, единственное действие и утверждение в поле зрения, чтобы читатель мог понять случай без прокрутки. Когда тесту нужно несколько утверждений, спросите, не является ли он на самом деле несколькими случаями, разделяющими одну подготовку.
test("отвергает срок в прошлом", () => {
expect(isValid(cardExpiredYesterday)).toBe(false);
});
Назовите ожидание
Имя теста документирует поведение модуля, если читать тесты списком. «возвращает цену со скидкой для участника» говорит вам, что делает система; «тест рассчитать цену 2» не говорит ничего при провальном запуске. Прочитайте имена в своём наборе как спецификацию, и вы найдёте и непокрытое поведение, и тесты, которые не утверждают ничего осмысленного.
calculatePrice
- не применяет скидку гостю
- применяет десять процентов участнику
- округляет половины вверх до ближайшего пенни
Подготовка, действие, проверка
Дайте каждому тесту одну и ту же трёхчастную форму и держите части визуально раздельными: подготовка строит мир, действие выполняет одну операцию под тестом, проверка утверждает исход. Если их смешать, получится тест, который четыре раза вызывает модуль и проверяет после, — такой невозможно диагностировать, потому что не понять, какой вызов привёл к провалу.
const cart = cartWith(item("book", 1000)); // подготовка
const total = cart.total(); // действие
expect(total).toBe(1000); // проверка
Предпочитайте настоящие значения умной подготовке
Тест должен читаться сверху вниз, без погони за вспомогательными функциями. Маленькие фабричные функции с разумными умолчаниями допустимы, а построитель на сто строк — нет. Когда подготовка разрастается, это обычно значит, что у модуля слишком много помощников, а это информация о продакшен-коде, а не о тесте.
Практика
Перепишите хрупкий тест
Найдите тест, который ломается всякий раз, когда вы рефакторите покрываемый им модуль. Перепишите его так, чтобы он утверждал только о наблюдаемом поведении, затем отрефакторите модуль и убедитесь, что тест по-прежнему проходит.
Готово, когда: вы изменили внутренности модуля, а тест не потребовал изменений.
Прочитайте свой набор как спецификацию
Откройте один файл тестов и прочитайте сверху вниз только имена тестов. Запишите, каким выглядит поведение модуля.
Готово, когда: вы видите хотя бы один пробел в спецификации и добавили для него тест.
Частые ошибки
- Утверждения о внутренних вызовах — тест ломается при каждом рефакторинге и блокирует улучшения; исправить: утверждайте о возвращаемом значении или о состоянии, наблюдаемом вызывающим.
- Несколько несвязанных утверждений в одном тесте — первый провал прячет остальные, и имя не описывает ни одного поведения; исправить: разделите на один тест на каждое ожидание.
- Разделяемое изменяемое состояние между тестами — порядок тестов начинает иметь значение, и провалы зависят от того, какой тест выполнился первым; исправить: стройте свежее состояние в каждом тесте.
- Тестирование фреймворка вместо кода — тест повторяет то, что библиотека уже гарантирует; исправить: тестируйте решения, которые принимает ваш код.
- Именование тестов по методу — провальный запуск не даёт подсказки, какое поведение сломалось; исправить: назовите ожидание, которое показывает тест.
Что читать дальше
- Тестовые двойники и тестовые данные (01-cs-fundamentals/10-unit-testing/02-test-doubles-and-test-data) — как изолировать модуль и не сделать тест труднее для чтения, чем код.
- Интеграционное тестирование (01-cs-fundamentals/21-integration-testing) — что модульный тест сказать не может и где его изоляция становится проблемой.
- Рефакторинг (01-cs-fundamentals/19-refactoring) — дисциплина, которую эти тесты и существуют, чтобы защищать.