Тестирование: pytest, фикстуры, моки, property-based, покрытие
В компилируемом языке значительная часть ошибок отсеивается до запуска: несуществующий
метод, перепутанный тип аргумента, забытая ветка в match — всё это ловит компилятор.
В Python компилятора в этом смысле нет. obj.svae(order) — синтаксически корректная
программа, и она упадёт ровно в тот момент, когда эта строка выполнится в проде. Отсюда
простой вывод: в Python тесты выполняют часть работы, которую в других языках делает
система типов, и объём тестирования здесь по умолчанию должен быть выше.
Часть этой нагрузки снимают аннотации типов и mypy — про них была отдельная статья. Но чекер не проверит, что скидка считается по правилам бизнеса, что при повторной оплате не спишутся деньги дважды и что парсер выживет на файле с BOM. Это проверяют тесты.
Здесь речь про инструменты и идиоматику именно Python. Общая теория тестирования —
пирамида, классы эквивалентности, когда писать интеграционные тесты, что такое
регрессия — разобрана в треке
«Тестирование» и в
«Принципах тестирования».
Основы синтаксиса assert и первого теста — в
«Программировании с нуля».
Карта экосистемы: что вообще ставят в проект
Базовый набор для нового проекта в 2026 году: pytest, pytest-cov, hypothesis,
pytest-asyncio (если есть async), testcontainers или pytest-postgresql (если есть
БД). Всё остальное добавляется по нужде. unittest из стандартной библиотеки остаётся
уместным ровно в двух случаях: тесты для самого CPython и код, который не может иметь
внешних зависимостей вообще.
Почему индустрия выбрала pytest: assert rewriting
Формальная причина проста: pytest избавляет от self.assertEqual. Настоящая причина
глубже — он переписывает ваши assert на уровне AST при импорте тестового модуля.
def test_order_total():
order = {"items": [1, 2, 3], "total": 7}
assert sum(order["items"]) == order["total"]
Обычный assert дал бы AssertionError без деталей. pytest подставляет
интроспекцию промежуточных значений:
E assert 6 == 7
E + where 6 = sum([1, 2, 3])
Механика: при сборке pytest перехватывает импорт тестовых модулей через свой
MetaPathFinder (см. «Модули и пакеты»),
разбирает исходник в AST, заменяет узел Assert на развёрнутую последовательность с
сохранением подвыражений во временные переменные и компилирует результат, кешируя
.pyc в __pycache__. Отсюда два практических следствия:
- Переписываются только тестовые модули,
conftest.pyи плагины. Если ваши общие хелперы с assert’ами лежат в отдельном пакете, вызовитеpytest.register_assert_rewrite("myproj.testing")до его импорта — иначе получите голоеAssertionErrorбез объяснений. - Оптимизация
python -Oвыкидываетassertиз байт-кода. Тесты под-Oне проверяют ничего. Это же причина, почемуassertнельзя использовать для валидации входных данных в проде — подробнее в «Исключениях».
Вторая опора pytest — система хуков (pluggy). Всё, включая фикстуры и сборку тестов,
реализовано плагинами, поэтому экосистема огромна: от pytest-django до
pytest-httpserver.
Жизненный цикл теста и что такое «фаза»
Каждый собранный тест (в терминах pytest — item) проходит три фазы, и у каждой свой
исход. Это не педантизм: ERROR в отчёте и FAILED означают разные вещи и чинятся
по-разному.
Читать так: FAILED — проблема в коде или в ожиданиях; ERROR — проблема в
окружении теста. Если в отчёте сотня ERROR, чинить надо одну фикстуру, а не сотню
тестов.
Посмотреть план построения фикстур можно без запуска:
pytest --collect-only -q # что вообще собралось
pytest --setup-plan tests/test_orders.py # какие фикстуры и в каком порядке
pytest --setup-show tests/test_orders.py # то же, но по ходу реального прогона
pytest --fixtures -q # все доступные фикстуры с докстрингами
--setup-plan — первое, что стоит запустить, когда тесты «почему-то медленные»: он
показывает, что дорогая фикстура пересоздаётся на каждом тесте.
Раскладка проекта, конфиг и один частый провал импорта
Классическая ошибка: тесты зелёные локально и падают в CI с ModuleNotFoundError, или
наоборот — тестируется не тот код, что поедет в релиз. Причина всегда в sys.path.
myproj/
├── pyproject.toml
├── src/
│ └── shop/
│ ├── __init__.py
│ └── pricing.py
└── tests/
├── conftest.py
├── unit/test_pricing.py
└── integration/test_orders_repo.py
src-layout (см. «Модули и пакеты»)
делает невозможным случайный импорт «из текущего каталога»: чтобы import shop
сработал, пакет должен быть установлен — обычно pip install -e . или
uv sync. Это ровно то, что вы хотите: тесты гоняют установленный дистрибутив, а не
дерево исходников, и ловят забытый файл в package_data.
[tool.pytest.ini_options]
minversion = "8.0"
testpaths = ["tests"]
addopts = "-ra -q --strict-markers --strict-config --import-mode=importlib"
xfail_strict = true
filterwarnings = [
"error", # любое предупреждение — провал
"ignore::DeprecationWarning:botocore.*", # точечные исключения, с комментарием
]
markers = [
"slow: дольше секунды, гоняем ночью",
"integration: нужен docker и внешние сервисы",
]
Что здесь важно и почему:
--strict-markers— опечатка@pytest.mark.slwoстанет ошибкой, а не молча проигнорируется. Без него нерабочий маркер тихо ничего не делает.xfail_strict = true— тест, помеченныйxfail, но внезапно прошедший, становится провалом. Иначе починенный баг остаётся навечно в списке «ожидаемо сломано».filterwarnings = ["error"]— самое недооценённое.DeprecationWarningиз библиотеки превращается в красный тест за год до того, как обновление сломает прод.--import-mode=importlib— современный режим импорта: pytest не вставляет каталоги вsys.pathи не требует__init__.pyв тестах. Снимает целый класс загадок с одноимённымиtest_utils.pyв разных папках.
conftest.py — не «файл с фикстурами», а точка расширения: фикстуры, хуки и плагины,
видимые всему поддереву каталогов. Их может быть несколько (корневой + на каждый слой
тестов), и они складываются по вложенности.
Фикстуры: это внедрение зависимостей, а не setUp
Главная идея, которую стоит принять: фикстура — это узел графа зависимостей, а не шаг «перед каждым тестом». Тест объявляет, что ему нужно, именем параметра; pytest строит граф и выполняет только те узлы, которые реально понадобились.
# tests/conftest.py
import os
import pytest
from sqlalchemy import create_engine, text
@pytest.fixture(scope="session")
def engine():
"""Дорогой ресурс: одно подключение и миграции на весь прогон."""
eng = create_engine(os.environ["TEST_DATABASE_URL"], future=True)
with eng.begin() as conn:
conn.execute(text("CREATE SCHEMA IF NOT EXISTS shop"))
yield eng # всё, что после yield, — финализация
eng.dispose()
@pytest.fixture
def connection(engine):
"""Дешёвая изоляция: каждый тест живёт в своей транзакции."""
conn = engine.connect()
tx = conn.begin()
try:
yield conn
finally:
tx.rollback() # тест не оставляет следов в БД
conn.close()
@pytest.fixture
def repo(connection):
return SqlOrderRepository(connection)
Тест пишет def test_save(repo): ... — и получает подключение, транзакцию и репозиторий.
Дорогое сделано один раз, изоляция — на каждом тесте.
Области видимости и главная ошибка новичка
Пять областей: function (по умолчанию), class, module, package, session.
Правило зависимостей одностороннее: фикстура может зависеть только от фикстуры
такой же или более широкой области.
@pytest.fixture(scope="session")
def broken(tmp_path): # tmp_path — function-scoped
...
# E ScopeMismatch: You tried to access the function scoped fixture tmp_path
# with a session scoped request object
Для session-каталога есть tmp_path_factory; для session-настроек — request с
областью сессии. Если очень хочется «session, но с уборкой между тестами», это почти
всегда сигнал, что состояние надо не переиспользовать, а откатывать (транзакция,
monkeypatch, копия каталога).
Порядок финализации — строго обратный порядку setup (LIFO), как у вложенных with.
Внутренне это и есть стек контекстных менеджеров — механика разобрана в
«Идиоматичном Python».
Фабрики вместо фикстур-констант
Фикстура, возвращающая готовый объект, годится, пока тесту нужен ровно один такой объект с одними и теми же полями. Дальше начинается копипаста. Идиоматичное решение — фикстура, возвращающая функцию:
@pytest.fixture
def make_order(repo):
"""Фабрика: тест просит столько заказов, сколько нужно, с нужными полями."""
created: list[Order] = []
def _make(total: int = 100, status: str = "new", **kw) -> Order:
order = Order(id=uuid4(), total=total, status=status, **kw)
repo.save(order)
created.append(order)
return order
yield _make
# уборка при необходимости; здесь её делает rollback транзакции
def test_only_new_orders_are_listed(make_order, repo):
make_order(status="new")
make_order(status="cancelled")
assert len(repo.list_new()) == 1
Плюс: тест читается как спецификация, значения по умолчанию не размазаны по файлу, а любое отклонение от «типичного заказа» видно прямо в вызове.
autouse: инструмент точечного применения
@pytest.fixture(autouse=True) подключает фикстуру ко всем тестам в области видимости,
без объявления в параметрах. Это удобно ровно для двух вещей: глобальная «страховка»
(запрет реальной сети, фиксация таймзоны, сброс кешей) и настройка логирования.
@pytest.fixture(autouse=True)
def _no_real_network(monkeypatch):
"""Любой тест, полезший в интернет, упадёт с понятным сообщением."""
def _boom(*args, **kwargs):
raise RuntimeError("тест пытается открыть сетевое соединение — замокайте клиент")
monkeypatch.setattr("socket.socket.connect", _boom)
Во всех остальных случаях autouse — это невидимая зависимость: тест зависит от чего-то,
что не написано в его сигнатуре. Отладка такого через полгода занимает часы.
Встроенные фикстуры, которые стоит знать наизусть
| Фикстура | Что делает | Типичная ошибка |
|---|---|---|
tmp_path |
pathlib.Path во временный каталог теста |
использовать устаревший tmpdir (py.path) |
tmp_path_factory |
то же, но session-scoped | пытаться получить tmp_path в session-фикстуре |
monkeypatch |
патчи атрибутов, os.environ, sys.path, chdir с авто-откатом |
os.environ["X"] = ... напрямую — течёт в другие тесты |
capsys / capfd |
перехват stdout/stderr (уровень Python / уровень fd) |
ждать вывод дочернего процесса от capsys — нужен capfd |
caplog |
записи logging как объекты |
логгер с propagate = False — записи не дойдут |
recwarn |
собранные предупреждения | конфликт с filterwarnings = error |
request |
метаданные теста, addfinalizer, param |
использовать как свалку глобального состояния |
def test_env_isolation(monkeypatch):
monkeypatch.setenv("SHOP_MODE", "test") # откатится автоматически
monkeypatch.delenv("AWS_PROFILE", raising=False)
monkeypatch.chdir("/tmp") # тоже вернётся обратно
assert load_config().mode == "test"
def test_logs_warning(caplog):
with caplog.at_level("WARNING", logger="shop.pricing"):
apply_discount(total=100, coupon=None)
assert "coupon is missing" in caplog.text
assert caplog.records[0].levelname == "WARNING"
Параметризация: один тест — таблица случаев
import pytest
from decimal import Decimal
@pytest.mark.parametrize(
("total", "rate", "expected"),
[
pytest.param(Decimal("100"), Decimal("0.10"), Decimal("90.00"), id="обычная-скидка"),
pytest.param(Decimal("100"), Decimal("0"), Decimal("100.00"), id="нулевая-скидка"),
pytest.param(Decimal("0"), Decimal("0.10"), Decimal("0.00"), id="нулевая-сумма"),
pytest.param(Decimal("100"), Decimal("1.5"), None, id="ставка-больше-100",
marks=pytest.mark.xfail(raises=ValueError, strict=True)),
],
)
def test_apply_discount(total, rate, expected):
assert apply_discount(total, rate) == expected
id — не косметика: именно он попадёт в отчёт CI и в команду перезапуска
(pytest -k "нулевая-сумма"). Автогенерируемые total0-rate0 бесполезны при разборе
падения.
Несколько декораторов parametrize перемножаются: два набора по 5 и 4 значения дадут
20 тестов, три набора — уже 200. Рост комбинаторный, O(n·m·k), и на этом регулярно
теряют минуты прогона. Если реальных сочетаний мало — перечислите их явным списком
кортежей, а не декартовым произведением.
Косвенная параметризация — когда параметр нужно передать не в тест, а в фикстуру:
@pytest.fixture
def client(request):
"""request.param приходит из parametrize(..., indirect=True)."""
with make_client(backend=request.param) as c:
yield c
@pytest.mark.parametrize("client", ["memory", "redis"], indirect=True)
def test_cache_roundtrip(client):
client.set("k", "v")
assert client.get("k") == "v"
Тот же эффект даёт параметризованная фикстура @pytest.fixture(params=[...]) — она
автоматически размножит все зависящие от неё тесты. Удобно для «прогнать весь набор
против двух реализаций порта».
Тестовые дубли: словарь и правила выбора
Терминология Джерарда Месароша (xUnit Test Patterns) и разбор Мартина Фаулера (Mocks Aren’t Stubs) — база, без которой обсуждение «моков» превращается в спор о словах.
Практическое правило: fake по протоколу лучше мока почти всегда. Мок проверяет
взаимодействие («был вызван save с таким аргументом») и потому ломается при любом
рефакторинге внутренностей. Fake проверяет поведение и переживает рефакторинг.
from typing import Protocol
from uuid import UUID
class OrderRepository(Protocol):
def get(self, order_id: UUID) -> Order | None: ...
def save(self, order: Order) -> None: ...
class InMemoryOrders:
"""Fake: настоящая работающая реализация порта, только в памяти."""
def __init__(self) -> None:
self._items: dict[UUID, Order] = {}
def get(self, order_id: UUID) -> Order | None:
return self._items.get(order_id)
def save(self, order: Order) -> None:
self._items[order.id] = order
_: OrderRepository = InMemoryOrders() # mypy проверит соответствие протоколу
Последняя строка — важный приём: статический чекер гарантирует, что fake не разошёлся с настоящим портом. Мок такой гарантии не даёт в принципе. Про протоколы и границы слоёв — в «Типизации» и «ООП», про архитектуру портов — в «Продакшн-архитектуре».
unittest.mock: две грабли, на которые наступают все
Грабля первая — патчить надо там, где смотрят, а не там, где определено.
# shop/report.py
from shop.clock import now # имя now СКОПИРОВАНО в пространство shop.report
def build() -> dict:
return {"generated_at": now()}
mocker.patch("shop.clock.now", return_value=FIXED) # не сработает: report держит свою ссылку
mocker.patch("shop.report.now", return_value=FIXED) # сработает
Почему так — прямое следствие модели имён и объектов из
«Модели данных»: from X import y копирует ссылку в
модуль-потребитель, и подмена атрибута в X этой копии не видна. Если писать
import shop.clock и вызывать shop.clock.now(), патч по месту определения заработает —
поэтому «импортировать модуль, а не имя» в тестируемом коде часто удобнее.
Грабля вторая — MagicMock соглашается на всё.
from unittest.mock import MagicMock, create_autospec
repo = MagicMock()
repo.svae(order) # опечатка: мок молча создаёт атрибут, тест зелёный
repo = create_autospec(OrderRepository, spec_set=True, instance=True)
repo.svae(order) # AttributeError — как в реальном объекте
repo.save(1, 2, 3) # TypeError: сигнатура не совпадает
create_autospec (или patch(..., autospec=True)) строит дубль по сигнатурам реального
объекта, spec_set=True дополнительно запрещает присваивание несуществующих атрибутов.
Правило: autospec по умолчанию, голый Mock — только осознанно.
Смежная ловушка — опечатки в проверках. Современный unittest.mock блокирует атрибуты,
начинающиеся с assert/assret (а с 3.12 — ещё called_once, called_once_with,
has_calls), так что mock.assert_called_onse() падает с AttributeError. Но всё
остальное по-прежнему на вашей совести.
repo.save.assert_called_once_with(order)
assert repo.save.call_args.kwargs["retry"] is False
assert repo.save.await_count == 1 # для AsyncMock
AsyncMock создаётся автоматически, когда autospec видит async def. Без autospec
MagicMock() вернёт из корутинного метода не awaitable-объект, и тест упадёт в
странном месте.
Что мокать нельзя
Есть эмпирическое правило «don’t mock what you don’t own»: не подменяйте чужие
библиотеки — подменяйте свою обёртку над ними. Мок boto3 фиксирует ваше представление
об AWS, а не поведение AWS. Правильнее: тонкий адаптер + fake адаптера в юнит-тестах +
один-два интеграционных теста против moto/localstack/реального стенда.
То же и с временем, случайностью и файловой системой: их лучше внедрять, а не патчить.
from datetime import UTC, datetime
from collections.abc import Callable
def make_receipt(order: Order, now: Callable[[], datetime] = lambda: datetime.now(UTC)) -> Receipt:
return Receipt(order_id=order.id, issued_at=now())
def test_receipt_time():
fixed = datetime(2026, 1, 1, tzinfo=UTC)
assert make_receipt(order, now=lambda: fixed).issued_at == fixed
Если внедрение невозможно (легаси), берите time-machine — он патчит время на уровне C
и работает быстрее, чем freezegun.
Границы: HTTP, БД, очереди
без фикстур и моков"] B -->|да| D{"Владеем ли мы этой зависимостью"} D -->|да, это наш порт| E["Fake по Protocol
плюс интеграционный тест реализации"] D -->|нет, чужой API| F{"Можно поднять локально"} F -->|да| G["testcontainers или localstack
контракт проверяется по-настоящему"] F -->|нет| H["Мок транспорта: respx, responses
плюс контрактный тест по схеме"] C --> I["Быстро, стабильно, таких тестов много"] E --> I G --> J["Медленно, ценно, таких тестов мало"] H --> J
Три рабочих рецепта.
HTTP-клиент. Не патчите requests.get — подменяйте транспорт:
import httpx, pytest
@pytest.fixture
def payments_client() -> httpx.Client:
def handler(request: httpx.Request) -> httpx.Response:
assert request.headers["idempotency-key"] # контракт проверяем здесь
return httpx.Response(200, json={"status": "captured"})
return httpx.Client(transport=httpx.MockTransport(handler), base_url="https://pay.test")
def test_capture(payments_client):
assert PaymentGateway(payments_client).capture(order_id="42") == "captured"
Своё ASGI-приложение (см. «Веб и API»):
import httpx
from shop.api import app
async def test_health():
transport = httpx.ASGITransport(app=app) # без реального сокета и порта
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
resp = await client.get("/health")
assert resp.status_code == 200
assert resp.json() == {"status": "ok"}
База данных. SQLite вместо PostgreSQL в тестах — классическая экономия, которая
дорого обходится: разные типы, разный JSON, разные блокировки, отсутствующие
CTE-возможности. Если приложение работает на PostgreSQL — тестируйте на PostgreSQL:
testcontainers поднимет его в docker на сессию, а изоляцию даст откат транзакции
(см. фикстуры выше). Про различия движков — трек
«Базы данных».
Асинхронные тесты
Корутина, вызванная без цикла событий, просто вернёт объект корутины — тест «пройдёт», ничего не выполнив. Поэтому нужен плагин.
[tool.pytest.ini_options]
asyncio_mode = "auto" # любой async def — тест, без декоратора
asyncio_default_fixture_loop_scope = "function"
import asyncio, pytest
async def test_timeout_is_enforced():
with pytest.raises(TimeoutError):
async with asyncio.timeout(0.05):
await asyncio.sleep(1)
@pytest.fixture(scope="session")
async def pool(): # фикстура тоже может быть async
p = await create_pool(dsn)
yield p
await p.close()
Грабли, специфичные для async-тестов:
- Разные циклы событий. Session-фикстура создала пул на одном цикле, function-тест
бежит на другом — получите
attached to a different loop. Лечится согласованием областей:@pytest.mark.asyncio(loop_scope="session")иasyncio_default_fixture_loop_scope. Переопределять фикстуруevent_loopв современных версияхpytest-asyncioбольше нельзя — это устаревший приём. - Незавершённые задачи.
asyncio.create_taskбезawaitдаётTask was destroyed but it is pending!в конце прогона и плавающие падения. Структурная конкурентность (asyncio.TaskGroup) решает это в корне — см. «Конкурентность». - Сон вместо синхронизации.
await asyncio.sleep(0.1)«чтобы успело» — источник флаки-тестов номер один. Используйтеasyncio.Event,wait_forили явные хендлы. - Альтернатива плагину —
anyio.pytest_pluginс фикстуройanyio_backend; он же позволяет прогнать те же тесты и наtrio.
Property-based тестирование: Hypothesis
Пример-ориентированный тест проверяет те случаи, о которых вы подумали. Баги живут в остальных. Property-based переворачивает подход: вы описываете свойство, которое должно выполняться для любого входа, а библиотека сама ищет контрпример.
from hypothesis import given, assume, settings, strategies as st
@given(st.lists(st.integers()))
def test_sorted_is_idempotent(xs):
once = sorted(xs)
assert sorted(once) == once # метаморфное свойство
@given(st.text())
def test_slug_roundtrip(s):
assume(s.strip()) # отбрасываем неинтересный вход
assert unslugify(slugify(s)) == normalize(s)
Ключевая ценность — не генерация, а сжатие (shrinking). Найдя падение на входе
[7, -3, 912, 0, 44], Hypothesis автоматически уменьшает его до минимального
контрпримера и печатает воспроизводимый пример:
Falsifying example: test_merge(a=[0], b=[0])
Отладить a=[0], b=[0] можно за минуту; исходный случайный список — нет. Найденный
контрпример сохраняется в каталоге .hypothesis/examples и воспроизводится первым
при следующем запуске, так что регрессия не проскочит.
Четыре типа свойств, которые почти всегда находятся в реальном коде:
| Тип свойства | Формулировка | Пример из практики |
|---|---|---|
| Round-trip | decode(encode(x)) == x |
JSON/сериализация, парсер конфигов, кодек |
| Инвариант | результат всегда удовлетворяет условию | сумма позиций равна итогу, баланс не отрицателен |
| Метаморфное | связь между двумя запусками | сортировка идемпотентна, скидка монотонна |
| Оракул | новая реализация = старая | замена медленного кода быстрым |
from decimal import Decimal
money = st.builds(
Money,
amount=st.decimals(min_value=0, max_value=10**9, places=2, allow_nan=False),
currency=st.sampled_from(["USD", "EUR", "RUB"]),
)
@given(money)
def test_money_json_roundtrip(m: Money):
assert Money.from_json(m.to_json()) == m
Это же — идеальный способ проверять контракт __eq__/__hash__ для value-объектов:
рефлексивность, симметричность, согласованность хеша (детали контракта —
в «Модели данных»).
Stateful-тестирование: находит то, что не находит ничто другое
Самые дорогие баги — не в функции, а в последовательности операций. RuleBasedStateMachine
генерирует случайные сценарии вызовов и сверяет реализацию с простой моделью.
from hypothesis.stateful import RuleBasedStateMachine, rule, invariant, precondition
class CartMachine(RuleBasedStateMachine):
def __init__(self):
super().__init__()
self.cart = Cart() # реальная реализация
self.model: list[int] = [] # заведомо правильная «модель»
@rule(price=st.integers(min_value=1, max_value=10_000))
def add(self, price):
self.cart.add(price)
self.model.append(price)
@precondition(lambda self: self.model)
@rule()
def remove_last(self):
self.cart.remove_last()
self.model.pop()
@invariant()
def total_matches(self):
assert self.cart.total() == sum(self.model)
TestCart = CartMachine.TestCase # pytest подберёт это как обычный тест
Hypothesis сгенерирует сотни последовательностей add/remove_last и, найдя расхождение,
сожмёт её до минимальной — обычно из двух-трёх шагов.
Настройка под CI и типичные грабли
# tests/conftest.py
import os
from hypothesis import HealthCheck, settings
settings.register_profile("dev", max_examples=25)
settings.register_profile("ci", max_examples=500, deadline=None,
suppress_health_check=[HealthCheck.too_slow])
settings.load_profile(os.getenv("HYPOTHESIS_PROFILE", "dev"))
deadlineпо умолчанию 200 мс на пример. На загруженном раннере CI это даёт флаки-паденияDeadlineExceeded— в CI-профиле его отключают.assumeне фильтр, а отбраковка. Если условие отсекает 90 % входов, вы получитеFailedHealthCheck: filter_too_much. Правильно — сузить стратегию (st.integers(min_value=1)), а не отбрасывать сгенерированное.- Плавающая точка.
st.floats()по умолчанию генерируетnanиinf; для денег и метрик используйтеallow_nan=False, allow_infinity=Falseили вообщеDecimal. - Не смешивайте
@givenс фикстурами, меняющими состояние. Функция под@givenвызывается многократно, а function-scoped фикстура строится один раз на весь набор примеров — БД накопит мусор. Для БД используйте stateful-машину или явную уборку внутри теста. .hypothesis/кладут в.gitignore, но кешируют между запусками CI — иначе теряется главное свойство базы примеров: не забывать найденные баги.
Покрытие: что оно измеряет и что не измеряет
coverage.py работает как трассировщик: раньше — через sys.settrace, начиная с
версии 7.4 умеет использовать sys.monitoring из
PEP 669 (включается COVERAGE_CORE=sysmon на
Python 3.12+), что снижает накладные расходы с десятков процентов почти до нуля.
[tool.coverage.run]
branch = true # без этого метрика почти бесполезна
source = ["src"]
parallel = true # отдельный файл данных на процесс
concurrency = ["thread", "multiprocessing"]
relative_files = true # чтобы пути совпали между docker и хостом
[tool.coverage.report]
fail_under = 85
show_missing = true
skip_covered = true
exclude_also = [
"if TYPE_CHECKING:",
"raise NotImplementedError",
"@(typing\\.)?overload",
"if __name__ == .__main__.:",
]
coverage run -m pytest && coverage combine && coverage report -m
# либо через плагин, что удобнее с xdist:
pytest -n auto --cov=shop --cov-branch --cov-report=term-missing --cov-report=xml
Три вещи, которые ломают покрытие в реальных проектах:
- Подпроцессы. Код, выполненный в
multiprocessing-воркере или вsubprocess, не попадает в отчёт. Лечится переменнойCOVERAGE_PROCESS_STARTи вызовомcoverage.process_startup()через.pth-файл в site-packages. - Импорт до старта измерения. При
pytest --covмодули, импортированные раньше плагина, теряют строки определения — отсюда «странные» 0 % у пакета. Порядок плагинов и--covвaddoptsэто решают. - Разные пути. Тесты в контейнере, отчёт на хосте —
relative_files = trueи[paths]-секция для склейки.
Честно: почему 100 % ничего не доказывают
Покрытие отвечает на вопрос «какой код исполнялся», а не «какое поведение проверено».
Тест без единого assert даёт то же покрытие, что и хороший.
def test_useless():
build_report(orders) # 100 % покрытия функции, 0 % проверки поведения
Отсюда рабочая позиция: покрытие — детектор дыр, а не цель. Смотрите на непокрытые
строки (там часто лежит забытая обработка ошибок), держите порог как защиту от регресса
(fail_under), но не поднимайте его до 100 % — последние проценты покупаются
бессмысленными тестами на __repr__ и # pragma: no cover по всему коду. Хорошее
изложение позиции — Code coverage best practices
от Google Testing Blog.
Что действительно измеряет качество тестов — мутационное тестирование: инструмент
вносит в код мелкие правки (> на >=, + на -, True на False) и проверяет, что
хоть один тест покраснел. Выживший мутант — доказанная дыра.
uvx mutmut run --paths-to-mutate src/shop/pricing.py
uvx mutmut results
Дорого по времени, поэтому запускают точечно — на модуле с бизнес-логикой перед серьёзным рефакторингом.
Куда вкладывать усилия
Тесты «с моками внутренностей» и снапшоты гигантских ответов — главные источники ложной уверенности: они ломаются при рефакторинге, но не ловят регрессий поведения.
Скорость и стабильность прогона
Медленный набор перестают запускать, флаки-набор перестают читать. Оба состояния одинаково смертельны.
pytest -n auto --dist loadgroup # параллельно по ядрам, группы вместе
pytest --lf -x # только упавшие в прошлый раз, стоп на первом
pytest --durations=10 # десять самых медленных тестов
pytest -p no:randomly # временно отключить рандомизацию порядка
pytest --collect-only -q | wc -l # сколько тестов вообще собирается
pytest-xdist раскидывает тесты по процессам. Всё, что было общим состоянием,
немедленно всплывает: одна тестовая БД на четыре воркера превращается в гонку. Решения —
своя схема/база на воркер (имя берётся из переменной PYTEST_XDIST_WORKER) или
--dist loadgroup с @pytest.mark.xdist_group("db"), чтобы связанные тесты попали в
один процесс.
pytest-randomly перемешивает порядок тестов и сбрасывает сиды random/faker перед
каждым тестом. Первый прогон после его установки обычно красный — и это ценная
информация: у вас были тесты, зависящие от порядка.
| Причина флаки-теста | Как проявляется | Чем чинить |
|---|---|---|
| Общее состояние между тестами | падает только в определённом порядке | pytest-randomly, monkeypatch, откат транзакции |
| Зависимость от времени | падает в полночь, 29 февраля, в другой TZ | инъекция now(), time-machine, TZ=UTC в CI |
| Сон вместо синхронизации | падает под нагрузкой CI | события, wait_for, polling с таймаутом |
| Реальная сеть | падает при недоступном DNS | MockTransport, запрет сокетов в autouse-фикстуре |
| Недетерминированный порядок | сравнение list вместо set |
сравнивать множества/сортировать |
| Параллелизм | падает только с -n auto |
изоляция ресурсов, xdist_group |
| Плавающая точка | 0.1 + 0.2 != 0.3 | pytest.approx(0.3, rel=1e-9), Decimal |
Флаки-тест, который «иногда падает», нельзя оставлять с @pytest.mark.flaky. Либо
чините, либо удаляйте: тест, которому не верят, вреднее отсутствия теста.
Прогон в CI
# .github/workflows/tests.yml (фрагмент)
jobs:
tests:
runs-on: ubuntu-latest
strategy:
matrix:
python: ["3.11", "3.12", "3.13"]
env:
TZ: UTC
HYPOTHESIS_PROFILE: ci
COVERAGE_CORE: sysmon
steps:
- uses: actions/checkout@v4
- uses: astral-sh/setup-uv@v5
- run: uv sync --all-extras --dev
- run: uv run pytest -n auto --cov=shop --cov-branch --cov-report=xml
- uses: actions/cache@v4 # база найденных Hypothesis контрпримеров
with:
path: .hypothesis
key: hypothesis-${{ matrix.python }}
Матрицу версий локально удобно гонять через nox (тот же tox, но конфиг на Python):
# noxfile.py
import nox
@nox.session(python=["3.11", "3.12", "3.13"])
def tests(session: nox.Session) -> None:
session.install("-e", ".[dev]")
session.run("pytest", "-q", *session.posargs)
Разделение на быстрый и медленный прогон — обязательное условие живого CI:
pytest -m "not slow and not integration" на каждый push (цель — до двух минут),
полный набор на merge и по ночам. Детали пайплайнов — в
«Тестах в CI» и
«Основах CI».
Где Python выигрывает и где проигрывает
Выигрывает. Динамика делает подмену чего угодно тривиальной: не нужны интерфейсы ради тестируемости, DI-контейнеры и фреймворки мокирования с генерацией прокси. Фикстуры pytest — одна из лучших моделей организации тестовых зависимостей в индустрии. Hypothesis — вероятно, лучшая property-based библиотека вне Haskell. Экосистема плагинов закрывает почти любую границу из коробки.
Проигрывает. Прогон медленный: импорт зависимостей крупного сервиса занимает
секунды до первого теста, а сами тесты интерпретируются. В Go тесты компилируются и
параллелятся из коробки (сравните с «Тестированием в Go»),
в Rust cargo test идёт параллельно по умолчанию
(«Тестирование в Rust»). Та же динамика,
которая упрощает моки, позволяет замокать что угодно — и наборы тестов легко вырождаются
в проверку собственных моков. И наконец, отсутствие компилятора означает, что часть
тестов существует только ради проверки того, что в других языках гарантировано бесплатно;
mypy --strict в CI сокращает эту категорию заметно.
Вывод для практики: держите юнит-слой быстрым и без моков (чистые функции домена), границы проверяйте настоящими зависимостями в docker, а инварианты отдайте Hypothesis.
Типичные грабли: сводка
| Симптом | Причина | Лечение |
|---|---|---|
ModuleNotFoundError в CI |
пакет не установлен, надежда на sys.path |
src-layout + pip install -e ., --import-mode=importlib |
| Патч не действует | патч по месту определения, а не использования | patch("модуль_потребитель.имя") |
| Тест зелёный при опечатке в вызове мока | голый MagicMock |
create_autospec(..., spec_set=True) |
ScopeMismatch |
широкая фикстура просит узкую | tmp_path_factory, пересмотр областей |
| Тесты «текут» друг в друга | os.environ, глобальные кеши, синглтоны |
monkeypatch, откат транзакции, сброс lru_cache |
| Порядок влияет на результат | скрытая зависимость через состояние | pytest-randomly, изоляция |
Task was destroyed but it is pending |
незавершённые задачи в async-тестах | TaskGroup, явный await/cancel |
attached to a different loop |
несогласованные loop_scope фикстур |
единая область цикла событий |
DeadlineExceeded только в CI |
дефолтный deadline Hypothesis |
профиль CI с deadline=None |
filter_too_much |
злоупотребление assume |
сузить стратегию |
| Покрытие 0 % у пакета | импорт раньше старта измерения | coverage run -m pytest или порядок плагинов |
| Нет покрытия воркеров | подпроцессы не трассируются | COVERAGE_PROCESS_START + .pth |
| xfail навсегда в отчёте | баг починили, маркер остался | xfail_strict = true |
| Тест ходит в интернет | забытый реальный клиент | autouse-запрет сокетов |
0.1 + 0.2 != 0.3 |
сравнение float на равенство | pytest.approx, Decimal |
Чек-лист перед мержем
- Новый тест падает, если убрать исправление (проверьте руками — это дешевле мутационного тестирования).
- В тесте есть хотя бы один осмысленный
assert, и он про поведение, а не про вызовы. - Нет
sleepв качестве синхронизации, нет обращений в сеть, нет зависимости от «сегодня». - Дубли построены через
autospecили fake поProtocol, а не через голыйMagicMock. - Параметризация имеет читаемые
id, комбинаторика не раздувает прогон. - Медленные тесты помечены маркером и не блокируют быстрый прогон.
pytest -p no:cacheprovider -n autoпроходит с чистого состояния и в параллели.- Покрытие не упало ниже порога, а непокрытые строки просмотрены глазами.
Мини-итог
pytest — это не «библиотека assert’ов», а раннер с двумя сильными идеями: переписывание
assert для читаемой диагностики и фикстуры как граф зависимостей с областями видимости
и LIFO-финализацией. Понимание фаз setup/call/teardown объясняет разницу между FAILED
и ERROR и снимает большую часть загадок с медленными и «текущими» тестами.
Моки — инструмент последней очереди: fake по Protocol даёт статически проверяемый
дубль, autospec спасает от зелёных тестов на опечатках, а патчить нужно там, где имя
ищут. Hypothesis закрывает то, что примерами не покрыть: round-trip, инварианты и
последовательности операций, — и главное, сам сжимает контрпример до отлаживаемого.
Покрытие с branch = true показывает дыры, но не качество; проверка качества тестов —
мутационное тестирование. А главный критерий живого набора прост: он быстрый, он
детерминированный, и ему верят.
Источники
- pytest documentation — начните с How-to guides и Fixtures reference.
- pytest: monkeypatch и Writing plugins.
- Brian Okken, Python Testing with pytest, 2nd ed. — лучшая книга именно по pytest.
- unittest.mock и Mock autospeccing.
- Martin Fowler, Mocks Aren’t Stubs; Gerard Meszaros, Test Double.
- Hypothesis documentation и What is property-based testing?; Stateful testing.
- coverage.py — разделы про branch coverage,
subprocessи конфигурацию; PEP 669 — Low impact monitoring. - Google Testing Blog, Code coverage best practices.
- pytest-asyncio и AnyIO testing.
- pytest-xdist, testcontainers-python, schemathesis для контрактных тестов API.
- Harry Percival, Bob Gregory, Architecture Patterns with Python — бесплатная онлайн-книга о том, как проектировать код, который легко тестировать.
Что дальше
Производительность: профилирование, оптимизации, C-расширения, PyPy —
разберём, почему интуиция об узких местах почти всегда ошибается, как читать вывод
cProfile и py-spy, что реально ускоряет Python-код, и когда единственный выход —
уйти в C, Cython или другой интерпретатор.