Что тестировать, а что нет: как перестать гнаться за процентом покрытия
Покрытие 100% означает, что каждая строка была выполнена во время тестов. Оно не означает, что поведение проверено. Тест, который вызывает функцию и ничего не утверждает, даёт покрытие и нулевую пользу.
def test_process():
process(data) # 100% покрытия, ноль проверок
Поэтому вопрос «какое у нас покрытие» стоит заменить на «что именно сломается незамеченным».
Иерархия ценности
Не весь код заслуживает одинакового внимания.
Обязательно к покрытию: бизнес-логика с ветвлениями, вычисления (особенно денежные), обработка пограничных значений, всё, что связано с правами доступа, и каждая исправленная ошибка — тест на неё фиксирует, что она не вернётся.
Обычно не окупается: геттеры и сеттеры, конфигурация, тонкие обёртки без логики, чужой код (библиотека тестируется своими авторами) и вёрстка.
Пирамида, а не мороженое
Здоровое распределение выглядит так: много быстрых модульных тестов, меньше интеграционных, совсем немного сквозных.
Модульные проверяют логику в изоляции и выполняются за миллисекунды. Интеграционные проверяют стыки: запрос действительно доходит до базы и возвращает то, что нужно. Сквозные проверяют сценарий целиком — они самые достоверные и самые дорогие в поддержке.
Перевёрнутая пирамида («мороженое») — когда основная масса проверок сквозные — приводит к набору, который выполняется сорок минут, падает через раз по случайным причинам и которому в итоге перестают верить.
Тестируйте поведение, а не устройство
Хрупкий тест ломается при рефакторинге, хотя поведение не изменилось. Обычно причина в том, что тест знает слишком много о внутренностях:
# Хрупко: проверяем, как именно посчитано
def test_discount_calls_helper():
with mock.patch("shop.pricing._apply_rate") as helper:
calculate_discount(order)
helper.assert_called_once()
# Устойчиво: проверяем результат
def test_discount_for_regular_customer():
order = Order(total=1000, customer=regular_customer)
assert calculate_discount(order) == 100
Второй тест переживёт переименование внутренних функций и упадёт только тогда, когда действительно изменится расчёт — то есть тогда, когда и должен упасть.
Пограничные случаи важнее основного сценария
Основной сценарий обычно проверяют вручную ещё при разработке. Ошибки живут на границах:
- пустой список, пустая строка,
None; - ноль, отрицательные значения, единица;
- ровно на границе условия и на единицу дальше;
- очень длинные входные данные;
- Unicode, эмодзи, разные регистры;
- переход через полночь, високосный год, смена часового пояса.
Параметризация позволяет описать всё это компактно:
@pytest.mark.parametrize("total,expected", [
(0, 0),
(999, 0),
(1000, 100), # ровно на границе
(1001, 100),
])
def test_discount_thresholds(total, expected):
assert calculate_discount(Order(total=total)) == expected
Признаки, что с тестами что-то не так
- тест падает случайным образом — скорее всего, зависит от времени, порядка выполнения или общего состояния;
- после безобидного рефакторинга падает двадцать тестов;
- чтобы понять упавший тест, приходится читать его реализацию;
- набор выполняется так долго, что его запускают только в CI.
Что в итоге
Полезный набор тестов отвечает на вопрос «можно ли выкатывать», а не «какой у нас процент». Покрывайте логику и границы, проверяйте результат вместо внутреннего устройства и держите быстрые тесты в большинстве — тогда им начнут доверять.