Практика разработки

Что тестировать, а что нет: как перестать гнаться за процентом покрытия

30.08.2026 · TitarX · 👁 4

Покрытие 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.

Что в итоге

Полезный набор тестов отвечает на вопрос «можно ли выкатывать», а не «какой у нас процент». Покрывайте логику и границы, проверяйте результат вместо внутреннего устройства и держите быстрые тесты в большинстве — тогда им начнут доверять.

Похожие статьи