Перейти к основному контенту
Tech Path Finder
КурсыИнтервьюКод-ревьюБлог
Tech Path Finder

Персонализированный путеводитель в IT. Квизы, мок-интервью, код ревью и аналитика прогресса.

@potapov_me

Платформа

  • Курсы
  • Прогресс
  • Мок-интервью
  • Код ревью
  • Живое ревью с ИИ
  • Тренажёр переговоров
  • Закладки

Контент

  • Блог
  • Главная
  • Обратная связь

Компания

  • О проекте
  • Тарифы
  • Условия использования
  • Конфиденциальность
  • Согласие на обработку данных
  • Cookie
  • Реквизиты

Аккаунт

  • Войти
  • Зарегистрироваться
  • Профиль

© 2026 Tech Path Finder. Все права защищены.

·ИП Потапов К.С.·Политика конфиденциальности·
Сделано с ❤️ в России
  1. Проверка тестов
testing_review

Проверка тестов

Покрытие тестами, моки, assertion quality, edge cases в тестах

Проверка тестов в Code Review

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

#Результат урока

Вы научитесь оценивать не количество тестов, а их доказательную силу: проверять, падает ли тест при поломке кода, различать сценарий и деталь реализации, замечать over-mocking, из-за которого тест проверяет мок вместо системы.

#1. Главный вопрос: что сломается, чтобы этот тест упал

Приём, который заменяет весь остальной чек-лист: прочитав тест, мысленно испортите код и посмотрите, заметит ли тест. Если не заметит — тест бесполезен. Если заметит поломку, которая никого не волнует, — тест хрупкий и будет мешать.

# ❌ не упадёт почти ни при какой поломке 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 == "Иван Петров"

Второй пример — не преувеличение: тесты, сравнивающие разметку целиком, живут в реальных проектах и переписываются при каждом изменении вёрстки, не находя ни одного бага.

Проверьте себя. Возьмите три теста из проекта и назовите для каждого правку кода, от которой он упадёт.

Частая ошибка. Оценивать тесты по проценту покрытия. Покрытие показывает, что строка исполнилась, а не что её поведение проверено.

#2. Утверждения должны быть конкретными

# ❌ что именно ожидается — 90? True? любое непустое? assert calculate_discount(100, 0.1) # ✅ assert calculate_discount(100, 0.1) == 90

assert 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 они не сообщают ничего.

#3. Что должно быть покрыто, кроме счастливого пути

Счастливый путь автор проверяет сам, ещё до 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 с конкретной формулировкой: «воспроизведите баг в тесте, чтобы он упал без вашей правки».

Проверьте себя. Найдите в истории проекта баг, исправленный дважды. Почти наверняка после первого раза не появилось теста.

Частая ошибка. Принимать тест, который проверяет исправление, но не воспроизводит исходный баг.

#4. Моки: чем меньше, тем лучше

Мок нужен там, где иначе тест станет медленным, ненадёжным или зависящим от внешнего мира: сеть, время, случайность, платёжный шлюз, отправка писем.

# ❌ настоящий 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 и определите, какую поломку он способен поймать.

Частая ошибка. Мокать метод класса, который тестируется. Тест начинает проверять, что мок был вызван, — то есть что тест написан.

#5. Хрупкость и связанность тестов

Три признака, что тесты будут мешать команде:

Зависимость от порядка. Тест проходит один и падает в наборе — значит, состояние протекает между тестами: общая БД без откатов, модульная переменная, кэш. Такой набор со временем начинают запускать «до первого падения», и он перестаёт работать вообще.

Зависимость от деталей реализации. Проверка приватных методов, точного количества вызовов, порядка обращений к моку. Любой рефакторинг без изменения поведения ломает такой тест, и команда учится игнорировать красный 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 и считать вопрос закрытым. Обычно плавает не тест, а код.

#6. Разбор: PR «Исправлен расчёт стоимости доставки»

В тикете: при заказе от 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 · major assert calculate_shipping(order) != 0 пройдёт для любого числа кроме нуля, включая отрицательное. Нужно точное значение: == 300.

tests/test_shipping.py:2 · minor Mock() здесь лишний: он примет любое обращение к любому полю. Обычный объект надёжнее — тест упадёт, если функция начнёт читать поле, которого нет.

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: без него правка >= на <= прошла бы незамеченной.

Общий приём из этого разбора: проверяйте тест, откатив исправление. Если тест не покраснел, он не про этот баг.

#7. Чек-лист

ДОКАЗАТЕЛЬНОСТЬ для каждого теста названа поломка, от которой он упадёт БАГФИКС тест воспроизводит баг и падает без правки ГРАНИЦЫ проверено значение ровно на границе и по обе стороны УТВЕРЖДЕНИЯ конкретные значения, а не «не None» и «не ноль» ИМЕНА называют проверяемое поведение СЦЕНАРИИ один тест — один сценарий, включая ошибочные пути МОКИ подменён только внешний мир; моков меньше, чем логики ВРЕМЯ не берётся из `now()` внутри логики ИЗОЛЯЦИЯ тесты не зависят от порядка и общего состояния

#Что дальше

Тренировка на фрагментах: Code Review Python, Code Review React. Полный курс по написанию тестов — Pytest с нуля и Тестирование React-приложений.


Ключевая мысль: тест, который не может покраснеть, не защищает код — он лишь создаёт впечатление защиты, а это хуже, чем её открытое отсутствие.

Далее: Рецензирование документации