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

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

@potapov_me

Платформа

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

Контент

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

Компания

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

Аккаунт

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

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

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

Стратегия тестового набора

Уровни тестов, ветвевое покрытие, мутационное тестирование, flaky-тесты, CI-gate

Стратегия тестового набора

  • Маршрут: Работа в эксплуатации · тема 2 из 6
  • Навыки: M6, S9
  • До урока: python_testing, test_doubles.
  • Результат: решение о составе набора принимается по измеримым сигналам, а не по проценту покрытия.
  • Основа: уровни тестов, ветвевое покрытие и его границы. Работа в эксплуатации: нестабильные тесты, обязательный контур проверки, мутационное тестирование. Углубление: проверка свойств и архитектурные тесты с их стоимостью.
  • Подтверждение: отчёт по набору — ветвевое покрытие критичного модуля, один выживший мутант и план его закрытия.

Набор тестов — это не список файлов, а инженерное решение: что именно проверять, на каком уровне и какой ценой. Ниже — сигналы, по которым это решение принимают и пересматривают.

Разбор идёт на том же модуле, что и две предыдущие темы: order_total из python_testing и checkout с внешними зависимостями из test_doubles.

#1. Уровни тестов

          /\
         /  \  E2E — сценарий пользователя целиком
        /────\
       /  Инт \  Интеграционные — несколько модулей вместе
      /────────\
     /   Unit   \  Юнит — функция или класс в изоляции
    /────────────\
УровеньЧто проверяетСкоростьЧто ломает при падении
Unitфункцию или класс в изоляциимиллисекундыодну формулу или ветвь
Интеграционныйвзаимодействие модулей, схему БД, формат запросасекундыграницу между частями
E2Eсценарий пользователя через настоящий интерфейсдесятки секундсборку целиком

Форма пирамиды — следствие цены, а не догма. Юнит-тесты дёшевы и точны, поэтому их много; E2E дороги, медленны и нестабильны, поэтому их держат ровно столько, сколько нужно, чтобы заметить неправильно собранную систему.

Что из этого следует практически: падение теста должно называть виновника. Если о поломке формулы скидки сообщает только E2E-сценарий оформления заказа, набор устроен неправильно — время на диагностику уйдёт на воспроизведение, а не на исправление.

Для order_total это выглядит так: границы скидки и налог — юнит-тесты; checkout с настоящим фейком почты и подменённым курсом — интеграционный; оформление заказа целиком через HTTP — один E2E.

#2. Покрытие: строки против ветвей

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

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 = true

branch = 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 в отчёте: там видно, какие именно ветви никогда не выполнялись.

#3. Мутационное тестирование: проверка самих тестов

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

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, а по расписанию и только на критичных модулях (расчёты, права доступа, деньги), и разбирать выживших как список задач.

#4. Проверка свойств: hypothesis

Тест с примерами проверяет случаи, которые вы придумали. Тест свойств проверяет утверждение, которое должно выполняться для любых входных данных, а библиотека сама ищет контрпример.

uv add --dev hypothesis
from 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)

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

#5. Нестабильные тесты

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) исправлением не является: он скрывает дефект, который в эксплуатации проявится под нагрузкой.

#6. Обязательный контур проверки

В 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 без причины, ограничение времени прогона.

#7. Архитектурные тесты и их цена

Архитектурный тест проверяет не поведение, а структуру: например, что доменный слой не импортирует слой доставки.

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

#8. Сигналы качества набора

Ни один показатель не описывает набор в одиночку. Смотрите на них вместе:

СигналО чём говоритТревожно, когда
Escaped defectsсколько дефектов дошло до эксплуатациирастёт при зелёном CI
Ветвевое покрытиекакие ветви не выполнялись ни разукритичный модуль ниже порога
Mutation scoreзамечают ли тесты порчу кодавыжившие мутанты в расчётах и правах
Доля нестабильныхдоверие к красному результатубольше 1% прогонов перезапускают
Время прогонакак быстро приходит сигналобратная связь дольше 10 минут
Стоимость поддержкисколько тестов правят при рефакторингеправка поведения ломает десятки тестов

Последняя строка — самая недооценённая. Набор, который приходится переписывать при каждом безобидном рефакторинге, проверяет реализацию, а не поведение. Чаще всего причина — избыточные проверки вызовов вместо проверок результата (см. тему test_doubles).

#Практика: от пробы к рабочему решению

#Шаг 1. Соберите минимальный пример

Возьмите один модуль с ветвлением и снимите по нему ветвевое покрытие. Задача шага — увидеть разрыв между построчным и ветвевым числом на собственном коде.

#Шаг 2. Проверьте поведение

uv run pytest --cov=shop --cov-report=term-missing
Name              Stmts   Miss Branch BrPart  Cover   Missing
-------------------------------------------------------------
shop/orders.py       14      0      6      2    90%   22->25, 30->exit

Столбец BrPart — частично пройденные ветвления, Missing показывает переходы, которых не было: 22->25 означает, что условие в строке 22 ни разу не оказалось ложным.

#Шаг 3. Доведите решение до рабочего сценария

Закройте непройденные ветви тестами, затем запустите мутационный прогон по этому же модулю. Найдите хотя бы одного выжившего мутанта и напишите тест, который его убивает. Обычно это граничное значение вроде percent == 50.

#Шаг 4. Объясните внутренний механизм

Объясните, почему 100% покрытия совместимы с нулём настоящих проверок, и чем mutation score отличается от покрытия по смыслу. Затем оцените собственный набор по таблице сигналов из раздела 8 и назовите тот единственный, который стоит улучшать первым — с обоснованием, чего это будет стоить.

#Упражнения

  1. Включите branch = true и найдите модуль, где ветвевое покрытие расходится с построчным больше чем на 15 процентных пунктов.
  2. Напишите тест без единого assert, покажите, что покрытие выросло, и сформулируйте, почему число покрытия само по себе ничего не гарантирует.
  3. Запустите мутационный прогон на модуле с арифметикой и разберите двух выживших мутантов: почему каждый выжил и какой тест его убивает.
  4. Напишите property-based тест для пары «сериализация — разбор» и убедитесь, что hypothesis сокращает найденный контрпример.
  5. Найдите в наборе тест, зависящий от текущего времени, и переделайте его так, чтобы время передавалось явно.
  6. Запустите набор с перемешанным порядком тестов и разберите каждое новое падение как скрытую зависимость между тестами.
  7. Составьте таблицу сигналов для своего проекта, проставьте текущие значения и выберите один показатель для улучшения в ближайшем месяце.

Далее: Асинхронный Python