Тесты, которые мешают: хрупкие и медленные
В pytest тесты чаще всего тормозят из-за базы, сети, sleep и слишком широких фикстур, а ломаются из-за случайных данных и привязки к внутреннему устройству кода. Разбираем, что менять первым и как измерять эффект.

Тестовый набор на pytest может тратить минуты на каждый запуск и падать после безобидного рефакторинга. Обычно причина не в самом фреймворке: тесты ходят в сеть, поднимают общую базу, ждут через sleep и проверяют внутренние вызовы вместо результата. Ниже — практический разбор для pytest 8.x и Python 3.12: как найти самые дорогие тесты, сделать проверки устойчивее и не превратить ускорение в отказ от интеграционных сценариев.
Сначала измерьте, а не переписывайте всё
Ощущение «тесты стали медленными» редко подсказывает виновника. Один тест может выполняться 400 миллисекунд, но запускаться 300 раз из-за параметризации. Другой длится две секунды, зато запускается один раз. В pytest начните с распределения времени:
pytest --durations=20
Команда покажет 20 самых долгих элементов запуска. Это не полный профиль приложения: время фикстур и подготовки иногда выглядит отдельно от самого теста. Для следующего шага полезно проверить, какие фикстуры создаются и с каким сроком жизни:
pytest --setup-show tests/test_orders.py
Замеры записывайте в одинаковых условиях: одна и та же машина, одинаковый набор тестов, без параллельного запуска других тяжёлых процессов. Не приписывайте оптимизации выигрыш, пока не сравнили время до и после на нескольких запусках.
Быстрый тест проверяет результат, а не терпение команды. Хрупкий тест проверяет случайность окружения, а не поведение программы.
Что считать проблемой
- Медленный тест — тот, чьё время заметно влияет на локальный цикл или CI. Универсального порога нет: для одного проекта это 100 миллисекунд, для другого — 10 секунд.
- Хрупкий тест — тот, который часто падает без изменения проверяемого поведения: из-за времени, порядка запуска, случайного идентификатора или формата внутреннего вызова.
- Тест с побочным эффектом — проверка, которая меняет базу, файловую систему, очередь или внешний сервис и не изолирует это изменение.
* Фикстура — функция pytest, которая подготавливает данные или ресурс для теста и может вернуть его через yield или обычный return. Параметр scope задаёт, как часто pytest создаёт фикстуру: например, один раз на функцию, модуль или всю сессию.
Три подхода к дорогим проверкам
Не всякий медленный тест нужно превращать в быстрый unit-тест. Цена решения — потеря части реальной проверки. Выбирайте границу по тому, что именно хотите доказать.
1. Unit-тест с подменой внешней зависимости
Если функция должна выбрать тариф или обработать ответ платёжного шлюза, сеть не нужна. Передайте зависимость аргументом и подмените её объектом из стандартного unittest.mock. Такой тест быстро проверяет ветвление и не зависит от доступности сервиса.
from unittest.mock import Mock
def calculate_discount(order, pricing_client):
if order['total'] >= 10_000:
return pricing_client.get_discount('vip')
return 0
def test_large_order_uses_vip_discount():
pricing_client = Mock()
pricing_client.get_discount.return_value = 15
order = {'total': 12_000}
discount = calculate_discount(order, pricing_client)
assert discount == 15
pricing_client.get_discount.assert_called_once_with('vip')
Проверяйте через mock только действительно важный контракт: был ли вызван внешний клиент с нужными аргументами. Если тест утверждает каждый внутренний вызов, порядок и количество промежуточных методов, рефакторинг сломает его даже при сохранении результата.
2. Параметризация вместо копий
Несколько почти одинаковых тестов усложняют поддержку и раздувают отчёт. В pytest варианты данных можно описать через pytest.mark.parametrize. Это не ускоряет одну проверку, но убирает копирование и делает полный набор случаев явным.
import pytest
@pytest.mark.parametrize(
('total', 'expected_discount'),
[
(9_999, 0),
(10_000, 15),
(25_000, 15),
],
)
def test_discount_boundary(total, expected_discount):
order = {'total': total}
pricing_client = type(
'PricingClient',
(),
{'get_discount': lambda self, name: 15},
)()
result = calculate_discount(order, pricing_client)
assert result == expected_discount
Здесь объект клиента намеренно прост: тест проверяет границу скидки, а не работу mock. Если объект создаётся во многих тестах, вынесите его в отдельную фабрику, но не делайте одну огромную фикстуру на весь проект.
3. Интеграционный тест, но не на каждый запуск
Проверку SQL-запроса, миграции или реального HTTP-контракта подмена не заменит. Однако такой сценарий не обязан запускаться вместе с каждой локальной проверкой. Разделите тесты маркером и явно договоритесь, когда запускается дорогой слой.
[pytest]
markers =
integration: tests that require external services or a real database
После этого интеграционный тест можно пометить так:
import pytest
@pytest.mark.integration
def test_order_is_saved_in_database(database):
order = database.create_order(total=12_000)
saved_order = database.get_order(order.id)
assert saved_order.total == 12_000
Проверяйте точную команду запуска в документации вашей версии pytest и в конфигурации CI. Важно не спрятать интеграционные тесты навсегда: они должны выполняться на отдельном этапе сборки, где доступны база и внешние зависимости.
Сравнение решений
| Подход | Что проверяет | Цена | Когда применять |
|---|---|---|---|
| Unit с mock | Логику и обработку ответов | Можно не заметить ошибку интеграции | Каждый коммит и локальный запуск |
| Параметризация | Границы и наборы входных данных | Большой набор вариантов всё равно выполняется полностью | Когда тесты отличаются только данными |
| Интеграционный тест | Связь с БД, HTTP-сервисом или очередью | Время, инфраструктура и нестабильность окружения | Отдельный этап CI и перед релизом |
Почему тесты становятся хрупкими
Случайность, время и порядок запуска
Тест, который использует текущую дату, случайный идентификатор или общий словарь из другой проверки, зависит от окружения. Такие зависимости нужно задавать явно. Для времени передавайте значение в функцию, а для случайности — генератор или заранее подготовленные данные. Не лечите нестабильность повторным запуском: retry может скрыть настоящий дефект.
Особенно опасны тесты, которые проходят по одному, но падают при полном запуске. Причина часто в изменённом глобальном состоянии, общей временной директории или данных, оставшихся в базе. Включите случайный порядок только как диагностический инструмент, а исправляйте саму зависимость, а не порядок.
Слишком широкие фикстуры
Фикстура с scope='session' экономит время, если создаёт неизменяемый справочник или тяжёлый клиент. Для изменяемой сущности она опасна: один тест меняет объект, следующий получает уже испорченное состояние. Фикстуры с базой, пользователем или заказом обычно безопаснее делать на уровень функции, если проект не предоставляет надёжную очистку транзакций.
Отдельный источник задержек — autouse=True. Такая фикстура запускается без явного упоминания в тесте, поэтому одна тяжёлая подготовка незаметно добавляется к сотням проверок. Оставляйте автоматическими только дешёвые и действительно обязательные действия.
Проверка внутренностей вместо контракта
Тест вида «метод вызван три раза с таким порядком аргументов» полезен, когда количество вызовов — часть требования. Во всех остальных случаях он фиксирует текущую реализацию. Предпочтительнее проверить публичный результат: состояние заказа, возвращённую ошибку или отправленное наружу сообщение.
Что ломается чаще всего
assert 200после HTTP-запроса без проверки тела. Такой тест подтверждает, что сервер ответил, но не что он вернул правильные данные. Для интеграции проверяйте минимальный контракт: статус, обязательные поля и важные значения.time.sleep(1)в ожидании фоновой операции. На быстрой машине это лишняя секунда, на медленной — иногда недостаточно. В unit-тесте уберите реальное ожидание и передайте контролируемую зависимость. В интеграционном тесте используйте механизм ожидания, который уже поддерживает ваш клиент или тестовый стенд, с ограниченным тайм-аутом.- Один общий объект для всех тестов. Прогон по отдельности проходит, полный набор — нет. Верните из фикстуры новый объект на каждый тест или добавьте гарантированную очистку в финализацию.
- Тест падает на случайном UUID или времени. Сравнивайте структуру результата, а не случайное значение целиком, либо передавайте генератор и часы через зависимости.
Когда ускорение заходит слишком далеко
Полностью заменить реальные сервисы mock-объектами нельзя. Можно получить зелёный набор при неверном SQL, несовместимом JSON или неправильной конфигурации очереди. Минимальный баланс выглядит так: быстрые unit-тесты покрывают ветвления, несколько интеграционных тестов проверяют реальные границы, а отдельный этап CI проверяет внешние контракты.
Не объединяйте в один тест сразу базу, HTTP, очередь и файловую систему. Такой сценарий дорог, а при падении неясно, где ошибка. Разделяйте проверки по границам и оставляйте один-два сквозных сценария для критического пользовательского пути.
Что делать дальше
Сохраните вывод pytest --durations=20 до изменений и составьте список из пяти самых дорогих тестов. Для каждого укажите причину: сеть, база, фикстура, ожидание или слишком большой набор данных. Затем перенесите внешнюю зависимость на границу функции, удалите реальные задержки из unit-слоя и повторите замер.
После этого добавьте в CI отдельные этапы для быстрых и интеграционных тестов. В проде следите не только за длительностью pipeline, но и за долей повторно запущенных тестов, временем самого медленного теста и числом падений без изменений в коде. Эти показатели покажут, действительно ли набор стал надёжнее, а не просто тише.
Тест и проверочная: Быстрые ответы с объяснением, почему остальные варианты неверны
Спросить за 10 ₽Источники: pytest documentation: How to handle test failures и длительность тестов, pytest documentation: Fixtures reference
Частые вопросы
Нужно ли удалять все медленные интеграционные тесты?
Почему тест проходит отдельно, но падает вместе с остальными?
Можно ли заменить time.sleep ожиданием подольше?
Какая версия pytest нужна для этих приёмов?
Что делать, если --durations не показывает настоящего виновника?
Не станет ли тест слишком слабым после замены mock на простой объект?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.