Python Тестирование: pytest, фикстуры, моки, property-based, покрытие
0%

Тестирование: pytest, фикстуры, моки, property-based, покрытие

Тестирование: 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, БД, очереди

Три рабочих рецепта.

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

Три вещи, которые ломают покрытие в реальных проектах:

  1. Подпроцессы. Код, выполненный в multiprocessing-воркере или в subprocess, не попадает в отчёт. Лечится переменной COVERAGE_PROCESS_START и вызовом coverage.process_startup() через .pth-файл в site-packages.
  2. Импорт до старта измерения. При pytest --cov модули, импортированные раньше плагина, теряют строки определения — отсюда «странные» 0 % у пакета. Порядок плагинов и --cov в addopts это решают.
  3. Разные пути. Тесты в контейнере, отчёт на хосте — 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 показывает дыры, но не качество; проверка качества тестов — мутационное тестирование. А главный критерий живого набора прост: он быстрый, он детерминированный, и ему верят.

Источники

Что дальше

Производительность: профилирование, оптимизации, C-расширения, PyPy — разберём, почему интуиция об узких местах почти всегда ошибается, как читать вывод cProfile и py-spy, что реально ускоряет Python-код, и когда единственный выход — уйти в C, Cython или другой интерпретатор.

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

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

Доска запросов