Покрытие тестами в Python: за какими цифрами гнаться
Coverage показал 95%, а прод падает на первом же граничном случае. Разбираем, что реально измеряет покрытие в Python, почему высокое покрытие может врать и какие цифры стоят вашего внимания.

Команда прогоняет pytest --cov, видит 92% и ставит галочку в CI: «покрытие в норме». Через неделю прод отдаёт KeyError в функции, которая по отчёту была покрыта на 100%. Так бывает, когда цифру покрытия принимают за меру качества тестов. Она таковой не является — она измеряет, какие строки исполнились во время прогона, и ничего не говорит о том, проверили ли вы результат. Ниже — что именно считает coverage.py, где цифра врёт и на какие метрики смотреть вместо голого процента. Версии: Python 3.11+, pytest 8.x, coverage.py 7.x, плагин pytest-cov 5.x.
Что на самом деле измеряет coverage.py
По умолчанию coverage.py считает line coverage — долю строк, которые были исполнены хотя бы раз. Инструмент ставит трассировщик, помечает каждую выполненную строку и в конце делит на общее число исполнимых строк. Ключевое слово — «исполнилась», а не «проверилась».
Рассмотрите функцию:
def apply_discount(price, rate):
result = price * (1 - rate)
return round(result, 2)
Тест, который просто вызывает apply_discount(100, 0.2) без единого assert, даёт этой функции 100% покрытия. Ни одна строка не осталась неисполненной. При этом тест не проверяет ничего: замените 1 - rate на 1 + rate — покрытие останется 100%, а тест по-прежнему зелёный. Вот почему процент строк — плохой одиночный ориентир.
Покрытие показывает, какой код вы затронули тестами, а не какой код вы проверили. Это две разные величины, и разница между ними — источник ложного спокойствия.
Branch coverage: цифра честнее
Включите ветвевое покрытие флагом --cov-branch (в pytest-cov) или branch = True в конфиге. Тогда coverage.py считает не строки, а переходы по ветвлениям: для каждого if проверяется, что исполнились обе ветки — и когда условие истинно, и когда ложно.
def get_shipping(weight):
if weight > 10:
return 500
return 200
Тест только с get_shipping(15) даёт line coverage 100% (строка return 200 недостижима без else — она вне ветки), но branch coverage покажет, что переход «weight не больше 10» ни разу не сработал. Ветвевое покрытие ловит непройденные else, пустые ветки и раннее return, которые line coverage пропускает.
Где цифра врёт
- Тесты без assert. Вызов функции без проверки результата поднимает покрытие, но не ловит регрессии. Coverage.py их не отличает от полезных.
- Исключённые файлы. Строки в секции
[tool.coverage.run] omitпросто выпадают из знаменателя. Легко получить 95%, спрятав нетестируемый модуль. - Прагмы. Комментарий
# pragma: no coverисключает строку из отчёта. Полезно для защитного кода, но им злоупотребляют, чтобы натянуть цифру. - Импорт как покрытие. Модуль, который импортируется в conftest, но не вызывается ни одним тестом, засчитывает исполненными все строки верхнего уровня — определения функций и классов. Тело функций при этом не тронуто, но общий процент растёт.
За какими цифрами гнаться
Голый общий процент — худший ориентир для порога в CI, потому что он усредняет по всему проекту и легко разбавляется тривиальным кодом (геттеры, __repr__, конфиги). Вместо него смотрите на три вещи.
| Метрика | Что показывает | Как получить |
|---|---|---|
| Branch coverage | Пройдены ли обе стороны каждого ветвления | --cov-branch |
| Покрытие diff | Покрыты ли строки, изменённые в этом PR | diff-cover поверх отчёта |
| Непокрытые строки | Конкретные места без тестов | coverage report --show-missing |
Покрытие диффа важнее общего процента
Порог «весь проект не ниже 90%» блокирует мержи из-за легаси-кода, к которому автор PR не притрагивался. Полезнее требовать, чтобы изменённые строки были покрыты. Инструмент diff-cover сравнивает отчёт coverage.py с git-диффом и падает, если новый код без тестов:
# сгенерировать XML-отчёт при прогоне
pytest --cov=myapp --cov-branch --cov-report=xml
# проверить покрытие только изменённых строк относительно main
diff-cover coverage.xml --compare-branch=main --fail-under=90
Такой порог не наказывает за чужое легаси и не даёт снижать качество новыми коммитами.
Смотрите на show-missing, а не на итог
Отчёт с флагом --show-missing (или -m) печатает номера непокрытых строк по каждому файлу:
coverage report --show-missing
Строка 85% 42-47, 91 полезнее числа 85: она сразу говорит, что необработаны строки 42–47 и 91. Часто это оказывается ветка обработки ошибок — как раз то, что падает в проде.
Какую цифру ставить порогом
Единственного правильного числа нет, но есть здравые ориентиры по типу кода:
- Бизнес-логика, расчёты, обработка данных — стремитесь к высокому branch coverage, здесь ошибка дорогая.
- Тонкие обёртки, DTO, конфиги,
__repr__— покрывать построчно смысла мало, тратите время не там. - Код взаимодействия с внешними системами — покрытие юнит-тестами часто искусственное (всё замокано); реальную уверенность дают интеграционные тесты, а не процент.
Гнаться за 100% общего покрытия — почти всегда плохая инвестиция: последние проценты закрываются тестами на недостижимый защитный код и except-ветки, которые в реальности не срабатывают. Время уходит на цифру, а не на надёжность.
90% с проверкой каждой ветки бизнес-логики честнее, чем 100%, набранные вызовами функций без assert. Порог в CI должен защищать от регрессий, а не от красного бейджа.
Как настроить это в проекте
Минимальный конфиг в pyproject.toml для coverage.py 7.x:
[tool.coverage.run]
branch = true
source = ["myapp"]
omit = ["*/migrations/*", "*/tests/*"]
[tool.coverage.report]
show_missing = true
exclude_lines = [
"pragma: no cover",
"if __name__ == .__main__.:",
"raise NotImplementedError",
]
Секция exclude_lines убирает из знаменателя код, который тестировать бессмысленно, — так процент отражает реальную логику, а не разбавлен служебными строками. Прогон:
pytest --cov --cov-report=term-missing
Что проверить, чтобы цифра не врала
Мутационное тестирование — способ проверить сами тесты, а не код. Инструмент вносит в код искажённые изменения (мутации): меняет > на >=, + на -, удаляет строки. Если после мутации тесты остаются зелёными — они не проверяют это место, каким бы ни было покрытие. Для Python это делает mutmut; запуск медленный, поэтому гоняйте его точечно по критичным модулям, а не на каждом коммите. Мутационное тестирование — отдельная большая тема, здесь важно одно: именно оно, а не coverage, отвечает на вопрос «а хорошие ли у меня тесты».
Что делать дальше
- Включите
branch = true— это самое дешёвое улучшение честности цифры. - Замените порог общего процента на порог покрытия диффа через
diff-cover. - Раз в спринт смотрите
--show-missingпо критичным модулям и закрывайте непокрытые ветки обработки ошибок. - Прогоните
mutmutна самом важном модуле — узнаете, сколько ваших тестов на самом деле ничего не проверяют.
Мерьте в проде не покрытие, а частоту регрессий, которые прошли мимо тестов, — это единственная цифра, ради которой всё затевается.
Тест и проверочная: Быстрые ответы с объяснением, почему остальные варианты неверны
Спросить за 10 ₽Источники: coverage.py documentation — Branch coverage measurement, pytest-cov documentation
Частые вопросы
Почему у меня 100% покрытия, но тесты пропускают баг?
У меня старая версия coverage.py, флаг --cov-branch не работает.
Какой процент покрытия считается нормальным?
diff-cover падает на легаси-коде, хотя я его не трогал.
Стоит ли включать мутационное тестирование в CI?
Как исключить служебный код из знаменателя, не занижая честность?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.