Уровни тестов, ветвевое покрытие, мутационное тестирование, flaky-тесты, CI-gate
- Маршрут: Работа в эксплуатации · тема 2 из 6
- Навыки:
M6,S9- До урока:
python_testing,test_doubles.- Результат: решение о составе набора принимается по измеримым сигналам, а не по проценту покрытия.
- Основа: уровни тестов, ветвевое покрытие и его границы. Работа в эксплуатации: нестабильные тесты, обязательный контур проверки, мутационное тестирование. Углубление: проверка свойств и архитектурные тесты с их стоимостью.
- Подтверждение: отчёт по набору — ветвевое покрытие критичного модуля, один выживший мутант и план его закрытия.
Набор тестов — это не список файлов, а инженерное решение: что именно проверять, на каком уровне и какой ценой. Ниже — сигналы, по которым это решение принимают и пересматривают.
Разбор идёт на том же модуле, что и две предыдущие темы: order_total из
python_testing и checkout с внешними зависимостями из test_doubles.
/\
/ \ E2E — сценарий пользователя целиком
/────\
/ Инт \ Интеграционные — несколько модулей вместе
/────────\
/ Unit \ Юнит — функция или класс в изоляции
/────────────\
| Уровень | Что проверяет | Скорость | Что ломает при падении |
|---|---|---|---|
| Unit | функцию или класс в изоляции | миллисекунды | одну формулу или ветвь |
| Интеграционный | взаимодействие модулей, схему БД, формат запроса | секунды | границу между частями |
| E2E | сценарий пользователя через настоящий интерфейс | десятки секунд | сборку целиком |
Форма пирамиды — следствие цены, а не догма. Юнит-тесты дёшевы и точны, поэтому их много; E2E дороги, медленны и нестабильны, поэтому их держат ровно столько, сколько нужно, чтобы заметить неправильно собранную систему.
Что из этого следует практически: падение теста должно называть виновника. Если о поломке формулы скидки сообщает только E2E-сценарий оформления заказа, набор устроен неправильно — время на диагностику уйдёт на воспроизведение, а не на исправление.
Для order_total это выглядит так: границы скидки и налог — юнит-тесты;
checkout с настоящим фейком почты и подменённым курсом — интеграционный;
оформление заказа целиком через HTTP — один E2E.
Покрытие показывает, какой код выполнился во время прогона. Оно не оценивает проверки — код может быть исполнен и никак не проверен.
uv add --dev pytest-cov
uv run pytest --cov=shop --cov-report=term-missing
uv run pytest --cov=shop --cov-report=html # подробный отчёт в htmlcov/# pyproject.toml
[tool.coverage.run]
source = ["shop"]
branch = true # ветвевое покрытие, а не только строки
omit = ["*/migrations/*"]
[tool.coverage.report]
fail_under = 80
show_missing = truebranch = true — главная настройка. Построчное покрытие считает строку
пройденной, если она выполнилась хотя бы раз; ветвевое требует пройти оба исхода
условия.
def order_total(items, promo, promos):
subtotal = sum(price * qty for price, qty in items)
if promo is not None: # ← две ветви: True и False
...
return subtotal * (1 + tax)Один тест со скидкой даёт 100% построчного покрытия этой функции и всего 50%
ветвевого: случай promo is None не проверен ни разу.
Чего покрытие не показывает вовсе:
def test_useless():
order_total([(100.0, 1)], "WELCOME", {"WELCOME": 10})
# ни одного assert — покрытие выросло, проверок нетОтсюда правило: покрытие полезно как поиск непройденных мест, а не как
цель. 80% для критичной логики — разумный порог, 100% почти всегда означает, что
писали тесты ради строк. Смотрите не на число, а на список Missing в отчёте:
там видно, какие именно ветви никогда не выполнялись.
Мутационное тестирование отвечает на вопрос, на который покрытие ответить не может: заметят ли тесты, если код сломать? Инструмент вносит небольшие правки — «мутантов» — и прогоняет набор. Убитый мутант — тесты покраснели. Выживший — правка прошла незамеченной.
uv add --dev mutmut
uv run mutmut run --paths-to-mutate shop/
uv run mutmut resultsТипичные мутации: > → >=, + → -, and → or, подмена константы,
удаление вызова.
if percent > 50: # оригинал
log.warning("скидка %d%% превышает обычный предел", percent)
if percent >= 50: # мутантМутант выживет, если в наборе нет теста ровно на percent == 50. Это и есть
непроверенная граница — та, что покрытие показывало зелёной.
Мутационный прогон дорог: набор запускается по разу на мутанта. Практика — гонять его не в каждом PR, а по расписанию и только на критичных модулях (расчёты, права доступа, деньги), и разбирать выживших как список задач.
hypothesisТест с примерами проверяет случаи, которые вы придумали. Тест свойств проверяет утверждение, которое должно выполняться для любых входных данных, а библиотека сама ищет контрпример.
uv add --dev hypothesisfrom hypothesis import assume, given, settings
from hypothesis import strategies as st
price = st.floats(min_value=0, max_value=10_000, allow_nan=False, allow_infinity=False)
positions = st.lists(st.tuples(price, st.integers(min_value=1, max_value=100)), max_size=20)
@given(positions)
def test_total_is_never_negative(items):
assert order_total(items, None, {}) >= 0
@given(positions, st.integers(min_value=0, max_value=100))
def test_bigger_discount_never_increases_total(items, percent):
full = order_total(items, None, {})
discounted = order_total(items, "CODE", {"CODE": percent})
assert discounted <= full + 1e-9
@given(st.text(min_size=1))
def test_unknown_promo_always_raises(code):
assume(code not in {"WELCOME", "SALE50"})
with pytest.raises(ValueError):
order_total([(100.0, 1)], code, {"WELCOME": 10, "SALE50": 50})Найдя падение, hypothesis сокращает пример до минимального — это shrinking.
Вместо списка из семнадцати позиций со случайными числами вы получите
[(0.0, 1)] и сразу увидите суть.
Свойства, которые проще всего искать: результат не выходит за границы, обратное
преобразование возвращает исходное (loads(dumps(x)) == x), повторное
применение ничего не меняет, порядок аргументов не важен.
@settings(max_examples=500) # по умолчанию 100
@given(st.lists(st.integers()))
def test_sort_is_idempotent(values):
assert sorted(sorted(values)) == sorted(values)Проверка свойств не заменяет примеры: она хорошо ловит забытые границы, но плохо документирует конкретный бизнес-случай. Держите оба вида.
Flaky-тест проходит и падает на неизменном коде. Вред от него больше, чем от отсутствующего теста: команда перестаёт верить красному CI и начинает перезапускать сборку не глядя.
Основные причины и что с ними делать:
| Причина | Как проявляется | Устранение |
|---|---|---|
| Настоящее время | падает в полночь, в конце месяца, в другом поясе | передавать время параметром или подменять его |
| Общее состояние | падает только при полном прогоне | изоляция через фикстуры, monkeypatch |
| Порядок тестов | падает при -p no:randomly или без него | убрать зависимость от соседних тестов |
| Гонка | падает под нагрузкой и в параллельном прогоне | явная синхронизация вместо ожидания |
| Внешняя система | падает при недоступности чужого сервиса | двойник, отдельный контур для контрактных тестов |
# Проблема: результат зависит от даты запуска
def is_expired(promo: dict) -> bool:
return promo["until"] < datetime.now()
# Как надо: время — аргумент, тест задаёт его явно
def is_expired(promo: dict, now: datetime) -> bool:
return promo["until"] < nowИнструменты диагностики: pytest -p randomly перемешивает порядок и вскрывает
скрытые зависимости, pytest --lf -x повторяет только упавшее, запуск в
несколько процессов (pytest -n auto) вскрывает общее состояние.
Карантин допустим как временная мера — с владельцем, заведённой задачей и сроком
возврата. Голый повтор (--reruns 3) исправлением не является: он скрывает
дефект, который в эксплуатации проявится под нагрузкой.
В CI набор запускают на каждый pull request. Нарушенный обязательный gate блокирует слияние — иначе договорённость о качестве существует только на словах.
# фрагмент CI
- run: uv run pytest -m "not slow" --cov=shop --cov-report=xml
- run: uv run pytest -m "slow" # отдельным этапом, тоже обязательныйМедленный набор можно разделить на этапы, чтобы разработчик получал сигнал быстрее, но нельзя тихо вывести из обязательного контура. Тест, который не блокирует слияние, перестают чинить в течение месяца.
Что стоит закрепить в gate помимо самих тестов: порог fail_under для покрытия,
запрет на рост числа xfail без причины, ограничение времени прогона.
Архитектурный тест проверяет не поведение, а структуру: например, что доменный слой не импортирует слой доставки.
import ast
from pathlib import Path
def test_domain_does_not_import_web():
for path in Path("shop/domain").rglob("*.py"):
tree = ast.parse(path.read_text(encoding="utf-8"))
for node in ast.walk(tree):
if isinstance(node, (ast.Import, ast.ImportFrom)):
name = getattr(node, "module", "") or ""
assert not name.startswith("shop.web"), f"{path}: запрещённый импорт {name}"Такой тест дёшев и хорошо держит границу, которую иначе размывает каждый спешный PR. Но у него есть цена, о которой нужно говорить вслух: он запрещает не только ошибку, но и часть допустимых изменений. Переименование пакета или осознанный перенос модуля упрутся в красный тест, и решение придётся принимать заново. Поэтому такие проверки формулируют как правило с обоснованием и списком исключений, а не как «так исторически сложилось».
Ни один показатель не описывает набор в одиночку. Смотрите на них вместе:
| Сигнал | О чём говорит | Тревожно, когда |
|---|---|---|
| Escaped defects | сколько дефектов дошло до эксплуатации | растёт при зелёном CI |
| Ветвевое покрытие | какие ветви не выполнялись ни разу | критичный модуль ниже порога |
| Mutation score | замечают ли тесты порчу кода | выжившие мутанты в расчётах и правах |
| Доля нестабильных | доверие к красному результату | больше 1% прогонов перезапускают |
| Время прогона | как быстро приходит сигнал | обратная связь дольше 10 минут |
| Стоимость поддержки | сколько тестов правят при рефакторинге | правка поведения ломает десятки тестов |
Последняя строка — самая недооценённая. Набор, который приходится переписывать
при каждом безобидном рефакторинге, проверяет реализацию, а не поведение. Чаще
всего причина — избыточные проверки вызовов вместо проверок результата (см.
тему test_doubles).
Возьмите один модуль с ветвлением и снимите по нему ветвевое покрытие. Задача шага — увидеть разрыв между построчным и ветвевым числом на собственном коде.
uv run pytest --cov=shop --cov-report=term-missingName Stmts Miss Branch BrPart Cover Missing
-------------------------------------------------------------
shop/orders.py 14 0 6 2 90% 22->25, 30->exit
Столбец BrPart — частично пройденные ветвления, Missing показывает переходы,
которых не было: 22->25 означает, что условие в строке 22 ни разу не оказалось
ложным.
Закройте непройденные ветви тестами, затем запустите мутационный прогон по этому
же модулю. Найдите хотя бы одного выжившего мутанта и напишите тест, который его
убивает. Обычно это граничное значение вроде percent == 50.
Объясните, почему 100% покрытия совместимы с нулём настоящих проверок, и чем mutation score отличается от покрытия по смыслу. Затем оцените собственный набор по таблице сигналов из раздела 8 и назовите тот единственный, который стоит улучшать первым — с обоснованием, чего это будет стоить.
branch = true и найдите модуль, где ветвевое покрытие расходится с построчным больше чем на 15 процентных пунктов.assert, покажите, что покрытие выросло, и сформулируйте, почему число покрытия само по себе ничего не гарантирует.Далее: Асинхронный Python