Покрытие тестами, моки, assertion quality, edge cases в тестах
Вопрос к тесту не «есть ли он», а «что именно он доказывает». Тест, который не может упасть, — это строчка в отчёте о покрытии и ничего больше.
Вы научитесь оценивать не количество тестов, а их доказательную силу: проверять, падает ли тест при поломке кода, различать сценарий и деталь реализации, замечать over-mocking, из-за которого тест проверяет мок вместо системы.
Приём, который заменяет весь остальной чек-лист: прочитав тест, мысленно испортите код и посмотрите, заметит ли тест. Если не заметит — тест бесполезен. Если заметит поломку, которая никого не волнует, — тест хрупкий и будет мешать.
# ❌ не упадёт почти ни при какой поломке
def test_get_user():
user = get_user(1)
assert user is not None
# ❌ упадёт от любой правки шаблона, ничего не проверив по сути
def test_profile_page():
html = render_profile(user)
assert html == "<div class='profile'><h1>Иван</h1></div>"
# ✅ упадёт ровно тогда, когда сломается поведение
def test_get_user_returns_display_name():
user = get_user(1)
assert user.display_name == "Иван Петров"Второй пример — не преувеличение: тесты, сравнивающие разметку целиком, живут в реальных проектах и переписываются при каждом изменении вёрстки, не находя ни одного бага.
Проверьте себя. Возьмите три теста из проекта и назовите для каждого правку кода, от которой он упадёт.
Частая ошибка. Оценивать тесты по проценту покрытия. Покрытие показывает, что строка исполнилась, а не что её поведение проверено.
# ❌ что именно ожидается — 90? True? любое непустое?
assert calculate_discount(100, 0.1)
# ✅
assert calculate_discount(100, 0.1) == 90assert result проходит для 90, True, "ошибка" и любого непустого объекта. Такое утверждение доказывает только то, что код не упал.
Про «одно утверждение на тест» — правило часто понимают буквально и делают хуже. Правильная формулировка: один сценарий на тест, а утверждений в нём столько, сколько нужно, чтобы описать результат этого сценария.
# ❌ буквальное следование правилу: три теста строят один и тот же объект
def test_user_name(): ...
def test_user_email(): ...
def test_user_is_active(): ...
# ✅ один сценарий «создание пользователя», полное описание результата
def test_new_user_is_active_with_given_credentials():
user = User("john", "john@example.com")
assert user.name == "john"
assert user.email == "john@example.com"
assert user.is_active is True
# ✅ другой сценарий — отдельный тест
def test_user_with_invalid_email_is_rejected():
with pytest.raises(ValidationError):
User("john", "not-an-email")Дробить надо по сценариям, а не по полям: имя теста должно называть проверяемое поведение, и при падении из имени должно быть понятно, что сломалось.
Проверьте себя. Прочитайте имена тестов в PR, не глядя на их тела. Понятно ли, какое поведение проверяет каждый?
Частая ошибка. Имена вида test_1, test_user, test_works. При падении в CI они не сообщают ничего.
Счастливый путь автор проверяет сам, ещё до PR. Ценность тестов — в остальном.
# автор написал только это
def test_add_to_cart():
cart = Cart()
cart.add(Product("Apple", 100))
assert cart.total == 100Чего не хватает: пустая корзина, повторное добавление того же товара, отрицательное количество, товар без цены, превышение лимита, удаление отсутствующего товара. Каждый из этих сценариев — потенциальный баг, и именно на них надо просить тесты.
def test_adding_same_product_twice_increases_quantity_not_items():
cart = Cart()
cart.add(Product("Apple", 100))
cart.add(Product("Apple", 100))
assert cart.total == 200
assert cart.item_count == 1 # тот же товар, не второй
def test_negative_quantity_is_rejected():
with pytest.raises(ValueError):
Cart().add(Product("Apple", 100), quantity=-1)Отдельно смотрите, есть ли тест на исправленный баг. Правило «багфикс = тест» существует не ради ритуала: без теста тот же баг вернётся при следующем рефакторинге, и вы не узнаете об этом.
Если в PR исправление есть, а теста нет, это major с конкретной формулировкой: «воспроизведите баг в тесте, чтобы он упал без вашей правки».
Проверьте себя. Найдите в истории проекта баг, исправленный дважды. Почти наверняка после первого раза не появилось теста.
Частая ошибка. Принимать тест, который проверяет исправление, но не воспроизводит исходный баг.
Мок нужен там, где иначе тест станет медленным, ненадёжным или зависящим от внешнего мира: сеть, время, случайность, платёжный шлюз, отправка писем.
# ❌ настоящий HTTP-запрос в юнит-тесте: медленно и падает без сети
def test_fetch_user():
assert fetch_user(123).name == "John"
# ✅ подменена только граница с внешним миром
def test_fetch_user_maps_response_to_model(respx_mock):
respx_mock.get("https://api.example.com/users/123").respond(
json={"id": 123, "full_name": "John Doe"}
)
assert fetch_user(123).name == "John Doe"Опасность в другом — over-mocking, когда моков столько, что тест проверяет собственные заглушки.
# ❌ тест ничего не доказывает: вся логика подменена
def test_process_order():
with patch("orders.validate", return_value=True), \
patch("orders.calculate_total", return_value=100), \
patch("orders.save"), \
patch("orders.notify"):
assert process_order(order) is TrueЗдесь process_order может содержать любую ошибку — тест всё равно пройдёт, потому что от настоящего кода не осталось ничего. Признак в диффе: моков больше, чем строк проверяемой логики.
Хорошая граница подмены — внешний мир и только он. Свои же функции подменять почти никогда не нужно: если без этого тест не пишется, проблема в связанности кода, и это уже архитектурное замечание.
Отдельно — время: datetime.now() внутри логики делает тест невоспроизводимым в полночь и 29 февраля. Правильно передавать время параметром или подменять единственный источник.
Проверьте себя. Найдите в проекте тест с наибольшим количеством patch и определите, какую поломку он способен поймать.
Частая ошибка. Мокать метод класса, который тестируется. Тест начинает проверять, что мок был вызван, — то есть что тест написан.
Три признака, что тесты будут мешать команде:
Зависимость от порядка. Тест проходит один и падает в наборе — значит, состояние протекает между тестами: общая БД без откатов, модульная переменная, кэш. Такой набор со временем начинают запускать «до первого падения», и он перестаёт работать вообще.
Зависимость от деталей реализации. Проверка приватных методов, точного количества вызовов, порядка обращений к моку. Любой рефакторинг без изменения поведения ломает такой тест, и команда учится игнорировать красный CI.
Плавающие тесты. Падают раз в двадцать запусков из-за таймингов, sleep вместо ожидания условия, реальных запросов. Один плавающий тест обесценивает весь набор быстрее, чем сотня отсутствующих: люди перестают верить падениям.
# ❌ гонка, замаскированная задержкой
start_worker()
time.sleep(0.5)
assert queue.processed == 1
# ✅ ожидание условия с таймаутом
start_worker()
wait_until(lambda: queue.processed == 1, timeout=5)Проверьте себя. Запустите набор тестов в случайном порядке (pytest -p no:randomly наоборот — pytest --random-order). Сколько упадёт?
Частая ошибка. Помечать плавающий тест @pytest.mark.flaky и считать вопрос закрытым. Обычно плавает не тест, а код.
В тикете: при заказе от 5000 ₽ доставка должна быть бесплатной, но клиентам всё равно начисляли 300 ₽. Автор поправил условие и приложил тест.
def calculate_shipping(order):
- if order.total > 5000:
+ if order.total >= 5000:
return 0
return 300
+def test_calculate_shipping():
+ order = Mock()
+ order.total = 6000
+ assert calculate_shipping(order) == 0
+
+def test_calculate_shipping_paid():
+ order = Mock()
+ order.total = 1000
+ assert calculate_shipping(order) != 0Что видит ревьюер. Исправление верное — это правка > на >=. А вот тесты не проверяют именно её: и 6000, и 1000 дают одинаковый результат до и после правки. Оба теста прошли бы на старом, сломанном коде. То есть в PR нет ни одного теста на исправленный баг.
Второе: assert != 0 проходит и для 300, и для 1, и для -500.
Третье: Mock() вместо объекта заказа. Здесь он не нужен — достаточно простого объекта или dataclass. С моком тест пройдёт, даже если функция начнёт обращаться к несуществующему полю.
Комментарии в PR:
tests/test_shipping.py:1· blocker Ни один из тестов не падает на старом коде — проверьте: подставьте обратно>и запустите. Баг был ровно на границе, значит нужен тест наtotal == 5000. Сейчас PR закрывает баг, но не защищает от его возвращения.
tests/test_shipping.py:8· majorassert calculate_shipping(order) != 0пройдёт для любого числа кроме нуля, включая отрицательное. Нужно точное значение:== 300.
tests/test_shipping.py:2· minorMock()здесь лишний: он примет любое обращение к любому полю. Обычный объект надёжнее — тест упадёт, если функция начнёт читать поле, которого нет.
shipping.py:2· question А что должно быть приtotal == 0или отрицательном (полный возврат)? Сейчас начислится 300 ₽. Если это невозможный случай — стоит явно запретить, иначе однажды окажется возможным.
Чем закончилось.
@pytest.mark.parametrize(
"total, expected",
[
(4999, 300), # рубль до порога
(5000, 0), # ровно порог — тот самый баг
(5001, 0),
(0, 0), # решение по вопросу выше: пустой заказ не тарифицируем
],
)
def test_shipping_cost_by_order_total(total, expected):
assert calculate_shipping(Order(total=total)) == expectedОдин параметризованный тест вместо двух, зато он падает на старом коде — что и требовалось. Значение 4999 не менее важно, чем 5000: без него правка >= на <= прошла бы незамеченной.
Общий приём из этого разбора: проверяйте тест, откатив исправление. Если тест не покраснел, он не про этот баг.
ДОКАЗАТЕЛЬНОСТЬ для каждого теста названа поломка, от которой он упадёт
БАГФИКС тест воспроизводит баг и падает без правки
ГРАНИЦЫ проверено значение ровно на границе и по обе стороны
УТВЕРЖДЕНИЯ конкретные значения, а не «не None» и «не ноль»
ИМЕНА называют проверяемое поведение
СЦЕНАРИИ один тест — один сценарий, включая ошибочные пути
МОКИ подменён только внешний мир; моков меньше, чем логики
ВРЕМЯ не берётся из `now()` внутри логики
ИЗОЛЯЦИЯ тесты не зависят от порядка и общего состоянияТренировка на фрагментах: Code Review Python, Code Review React. Полный курс по написанию тестов — Pytest с нуля и Тестирование React-приложений.
Ключевая мысль: тест, который не может покраснеть, не защищает код — он лишь создаёт впечатление защиты, а это хуже, чем её открытое отсутствие.
Далее: Рецензирование документации