Лекция 3. Тестирование и логирование

Содержание

Лекция 3. Тестирование и логирование#

План#

Сначала коротко — зачем тесты вообще и какими они бывают. Дальше четыре блока:

  1. pytest — discovery, фикстуры и scope, monkeypatch, parametrize, conftest.py, marks, плагины.

  2. Coverage — line vs branch, что мерить и что нет.

  3. logging — уровни, иерархия, handlers/filters/formatters, dictConfig.

  4. structlog — structured logs, context binding.

В конце — раздел «Разбираем на семинаре»: то, что нужно для задач, но для основной части не обязательно.

Зачем тесты#

Работаем мы обычно командой, и договорённости о том, как ведёт себя код, надо где-то держать. Можно описать поведение текстом — в README, в комментарии, в голове автора. А можно зафиксировать его кодом, тестом.

Кодом всегда лучше, и причина одна: настроенный CI гоняет тесты на каждый коммит, поэтому зафиксированное поведение остаётся актуальным — либо оно выполняется, либо прогон красный. Текст же устаревает молча: код поменяли, описание осталось прежним, и никто не заметил.

Виды тестов#

Делят по тому, какой кусок системы тест трогает: юнит — одна функция или класс без внешнего мира; интеграционные — несколько компонентов вместе, часто с настоящими файлами или запущенным процессом; приёмочные — система целиком, глазами пользователя. Граница размытая, и спорить о ней бессмысленно.

1. pytest: за что мы его любим#

Три вещи, которые делают pytest де-факто стандартом:

  • Синтаксис: тест — просто функция и обычный assert, без обязательного класса-обёртки и без отдельного метода на каждый вид проверки (см. ниже).

  • Фикстуры как DI: аргументы функции подтягиваются из фикстур, время жизни задаётся явно.

  • Гигантская экосистема: pytest-mock, pytest-xdist, pytest-cov, pytest-benchmark, pytest-timeout, … (тесты для async — pytest-asyncio — рассмотрим дальше в курсе, там, где сам asyncio).

Термин: DI (dependency injection)

Зависимость не создаётся внутри функции, а передаётся ей снаружи. Тест объявляет, что ему нужно, параметром — def test_x(storage), — а кто и как создаст storage, решает pytest по имени параметра. Тест не знает ни про конструктор, ни про порядок инициализации, поэтому ту же зависимость легко подменить в другом окружении.

Синтаксис unittest#

В unittest из stdlib тест обязан быть методом класса-наследника TestCase, а на каждый вид сравнения есть свой метод: assertEqual, assertTrue, assertIn, assertRaises, … Их надо помнить и выбирать.

%%writefile /tmp/test_unittest_style.py
import unittest


def add(a: int, b: int) -> int:
    return a + b


class TestAdd(unittest.TestCase):
    def test_add_positive(self) -> None:
        self.assertEqual(add(2, 3), 5)

    def test_in(self) -> None:
        self.assertIn("a", "abc")

    def test_oops(self) -> None:
        self.assertEqual(add(2, 3), 6)  # ломаем специально — смотрим диагностику
Writing /tmp/test_unittest_style.py
!PYTHONPATH=/tmp uv run python -m unittest -v test_unittest_style 2>&1
test_add_positive (test_unittest_style.TestAdd.test_add_positive) ... ok
test_in (test_unittest_style.TestAdd.test_in) ... ok
test_oops (test_unittest_style.TestAdd.test_oops) ... FAIL

======================================================================
FAIL: test_oops (test_unittest_style.TestAdd.test_oops)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "/tmp/test_unittest_style.py", line 16, in test_oops
    self.assertEqual(add(2, 3), 6)  # ломаем специально — смотрим диагностику
    ~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^
AssertionError: 5 != 6

----------------------------------------------------------------------
Ran 3 tests in 0.001s

FAILED (failures=1)

То же самое на pytest — функция и обычный assert, без класса, без self, без словаря assert*-методов:

%%writefile /tmp/test_flat_style.py
def add(a: int, b: int) -> int:
    return a + b


def test_add_positive() -> None:
    assert add(2, 3) == 5


def test_in() -> None:
    assert "a" in "abc"


def test_oops() -> None:
    assert add(2, 3) == 6  # ломаем так же
Writing /tmp/test_flat_style.py
!uv run pytest /tmp/test_flat_style.py -v || true
============================= test session starts ==============================
platform darwin -- Python 3.14.8, pytest-9.1.1, pluggy-1.6.0 -- /Users/chopik/advanced-python-ami-lectures-2026/course-content/.venv/bin/python3
cachedir: .pytest_cache
hypothesis profile 'default'
rootdir: /tmp
plugins: mock-3.15.1, cov-7.1.0, xdist-3.8.0, asyncio-1.4.0, anyio-4.14.2, hypothesis-6.165.2
asyncio: mode=Mode.STRICT, debug=False, asyncio_default_fixture_loop_scope=None, asyncio_default_test_loop_scope=function
collecting ... 

collected 3 items                                                              

../../../../../../tmp/test_flat_style.py::test_add_positive PASSED       [ 33%]
../../../../../../tmp/test_flat_style.py::test_in PASSED                 [ 66%]
../../../../../../tmp/test_flat_style.py::test_oops FAILED               [100%]

=================================== FAILURES ===================================
__________________________________ test_oops ___________________________________

    def test_oops() -> None:
>       assert add(2, 3) == 6  # ломаем так же
        ^^^^^^^^^^^^^^^^^^^^^
E       assert 5 == 6
E        +  where 5 = add(2, 3)

/tmp/test_flat_style.py:14: AssertionError
=========================== short test summary info ============================
FAILED ../../../../../../tmp/test_flat_style.py::test_oops - assert 5 == 6
========================= 1 failed, 2 passed in 0.16s ==========================

unittest сообщил только итог сравнения: AssertionError: 5 != 6. Какое подвыражение дало пятёрку, приходится достраивать глазами по строке кода. Pytest переписывает assert на этапе импорта и печатает разбор сам: assert 5 == 6, а следом where 5 = add(2, 3). На add(2, 3) разница невелика, на условии в полстроки — уже нет.

Проверка исключений — pytest.raises#

Обычного assert хватает не на всё: проверить, что код бросил исключение, им нельзя — упавший вызов не даст добраться до самого assert. Для этого есть менеджер контекста pytest.raises.

%%writefile /tmp/test_raises.py
import pytest


def parse_age(raw: str) -> int:
    age = int(raw)
    if age < 0:
        raise ValueError(f"возраст не может быть отрицательным: {age}")
    return age


def test_raises_on_negative() -> None:
    with pytest.raises(ValueError):
        parse_age("-1")


def test_message_matters() -> None:
    # match — регулярка по тексту исключения
    with pytest.raises(ValueError, match="отрицательным"):
        parse_age("-1")


def test_inspect_the_exception() -> None:
    with pytest.raises(ValueError) as excinfo:
        parse_age("-5")
    assert "-5" in str(excinfo.value)
Writing /tmp/test_raises.py
!uv run pytest /tmp/test_raises.py -v
============================= test session starts ==============================
platform darwin -- Python 3.14.8, pytest-9.1.1, pluggy-1.6.0 -- /Users/chopik/advanced-python-ami-lectures-2026/course-content/.venv/bin/python3
cachedir: .pytest_cache
hypothesis profile 'default'
rootdir: /tmp
plugins: mock-3.15.1, cov-7.1.0, xdist-3.8.0, asyncio-1.4.0, anyio-4.14.2, hypothesis-6.165.2
asyncio: mode=Mode.STRICT, debug=False, asyncio_default_fixture_loop_scope=None, asyncio_default_test_loop_scope=function
collecting ... 
collected 3 items                                                              

../../../../../../tmp/test_raises.py::test_raises_on_negative PASSED     [ 33%]
../../../../../../tmp/test_raises.py::test_message_matters PASSED        [ 66%]
../../../../../../tmp/test_raises.py::test_inspect_the_exception PASSED  [100%]

============================== 3 passed in 0.08s ===============================

Три степени строгости. Голый pytest.raises(ValueError) проверяет только тип — и это чаще всего слишком слабо: parse_age("abc") тоже даёт ValueError, но совсем по другой причине, из int(). match= добавляет проверку текста, а excinfo.value отдаёт сам объект исключения, если надо посмотреть на поля.

Внутри with — только то, что должно упасть

Лишние строки в блоке — и тест начнёт проходить не по той причине: исключение бросит соседний вызов, а ты будешь думать, что проверил нужный. Правило: один вызов внутри with, всё остальное до или после.

Как pytest находит тесты#

Термин: discovery (сбор тестов)

Отдельная фаза до запуска: pytest обходит дерево каталогов, импортирует подходящие файлы и собирает из них тест-функции. Если тест «не бежит» — он чаще всего не запустился, а не упал: его просто не собрали. Смотреть, что собралось, — pytest –collect-only.

Правила discovery по умолчанию:

  • Файлы: test_*.py или *_test.py в текущем каталоге и всех подкаталогах.

  • Функции: def test_*(...) на верхнем уровне модуля.

  • Классы: class Test* без __init__ — методы внутри def test_*(self, ...). Класс с __init__ не собирается вовсе: PytestCollectionWarning, и в отчёте его тестов нет — ни passed, ни skipped.

Всё это конфигурируется в pyproject.toml ([tool.pytest.ini_options]):

[tool.pytest.ini_options]
testpaths = ["tests"]                    # где искать
python_files = ["test_*.py", "check_*.py"]
python_classes = ["Test*", "Check*"]
python_functions = ["test_*", "check_*"]

Полезное:

  • pytest --collect-only — показать, что нашлось, без запуска. Быстрый способ понять «почему мой тест не бежит».

  • Один файл: pytest tests/test_x.py. Один тест: pytest tests/test_x.py::test_foo. Один параметр: pytest tests/test_x.py::test_foo[case-name].

Сравнение float — pytest.approx#

0.1 + 0.2 == 0.3 в Python — False: float хранится двоичной дробью, а 0.1 в неё точно не ложится. Прямое сравнение float через == в тестах — почти всегда баг, ждущий своего часа.

%%writefile /tmp/test_float.py
import pytest


def scale(x: float, factor: float) -> float:
    return x * factor


def test_scale_exact_fails() -> None:
    # Не "иногда не сходится": 0.1 * 3 == 0.30000000000000004 на любой
    # машине. Точное сравнение float ломается детерминированно.
    assert scale(0.1, 3) != 0.3  # sic!


def test_scale_approx() -> None:
    assert scale(0.1, 3) == pytest.approx(0.3)               # по умолчанию rel=1e-6
    assert scale(0.1, 3) == pytest.approx(0.3, rel=1e-9)     # допуск в долях
    assert scale(0.1, 3) == pytest.approx(0.3, abs=1e-12)    # допуск в единицах

    # Работает и для контейнеров:
    assert [0.1 + 0.2, 1.0 / 3] == pytest.approx([0.3, 0.333333], rel=1e-4)
    assert {"a": 0.1 + 0.2} == pytest.approx({"a": 0.3})


def test_rel_vs_abs() -> None:
    # rel — доля от ожидаемого: 2% от 100 это 2, от 1 это 0.02.
    assert 101 == pytest.approx(100, rel=0.02)
    assert 103 != pytest.approx(100, rel=0.02)

    # abs — фиксированная величина, масштаб не важен.
    assert 3 == pytest.approx(1, abs=2)

    # Около нуля rel бесполезен: rel * 0 == 0, любой допуск схлопывается.
    assert 1e-9 != pytest.approx(0, rel=0.01)
    assert 1e-9 == pytest.approx(0, abs=1e-6)

    # Заданы оба — достаточно, чтобы сошёлся любой.
    assert 104 == pytest.approx(100, rel=0.001, abs=5)
Writing /tmp/test_float.py
!uv run pytest /tmp/test_float.py -v
============================= test session starts ==============================
platform darwin -- Python 3.14.8, pytest-9.1.1, pluggy-1.6.0 -- /Users/chopik/advanced-python-ami-lectures-2026/course-content/.venv/bin/python3
cachedir: .pytest_cache
hypothesis profile 'default'
rootdir: /tmp
plugins: mock-3.15.1, cov-7.1.0, xdist-3.8.0, asyncio-1.4.0, anyio-4.14.2, hypothesis-6.165.2
asyncio: mode=Mode.STRICT, debug=False, asyncio_default_fixture_loop_scope=None, asyncio_default_test_loop_scope=function
collecting ... 
collected 3 items                                                              

../../../../../../tmp/test_float.py::test_scale_exact_fails PASSED       [ 33%]
../../../../../../tmp/test_float.py::test_scale_approx PASSED            [ 66%]
../../../../../../tmp/test_float.py::test_rel_vs_abs PASSED              [100%]

============================== 3 passed in 0.08s ===============================

Разница между rel и abs — в том, от чего считается допуск. rel задаёт его долей от ожидаемого значения: rel=0.02 это 2%, и для 100 допуск получится 2, а для 1 — всего 0.02. abs задаёт допуск в самих единицах, одинаковый при любом масштабе. Когда заданы оба, достаточно, чтобы сошёлся любой из них.

Практическое следствие — около нуля rel не работает вовсе: допуск считается как rel * |ожидаемое|, а у нуля это ноль. Сравниваешь с нулём — бери abs.

Фикстуры (fixtures) — DI-механизм#

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

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

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

Фикстура — функция, помеченная @pytest.fixture. Тесты просят её через параметр — pytest подставляет.

%%writefile /tmp/test_fixtures.py
import pytest


@pytest.fixture
def user() -> dict[str, str]:
    print("  [setup] creating user")
    return {"name": "Ivan", "role": "student"}


def test_user_name(user: dict[str, str]) -> None:
    assert user["name"] == "Ivan"


def test_user_role(user: dict[str, str]) -> None:
    assert user["role"] == "student"
Writing /tmp/test_fixtures.py
!uv run pytest /tmp/test_fixtures.py -v -s
============================= test session starts ==============================
platform darwin -- Python 3.14.8, pytest-9.1.1, pluggy-1.6.0 -- /Users/chopik/advanced-python-ami-lectures-2026/course-content/.venv/bin/python3
cachedir: .pytest_cache
hypothesis profile 'default'
rootdir: /tmp
plugins: mock-3.15.1, cov-7.1.0, xdist-3.8.0, asyncio-1.4.0, anyio-4.14.2, hypothesis-6.165.2
asyncio: mode=Mode.STRICT, debug=False, asyncio_default_fixture_loop_scope=None, asyncio_default_test_loop_scope=function
collecting ... 
collected 2 items                                                              

../../../../../../tmp/test_fixtures.py::test_user_name   [setup] creating user
PASSED
../../../../../../tmp/test_fixtures.py::test_user_role   [setup] creating user
PASSED

============================== 2 passed in 0.07s ===============================

Заметь: [setup] печатается дважды. Фикстура со scope="function" (это значение по умолчанию) создаётся заново на каждый тест.

Термин: scope (время жизни фикстуры)

Не область видимости, а как долго живёт один экземпляр и сколько тестов его переиспользуют: function (по умолчанию) — свой на каждый тест, class — на класс, module — на файл, package — на пакет, session — один на весь прогон. Видны же фикстуры одинаково — по месту объявления (тот же файл или conftest.py выше по дереву).

Scope: function / class / module / session#

%%writefile /tmp/test_scope.py
import pytest


@pytest.fixture(scope="module")
def storage() -> dict[str, list[str]]:
    print("  [setup module] готовлю хранилище")
    return {"users": []}


def test_add_user(storage: dict[str, list[str]]) -> None:
    storage["users"].append("alice")
    assert "alice" in storage["users"]


def test_users_count(storage: dict[str, list[str]]) -> None:
    # Тесты делят состояние — предыдущий тест уже добавил alice
    assert len(storage["users"]) == 1
Writing /tmp/test_scope.py
!uv run pytest /tmp/test_scope.py -v -s
============================= test session starts ==============================
platform darwin -- Python 3.14.8, pytest-9.1.1, pluggy-1.6.0 -- /Users/chopik/advanced-python-ami-lectures-2026/course-content/.venv/bin/python3
cachedir: .pytest_cache
hypothesis profile 'default'
rootdir: /tmp
plugins: mock-3.15.1, cov-7.1.0, xdist-3.8.0, asyncio-1.4.0, anyio-4.14.2, hypothesis-6.165.2
asyncio: mode=Mode.STRICT, debug=False, asyncio_default_fixture_loop_scope=None, asyncio_default_test_loop_scope=function
collecting ... 
collected 2 items                                                              

../../../../../../tmp/test_scope.py::test_add_user   [setup module] готовлю хранилище
PASSED
../../../../../../tmp/test_scope.py::test_users_count PASSED

============================== 2 passed in 0.08s ===============================

[setup module] печатается один раз — фикстура пережила между тестами одного модуля.

Сколько раз срабатывает каждый scope и как это выглядит вживую — в разделе «Разбираем на семинаре».

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

function — значение по умолчанию и почти всегда правильный ответ. Каждый тест получает свой экземпляр, порядок тестов не важен, сломать друг другу ничего нельзя. Менять стоит только когда есть причина.

class — когда тесты внутри класса проверяют один и тот же готовый результат, только с разных сторон. Считаем его один раз на класс, а не на каждый тест.

class TestReport:
    @pytest.fixture(scope="class")
    def report(self) -> Report:
        return build_report(SAMPLE)  # один разбор на весь класс

    def test_title(self, report: Report) -> None: ...
    def test_rows(self, report: Report) -> None: ...

module — дорогая подготовка, общая для всего файла: разобрать большой вход, сгенерировать набор данных, скомпилировать грамматику.

session — один раз на весь прогон, обычно объявляется в conftest.py: распаковать архив, собрать бинарник, поднять процесс.

# conftest.py
@pytest.fixture(scope="session")
def corpus() -> list[str]:
    # 200 МБ: на каждый тест — минуты вместо секунд
    return Path("tests/data/corpus.txt").read_text().splitlines()

package — на пакет целиком, между module и session. Нужен редко.

Осторожно с session/module scope

Экземпляр один на много тестов: что в нём поменял один тест, увидят все следующие. В демо выше test_add_user положил alice в общее хранилище, а test_users_count рассчитывает, что она там есть.

Отсюда правило: длинный scope берут для ресурса, который дорого создавать и который никто не меняет. Если тесты в ресурс пишут — оставляй function.

Проверим — запустим второй тест отдельно, без первого.

!uv run pytest "/tmp/test_scope.py::test_users_count" -v || true
============================= test session starts ==============================
platform darwin -- Python 3.14.8, pytest-9.1.1, pluggy-1.6.0 -- /Users/chopik/advanced-python-ami-lectures-2026/course-content/.venv/bin/python3
cachedir: .pytest_cache
hypothesis profile 'default'
rootdir: /tmp
plugins: mock-3.15.1, cov-7.1.0, xdist-3.8.0, asyncio-1.4.0, anyio-4.14.2, hypothesis-6.165.2
asyncio: mode=Mode.STRICT, debug=False, asyncio_default_fixture_loop_scope=None, asyncio_default_test_loop_scope=function
collecting ... 
collected 1 item                                                               

../../../../../../tmp/test_scope.py::test_users_count FAILED             [100%]

=================================== FAILURES ===================================
_______________________________ test_users_count _______________________________

storage = {'users': []}
    def test_users_count(storage: dict[str, list[str]]) -> None:
        # Тесты делят состояние — предыдущий тест уже добавил alice
>       assert len(storage["users"]) == 1
E       assert 0 == 1
E        +  where 0 = len([])

/tmp/test_scope.py:17: AssertionError
---------------------------- Captured stdout setup -----------------------------
  [setup module] готовлю хранилище
=========================== short test summary info ============================
FAILED ../../../../../../tmp/test_scope.py::test_users_count - assert 0 == 1
============================== 1 failed in 0.09s ===============================

Тот же тест, что был зелёным в прогоне всего файла. Сам по себе он не работает: alice в хранилище кладёт сосед, и без него проверять нечего.

Фикстура с teardown (yield)#

%%writefile /tmp/test_teardown.py
from collections.abc import Iterator
from pathlib import Path

import pytest


@pytest.fixture
def temp_file(tmp_path: Path) -> Iterator[Path]:
    f = tmp_path / "data.txt"
    f.write_text("hello")
    yield f
    print(f"  [teardown] прибираю за {f.name}")
    f.unlink()  # tmp_path убрал бы и сам, но нам нужен видимый teardown


def test_read(temp_file: Path) -> None:
    assert temp_file.read_text() == "hello"
Writing /tmp/test_teardown.py
!uv run pytest /tmp/test_teardown.py -v -s
============================= test session starts ==============================
platform darwin -- Python 3.14.8, pytest-9.1.1, pluggy-1.6.0 -- /Users/chopik/advanced-python-ami-lectures-2026/course-content/.venv/bin/python3
cachedir: .pytest_cache
hypothesis profile 'default'
rootdir: /tmp
plugins: mock-3.15.1, cov-7.1.0, xdist-3.8.0, asyncio-1.4.0, anyio-4.14.2, hypothesis-6.165.2
asyncio: mode=Mode.STRICT, debug=False, asyncio_default_fixture_loop_scope=None, asyncio_default_test_loop_scope=function
collecting ... 
collected 1 item                                                               

../../../../../../tmp/test_teardown.py::test_read PASSED  [teardown] прибираю за data.txt


============================== 1 passed in 0.09s ===============================

yield разделяет setup и teardown. tmp_path — встроенная фикстура pytest. Teardown отработает и если тест упал, а вот если исключение случилось до yield — теста не будет вовсе, он развалится на стадии setup (ERROR, не FAILED). Флаг -s в ячейках выше нужен, чтобы pytest не перехватывал stdout и чтобы print из фикстуры был виден.

request — контекст, в котором позвали фикстуру#

Фикстура может попросить request — встроенную фикстуру, через которую видно обстоятельства вызова. Что оттуда берут:

  • request.node — сам тест: имя, метки (get_closest_marker), stash;

  • request.config — конфиг прогона и опции командной строки;

  • request.getfixturevalue("name") — попросить другую фикстуру по имени прямо во время работы, а не через параметр;

  • request.param — значение, с которым pytest позвал параметризованную фикстуру.

Последнее стоит отдельного слова. Параметризовать можно не только тест, но и фикстуру: даёшь ей params=[...], и каждый зависящий от неё тест прогоняется столько раз, сколько там значений.

%%writefile /tmp/test_request.py
import pytest


@pytest.fixture(params=["json", "yaml"])
def fmt(request: pytest.FixtureRequest) -> str:
    return request.param


@pytest.fixture
def whoami(request: pytest.FixtureRequest) -> str:
    return request.node.name


def test_dump(fmt: str, whoami: str) -> None:
    print(f"  тест {whoami} работает с форматом {fmt}")
    assert fmt in {"json", "yaml"}
Writing /tmp/test_request.py
!uv run pytest /tmp/test_request.py -v -s
============================= test session starts ==============================
platform darwin -- Python 3.14.8, pytest-9.1.1, pluggy-1.6.0 -- /Users/chopik/advanced-python-ami-lectures-2026/course-content/.venv/bin/python3
cachedir: .pytest_cache
hypothesis profile 'default'
rootdir: /tmp
plugins: mock-3.15.1, cov-7.1.0, xdist-3.8.0, asyncio-1.4.0, anyio-4.14.2, hypothesis-6.165.2
asyncio: mode=Mode.STRICT, debug=False, asyncio_default_fixture_loop_scope=None, asyncio_default_test_loop_scope=function
collecting ... 

collected 2 items                                                              

../../../../../../tmp/test_request.py::test_dump[json]   тест test_dump[json] работает с форматом json
PASSED
../../../../../../tmp/test_request.py::test_dump[yaml]   тест test_dump[yaml] работает с форматом yaml
PASSED

============================== 2 passed in 0.08s ===============================

Один тест в коде — два в отчёте, и значение параметра попало в id. Получается второй способ размножить тесты: parametrize размножает по данным, params= у фикстуры — по способу подготовки. Сам тест при этом не меняется и про размножение не знает.

parametrize — базовый инструмент#

Одну и ту же проверку на десятке входов хочется свернуть в цикл: складываем кейсы в список и проходим for. Так можно — и работает это ровно до первой ошибки.

%%writefile /tmp/test_loop.py
CASES = [(2, True), (3, True), (4, False), (17, True), (0, False), (1, False)]


def is_prime(n: int) -> bool:
    return all(n % i != 0 for i in range(2, int(n**0.5) + 1))


def test_is_prime_loop() -> None:
    for n, expected in CASES:
        assert is_prime(n) == expected
Writing /tmp/test_loop.py
!uv run pytest /tmp/test_loop.py -v || true
============================= test session starts ==============================
platform darwin -- Python 3.14.8, pytest-9.1.1, pluggy-1.6.0 -- /Users/chopik/advanced-python-ami-lectures-2026/course-content/.venv/bin/python3
cachedir: .pytest_cache
hypothesis profile 'default'
rootdir: /tmp
plugins: mock-3.15.1, cov-7.1.0, xdist-3.8.0, asyncio-1.4.0, anyio-4.14.2, hypothesis-6.165.2
asyncio: mode=Mode.STRICT, debug=False, asyncio_default_fixture_loop_scope=None, asyncio_default_test_loop_scope=function
collecting ... 
collected 1 item                                                               

../../../../../../tmp/test_loop.py::test_is_prime_loop FAILED            [100%]

=================================== FAILURES ===================================
______________________________ test_is_prime_loop ______________________________
    def test_is_prime_loop() -> None:
        for n, expected in CASES:
>           assert is_prime(n) == expected
E           assert True == False
E            +  where True = is_prime(0)

/tmp/test_loop.py:10: AssertionError
=========================== short test summary info ============================
FAILED ../../../../../../tmp/test_loop.py::test_is_prime_loop - assert True == False
============================== 1 failed in 0.09s ===============================

Первый же упавший assert завершает функцию, поэтому до n=1 проверка не дошла — есть там баг или нет, мы так и не узнали. В отчёте при этом один тест: 1 failed выглядит одинаково и когда сломан один кейс, и когда сломаны все шесть. Отдельный кейс тоже не запустить: pytest про них ничего не знает, он видит только test_is_prime_loop.

parametrize разворачивает тот же список в отдельные тесты. Каждый кейс pytest считает и запускает сам по себе.

%%writefile /tmp/test_parametrize.py
import pytest


def is_prime(n: int) -> bool:
    return all(n % i != 0 for i in range(2, int(n**0.5) + 1))


@pytest.mark.parametrize("n,expected", [
    (2, True),
    (3, True),
    (4, False),
    (17, True),
    (0, False),
    (1, False),
])
def test_is_prime(n: int, expected: bool) -> None:
    assert is_prime(n) == expected
Writing /tmp/test_parametrize.py
!uv run pytest /tmp/test_parametrize.py -v || true
============================= test session starts ==============================
platform darwin -- Python 3.14.8, pytest-9.1.1, pluggy-1.6.0 -- /Users/chopik/advanced-python-ami-lectures-2026/course-content/.venv/bin/python3
cachedir: .pytest_cache
hypothesis profile 'default'
rootdir: /tmp
plugins: mock-3.15.1, cov-7.1.0, xdist-3.8.0, asyncio-1.4.0, anyio-4.14.2, hypothesis-6.165.2
asyncio: mode=Mode.STRICT, debug=False, asyncio_default_fixture_loop_scope=None, asyncio_default_test_loop_scope=function
collecting ... 
collected 6 items                                                              

../../../../../../tmp/test_parametrize.py::test_is_prime[2-True] PASSED  [ 16%]
../../../../../../tmp/test_parametrize.py::test_is_prime[3-True] PASSED  [ 33%]
../../../../../../tmp/test_parametrize.py::test_is_prime[4-False] PASSED [ 50%]
../../../../../../tmp/test_parametrize.py::test_is_prime[17-True] PASSED [ 66%]
../../../../../../tmp/test_parametrize.py::test_is_prime[0-False] 
FAILED [ 83%]
../../../../../../tmp/test_parametrize.py::test_is_prime[1-False] FAILED [100%]

=================================== FAILURES ===================================
____________________________ test_is_prime[0-False] ____________________________

n = 0, expected = False

    @pytest.mark.parametrize("n,expected", [
        (2, True),
        (3, True),
        (4, False),
        (17, True),
        (0, False),
        (1, False),
    ])
    def test_is_prime(n: int, expected: bool) -> None:
>       assert is_prime(n) == expected
E       assert True == False
E        +  where True = is_prime(0)

/tmp/test_parametrize.py:17: AssertionError
____________________________ test_is_prime[1-False] ____________________________

n = 1, expected = False

    @pytest.mark.parametrize("n,expected", [
        (2, True),
        (3, True),
        (4, False),
        (17, True),
        (0, False),
        (1, False),
    ])
    def test_is_prime(n: int, expected: bool) -> None:
>       assert is_prime(n) == expected
E       assert True == False
E        +  where True = is_prime(1)

/tmp/test_parametrize.py:17: AssertionError
=========================== short test summary info ============================
FAILED ../../../../../../tmp/test_parametrize.py::test_is_prime[0-False] - assert True == False
FAILED ../../../../../../tmp/test_parametrize.py::test_is_prime[1-False] - assert True == False
========================= 2 failed, 4 passed in 0.10s ==========================

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

!uv run pytest "/tmp/test_parametrize.py::test_is_prime[1-False]" -v || true
============================= test session starts ==============================
platform darwin -- Python 3.14.8, pytest-9.1.1, pluggy-1.6.0 -- /Users/chopik/advanced-python-ami-lectures-2026/course-content/.venv/bin/python3
cachedir: .pytest_cache
hypothesis profile 'default'
rootdir: /tmp
plugins: mock-3.15.1, cov-7.1.0, xdist-3.8.0, asyncio-1.4.0, anyio-4.14.2, hypothesis-6.165.2
asyncio: mode=Mode.STRICT, debug=False, asyncio_default_fixture_loop_scope=None, asyncio_default_test_loop_scope=function
collecting ... 
collected 1 item                                                               

../../../../../../tmp/test_parametrize.py::test_is_prime[1-False] FAILED [100%]
=================================== FAILURES ===================================
____________________________ test_is_prime[1-False] ____________________________

n = 1, expected = False

    @pytest.mark.parametrize("n,expected", [
        (2, True),
        (3, True),
        (4, False),
        (17, True),
        (0, False),
        (1, False),
    ])
    def test_is_prime(n: int, expected: bool) -> None:
>       assert is_prime(n) == expected
E       assert True == False
E        +  where True = is_prime(1)

/tmp/test_parametrize.py:17: AssertionError
=========================== short test summary info ============================
FAILED ../../../../../../tmp/test_parametrize.py::test_is_prime[1-False] - assert True == False
============================== 1 failed in 0.09s ===============================

Чинится одной строчкой в начале is_prime: if n < 2: return False.

monkeypatch#

Код то и дело обращается к тому, что в тесте не воспроизвести: случайные числа, текущее время, переменные окружения, сеть. Встроенная фикстура monkeypatch подменяет это на время одного теста, а после теста сама возвращает как было — тот же yield-teardown, только писать его не надо.

%%writefile /tmp/test_monkeypatch.py
import os
import random

import pytest

GREETINGS = ["привет", "здорово", "салют"]


def pick_greeting() -> str:
    return random.choice(GREETINGS)


def api_url() -> str:
    return os.environ.get("COURSE_API_URL", "http://localhost:8000")


def test_greeting_is_deterministic(monkeypatch: pytest.MonkeyPatch) -> None:
    monkeypatch.setattr(random, "choice", lambda seq: seq[-1])
    assert pick_greeting() == "салют"


def test_api_url_from_env(monkeypatch: pytest.MonkeyPatch) -> None:
    monkeypatch.setenv("COURSE_API_URL", "http://test.local")
    assert api_url() == "http://test.local"


def test_nothing_leaked() -> None:
    # Обе подмены откатились сами, ни строчки teardown.
    assert pick_greeting() in GREETINGS
    assert api_url() == "http://localhost:8000"
Writing /tmp/test_monkeypatch.py
!uv run pytest /tmp/test_monkeypatch.py -v
============================= test session starts ==============================
platform darwin -- Python 3.14.8, pytest-9.1.1, pluggy-1.6.0 -- /Users/chopik/advanced-python-ami-lectures-2026/course-content/.venv/bin/python3
cachedir: .pytest_cache
hypothesis profile 'default'
rootdir: /tmp
plugins: mock-3.15.1, cov-7.1.0, xdist-3.8.0, asyncio-1.4.0, anyio-4.14.2, hypothesis-6.165.2
asyncio: mode=Mode.STRICT, debug=False, asyncio_default_fixture_loop_scope=None, asyncio_default_test_loop_scope=function
collecting ... 
collected 3 items                                                              

../../../../../../tmp/test_monkeypatch.py::test_greeting_is_deterministic PASSED [ 33%]
../../../../../../tmp/test_monkeypatch.py::test_api_url_from_env PASSED  [ 66%]
../../../../../../tmp/test_monkeypatch.py::test_nothing_leaked PASSED    [100%]

============================== 3 passed in 0.08s ===============================

Термин: переменные окружения

Набор пар «имя -> строка», который процесс получает от того, кто его запустил, и передаёт дальше своим дочерним процессам. В Python это os.environ — обычный словарь строк. Туда кладут то, что меняется от машины к машине и не должно лежать в коде: адрес сервиса, режим работы (DEBUG=1), токены. Снаружи задаются перед командой — COURSE_API_URL=http://test.local python3 app.py, — а в тесте их подменяет monkeypatch.setenv.

Кроме setattr и setenv есть delattr, delenv, setitem/delitem для словарей, syspath_prepend и chdir. Откатываются они так же.

Главная ловушка — подменять не там. monkeypatch.setattr(random, "choice", ...) меняет атрибут у модуля random. А строчка from random import choice ещё при импорте завела в модуле service собственное имя choice, указывающее на ту же функцию. Дальше service зовёт своё имя и на random уже не смотрит:

%%writefile /tmp/service.py
from random import choice

GREETINGS = ["привет", "здорово", "салют"]


def greet() -> str:
    return choice(GREETINGS)
Writing /tmp/service.py
%%writefile /tmp/test_patch_target.py
import random

import pytest
import service


def test_patching_random_does_not_help(monkeypatch: pytest.MonkeyPatch) -> None:
    monkeypatch.setattr(random, "choice", lambda seq: "ЗАПАТЧЕНО")
    assert service.greet() == "ЗАПАТЧЕНО"


def test_patch_where_the_name_is_used(monkeypatch: pytest.MonkeyPatch) -> None:
    monkeypatch.setattr(service, "choice", lambda seq: "ЗАПАТЧЕНО")
    assert service.greet() == "ЗАПАТЧЕНО"
Writing /tmp/test_patch_target.py
!uv run pytest /tmp/test_patch_target.py -v || true
============================= test session starts ==============================
platform darwin -- Python 3.14.8, pytest-9.1.1, pluggy-1.6.0 -- /Users/chopik/advanced-python-ami-lectures-2026/course-content/.venv/bin/python3
cachedir: .pytest_cache
hypothesis profile 'default'
rootdir: /tmp
plugins: mock-3.15.1, cov-7.1.0, xdist-3.8.0, asyncio-1.4.0, anyio-4.14.2, hypothesis-6.165.2
asyncio: mode=Mode.STRICT, debug=False, asyncio_default_fixture_loop_scope=None, asyncio_default_test_loop_scope=function
collecting ... 
collected 2 items                                                              

../../../../../../tmp/test_patch_target.py::test_patching_random_does_not_help 
FAILED [ 50%]
../../../../../../tmp/test_patch_target.py::test_patch_where_the_name_is_used PASSED [100%]

=================================== FAILURES ===================================
______________________ test_patching_random_does_not_help ______________________

monkeypatch = <_pytest.monkeypatch.MonkeyPatch object at 0x10b5a03e0>

    def test_patching_random_does_not_help(monkeypatch: pytest.MonkeyPatch) -> None:
        monkeypatch.setattr(random, "choice", lambda seq: "ЗАПАТЧЕНО")
>       assert service.greet() == "ЗАПАТЧЕНО"
E       AssertionError: assert 'привет' == 'ЗАПАТЧЕНО'
E         
E         - ЗАПАТЧЕНО
E         + привет

/tmp/test_patch_target.py:9: AssertionError
=========================== short test summary info ============================
FAILED ../../../../../../tmp/test_patch_target.py::test_patching_random_does_not_help - AssertionError: assert 'привет' == 'ЗАПАТЧЕНО'
========================= 1 failed, 1 passed in 0.10s ==========================

Первый упал: в service лежит своя ссылка на исходный choice, и правка random.choice до неё не достаёт. Правило: патчить там, где имя ищут, а не там, где оно объявлено. Если бы service писал import random и звал random.choice(...), ловушки бы не было — но и «оба варианта» всё равно не сработали бы. Подменять надо было бы random.choice: имя ищут в модуле random уже в момент вызова. А monkeypatch.setattr(service, "choice", ...) тогда упал бы с AttributeError — такого атрибута у service просто нет.

Много подмен — плохой знак

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

Часто подмена и не нужна. Пусть функция не лезет за случайностью сама, а принимает её аргументом:

%%writefile /tmp/di_demo.py
import random
from collections.abc import Callable

GREETINGS = ["привет", "здорово", "салют"]


def greet_hardcoded() -> str:
    return random.choice(GREETINGS)  # откуда случайность — решено внутри


def greet(pick: Callable[[list[str]], str] = random.choice) -> str:
    return pick(GREETINGS)  # откуда брать — решает вызывающий
Writing /tmp/di_demo.py
%%writefile /tmp/test_di_demo.py
from di_demo import GREETINGS, greet


def test_greet_is_predictable() -> None:
    assert greet(lambda seq: seq[-1]) == "салют"


def test_greet_default_is_still_random() -> None:
    assert greet() in GREETINGS
Writing /tmp/test_di_demo.py
!uv run pytest /tmp/test_di_demo.py -v
============================= test session starts ==============================
platform darwin -- Python 3.14.8, pytest-9.1.1, pluggy-1.6.0 -- /Users/chopik/advanced-python-ami-lectures-2026/course-content/.venv/bin/python3
cachedir: .pytest_cache
hypothesis profile 'default'
rootdir: /tmp
plugins: mock-3.15.1, cov-7.1.0, xdist-3.8.0, asyncio-1.4.0, anyio-4.14.2, hypothesis-6.165.2
asyncio: mode=Mode.STRICT, debug=False, asyncio_default_fixture_loop_scope=None, asyncio_default_test_loop_scope=function
collecting ... 

collected 2 items                                                              

../../../../../../tmp/test_di_demo.py::test_greet_is_predictable PASSED  [ 50%]
../../../../../../tmp/test_di_demo.py::test_greet_default_is_still_random PASSED [100%]

============================== 2 passed in 0.10s ===============================

Тест передаёт свою функцию выбора и получает предсказуемый ответ, ничего не зная про внутренности greet. Ни monkeypatch, ни знания, из какого модуля пришёл choice. Так же убирается время (now аргументом вместо datetime.now() внутри) и всё остальное неуправляемое. Приём тот же, что в начале лекции: зависимость не берут внутри, а передают снаружи.

conftest.py — общие фикстуры и хуки#

conftest.py — специальный файл, который pytest ищет и подгружает сам, без импорта. Живёт в директории с тестами и действует на неё и все вложенные подкаталоги. В дереве может быть несколько conftest.py. Для фикстур побеждает ближайший к тесту — одноимённая фикстура снизу перекрывает верхнюю. А вот с хуками так не выйдет: pytest зовёт одноимённый хук из каждого подходящего conftest.py, ближний не отменяет верхний.

Что в него кладут:

1. Общие фикстуры — без импорта видны во всех test_*.py под этой директорией:

%%writefile /tmp/conftest_demo/conftest.py
import shutil
import tempfile
from collections.abc import Iterator
from pathlib import Path

import pytest


@pytest.fixture
def sample_user() -> dict[str, str]:
    return {"id": "u1", "name": "Alice"}


@pytest.fixture(scope="session")
def workdir() -> Iterator[Path]:
    path = Path(tempfile.mkdtemp())
    yield path
    shutil.rmtree(path)
Writing /tmp/conftest_demo/conftest.py

Тест лежит рядом и ничего не импортирует — просто просит фикстуры по именам:

%%writefile /tmp/conftest_demo/test_users.py
from pathlib import Path


def test_user_has_name(sample_user: dict[str, str]) -> None:
    assert sample_user["name"] == "Alice"


def test_workdir_is_writable(workdir: Path) -> None:
    (workdir / "a.txt").write_text("ok")
    assert (workdir / "a.txt").read_text() == "ok"
Writing /tmp/conftest_demo/test_users.py
!uv run pytest /tmp/conftest_demo -v
============================= test session starts ==============================
platform darwin -- Python 3.14.8, pytest-9.1.1, pluggy-1.6.0 -- /Users/chopik/advanced-python-ami-lectures-2026/course-content/.venv/bin/python3
cachedir: .pytest_cache
hypothesis profile 'default'
rootdir: /tmp/conftest_demo
plugins: mock-3.15.1, cov-7.1.0, xdist-3.8.0, asyncio-1.4.0, anyio-4.14.2, hypothesis-6.165.2
asyncio: mode=Mode.STRICT, debug=False, asyncio_default_fixture_loop_scope=None, asyncio_default_test_loop_scope=function
collecting ... 
collected 2 items                                                              

../../../../../../tmp/conftest_demo/test_users.py::test_user_has_name PASSED [ 50%]
../../../../../../tmp/conftest_demo/test_users.py::test_workdir_is_writable PASSED [100%]

============================== 2 passed in 0.06s ===============================

2. Хуки — точки расширения pytest’а. В примерах ниже мелькают marks (метки тестов) — про них следующий раздел.

Термин: хук (hook)

Функция с зарезервированным именем (pytest_configure, pytest_collection_modifyitems, …), которую в нужный момент зовёт сам pytest. Регистрировать ничего не надо: достаточно положить функцию с таким именем в conftest.py или в плагин — pytest найдёт её по имени. А вот опечатка обходится дорого: pytest_runtest_setupp — и прогон падает целиком, PluginValidationError: unknown hook, ни один тест не запускается. Молча пропадёт только функция, у которой сломан сам префикс pytest_.

%%writefile /tmp/hooks_demo/conftest.py
import pytest


def pytest_configure(config: pytest.Config) -> None:
    """Вызывается один раз при старте — до collection."""
    config.addinivalue_line("markers", "slow: медленные тесты")


def pytest_collection_modifyitems(items: list[pytest.Item]) -> None:
    """Пробегает по всем собранным тестам: можно навесить mark по имени
    файла, отсортировать их или отфильтровать."""
    for item in items:
        if "integration" in str(item.path):
            item.add_marker(pytest.mark.slow)


def pytest_runtest_setup(item: pytest.Item) -> None:
    """Вызывается перед каждым тестом — тут его можно и пропустить."""
    if item.get_closest_marker("slow"):
        pytest.skip("slow выключены в этом прогоне")


def pytest_runtest_call(item: pytest.Item) -> None:
    """Фаза прогона тела теста. Мы тут не вместо него, а вместе с ним."""
    print(f"  [call] начинается {item.name}")
Writing /tmp/hooks_demo/conftest.py

Два теста, и ни один про хуки не знает:

%%writefile /tmp/hooks_demo/test_unit.py
def test_add() -> None:
    print("  [тест] тело test_add")
    assert 2 + 2 == 4
Writing /tmp/hooks_demo/test_unit.py
%%writefile /tmp/hooks_demo/test_integration_api.py
def test_api_roundtrip() -> None:
    assert True
Writing /tmp/hooks_demo/test_integration_api.py
!uv run pytest /tmp/hooks_demo -v -rs -s
============================= test session starts ==============================
platform darwin -- Python 3.14.8, pytest-9.1.1, pluggy-1.6.0 -- /Users/chopik/advanced-python-ami-lectures-2026/course-content/.venv/bin/python3
cachedir: .pytest_cache
hypothesis profile 'default'
rootdir: /tmp/hooks_demo
plugins: mock-3.15.1, cov-7.1.0, xdist-3.8.0, asyncio-1.4.0, anyio-4.14.2, hypothesis-6.165.2
asyncio: mode=Mode.STRICT, debug=False, asyncio_default_fixture_loop_scope=None, asyncio_default_test_loop_scope=function
collecting ... 
collected 2 items                                                              

../../../../../../tmp/hooks_demo/test_integration_api.py::test_api_roundtrip SKIPPED
../../../../../../tmp/hooks_demo/test_unit.py::test_add   [call] начинается test_add
  [тест] тело test_add
PASSED

=========================== short test summary info ============================
SKIPPED [1] ../../../../../../tmp/hooks_demo/conftest.py:20: slow выключены в этом прогоне
========================= 1 passed, 1 skipped in 0.06s =========================

Имя файла попало в условие — тест получил метку slow и был пропущен, хотя в самом файле ни метки, ни skip нет. Флаг -rs печатает причину пропуска, -s не даёт прятать print.

Обрати внимание на pytest_runtest_call. Своей реализацией мы тело теста не заменили: [call] напечаталось, а сразу за ним отработал и сам тест. Хуки не переопределяют друг друга — pytest зовёт их все, в том числе свой собственный, который и запускает тест. Видно только одну сторону: что было до. Чем кончился тест, такой хук не узнает — для этого нужна обёртка.

Одного имени хватает, чтобы хук вызвался. Но иногда нужно не просто добавить свою реализацию, а обернуть остальные — выполнить код до и после и увидеть исход. Для этого есть декоратор @pytest.hookimpl; разбираем на семинаре.

Полный список хуков — в документации. Штатного способа спросить «а какие реализации этого хука сейчас есть» у pytest нет; pytest --trace-config покажет только, какие плагины и conftest.py вообще подхватились.

Правила:

  • conftest.py не запускается как тест и не импортируется руками — pytest сам его подхватит.

  • Одна и та же фикстура, объявленная в двух conftest’ах на разных уровнях, — резолвится по ближайшему к тесту.

  • Общую подготовку (фикстуры и хуки) держи именно здесь, не дублируй по тест-файлам.

Marks — метки тестов#

Mark — это метка/атрибут, которую вешаешь на тест-функцию (или на конкретный параметр в parametrize). Pytest их использует для двух вещей: модификация поведения (skip, xfail, timeout, …) и отбор (pytest -m slow — запустить только тесты с меткой slow).

Термин: skip vs xfail

  • skip — не запускать. Тест неприменим здесь и сейчас: не та платформа, нет внешнего сервиса.
  • xfail — запустить и ожидать провала. Код сломан, баг известен. Упал -> XFAIL, прогон зелёный. Неожиданно прошёл -> XPASS.
  • xfail(strict=True) — XPASS роняет прогон. Так узнаёшь, что баг починился и метку пора снять.

Встроенные, самые важные:

%%writefile /tmp/test_marks.py
import sys
import pytest


@pytest.mark.skip(reason="возможность временно выключена")
def test_disabled() -> None:
    assert False  # не побежит, тест skipped


@pytest.mark.skipif(sys.platform == "win32", reason="Unix-only")
def test_unix_only() -> None:
    assert "/" in "/tmp"


@pytest.mark.xfail(reason="известный баг")
def test_known_bug() -> None:
    assert 1 + 1 == 3  # ожидаемо падает -> XFAIL (жёлтый), не роняет прогон


@pytest.mark.xfail(strict=True, reason="если вдруг заработает — сигналим")
def test_should_still_be_broken() -> None:
    assert 1 + 1 == 3  # если пройдёт при strict=True — прогон упадёт (XPASS)


@pytest.mark.slow
def test_full_dataset() -> None:
    # долгий тест — можно исключить: pytest -m "not slow"
    assert sum(range(10_000)) == 49_995_000
Writing /tmp/test_marks.py
!uv run pytest /tmp/test_marks.py -v
============================= test session starts ==============================
platform darwin -- Python 3.14.8, pytest-9.1.1, pluggy-1.6.0 -- /Users/chopik/advanced-python-ami-lectures-2026/course-content/.venv/bin/python3
cachedir: .pytest_cache
hypothesis profile 'default'
rootdir: /tmp
plugins: mock-3.15.1, cov-7.1.0, xdist-3.8.0, asyncio-1.4.0, anyio-4.14.2, hypothesis-6.165.2
asyncio: mode=Mode.STRICT, debug=False, asyncio_default_fixture_loop_scope=None, asyncio_default_test_loop_scope=function
collecting ... 
collected 5 items                                                              

../../../../../../tmp/test_marks.py::test_disabled SKIPPED (возможно...) [ 20%]
../../../../../../tmp/test_marks.py::test_unix_only PASSED               [ 40%]
../../../../../../tmp/test_marks.py::test_known_bug XFAIL (известный...) [ 60%]
../../../../../../tmp/test_marks.py::test_should_still_be_broken XFAIL   [ 80%]
../../../../../../tmp/test_marks.py::test_full_dataset PASSED            [100%]

=============================== warnings summary ===============================
../../../../../../tmp/test_marks.py:25
  /tmp/test_marks.py:25: PytestUnknownMarkWarning: Unknown pytest.mark.slow - is this a typo?  You can register custom marks to avoid this warning - for details, see https://docs.pytest.org/en/stable/how-to/mark.html
    @pytest.mark.slow

-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
============== 2 passed, 1 skipped, 2 xfailed, 1 warning in 0.09s ==============

Кастомные marks (slow, integration, network и т.п.) надо объявить — иначе pytest выдаёт PytestUnknownMarkWarning. Два способа:

1. Декларативно, в pyproject.toml:

[tool.pytest.ini_options]
markers = [
  "slow: медленные тесты (исключаются в PR)",
  "integration: требуют внешний сервис",
]

2. Императивно, в conftest.py — тот самый pytest_configure, если нужна логика (условия, генерация):

def pytest_configure(config: pytest.Config) -> None:
    config.addinivalue_line("markers", "slow: медленные тесты (исключаются в PR)")
    config.addinivalue_line("markers", "integration: требуют внешний сервис")

Форматы эквивалентны. Первый — когда список статичный. Второй — когда marks добавляет плагин или их набор зависит от окружения.

Тогда селекция работает:

pytest -m slow              # только slow
pytest -m "not slow"        # все кроме slow
pytest -m "slow and not integration"

Посмотреть все зарегистрированные marks — pytest --markers.

Marks с аргументами#

Метка — не только флаг: ей можно передать данные, а потом прочитать их из хука. На этом держатся плагины вроде pytest-timeout (@pytest.mark.timeout(5)) — и задача, которая ждёт тебя после лекции.

%%writefile /tmp/marks_args_demo/conftest.py
import pytest


def pytest_configure(config: pytest.Config) -> None:
    config.addinivalue_line("markers", "budget(ms, *, strict=False): бюджет теста")


def pytest_runtest_call(item: pytest.Item) -> None:
    for marker in item.iter_markers("budget"):
        print(f"  [{item.name}] args={marker.args} kwargs={marker.kwargs}")
    closest = item.get_closest_marker("budget")
    print(f"  [{item.name}] ближайший: {closest.args if closest else None}")
Writing /tmp/marks_args_demo/conftest.py
%%writefile /tmp/marks_args_demo/test_budget.py
import pytest


@pytest.mark.budget(100)
def test_one() -> None:
    assert True


@pytest.mark.budget(500, strict=True)
@pytest.mark.budget(100)
def test_two() -> None:
    assert True
Writing /tmp/marks_args_demo/test_budget.py
!uv run pytest /tmp/marks_args_demo -q -s
  [test_one] args=(100,) kwargs={}
  [test_one] ближайший: (100,)
.  [test_two] args=(100,) kwargs={}
  [test_two] args=(500,) kwargs={'strict': True}
  [test_two] ближайший: (100,)
.
2 passed in 0.06s

Что видно в выводе. Позиционные аргументы метки лежат в marker.args, именованные — в marker.kwargs. Меток с одним именем на тесте может быть несколько, и item.iter_markers("budget") отдаёт все.

А item.get_closest_marker("budget") отдаёт только одну — ближайшую к функции, то есть нижний декоратор (в выводе это (100,), хотя сверху написано 500). Разница неочевидная и стоит дорого: возьмёшь get_closest_marker там, где нужны все метки, — часть проверок просто не выполнится, и тест будет зелёным зря.

parametrize с ids и marks#

%%writefile /tmp/test_parametrize_advanced.py
import pytest


def divide(a: int, b: int) -> float:
    return a / b


@pytest.mark.parametrize("a,b,expected", [
    pytest.param(10, 2, 5.0, id="int-int"),
    pytest.param(10, 3, 3.33, id="int-frac", marks=pytest.mark.xfail),
    pytest.param(-6, 2, -3.0, id="neg"),
])
def test_divide(a: int, b: int, expected: float) -> None:
    assert abs(divide(a, b) - expected) < 0.001


# То же самое, но имена кейсов заданы списком на весь набор
@pytest.mark.parametrize(
    "a,b,expected",
    [(10, 2, 5.0), (9, 3, 3.0), (-6, 2, -3.0)],
    ids=["exact", "whole", "negative"],
)
def test_divide_ids(a: int, b: int, expected: float) -> None:
    assert divide(a, b) == pytest.approx(expected)
Writing /tmp/test_parametrize_advanced.py
!uv run pytest /tmp/test_parametrize_advanced.py -v
============================= test session starts ==============================
platform darwin -- Python 3.14.8, pytest-9.1.1, pluggy-1.6.0 -- /Users/chopik/advanced-python-ami-lectures-2026/course-content/.venv/bin/python3
cachedir: .pytest_cache
hypothesis profile 'default'
rootdir: /tmp
plugins: mock-3.15.1, cov-7.1.0, xdist-3.8.0, asyncio-1.4.0, anyio-4.14.2, hypothesis-6.165.2
asyncio: mode=Mode.STRICT, debug=False, asyncio_default_fixture_loop_scope=None, asyncio_default_test_loop_scope=function
collecting ... 
collected 6 items                                                              

../../../../../../tmp/test_parametrize_advanced.py::test_divide[int-int] PASSED [ 16%]
../../../../../../tmp/test_parametrize_advanced.py::test_divide[int-frac] XFAIL [ 33%]
../../../../../../tmp/test_parametrize_advanced.py::test_divide[neg] PASSED [ 50%]
../../../../../../tmp/test_parametrize_advanced.py::test_divide_ids[exact] PASSED [ 66%]
../../../../../../tmp/test_parametrize_advanced.py::test_divide_ids[whole] PASSED [ 83%]
../../../../../../tmp/test_parametrize_advanced.py::test_divide_ids[negative] PASSED [100%]
========================= 5 passed, 1 xfailed in 0.09s =========================

Два способа назвать кейсы. id= у pytest.param — имя рядом с самими данными; ids=[...] у parametrize — список имён на весь набор, по порядку. В выводе они неразличимы: test_divide[int-frac] и test_divide_ids[whole], и по любому из них запускается один кейс.

marks= работает только в первом виде — метка вешается на конкретный pytest.param: int-frac ожидаемо не сходится с точностью 0.001 и даёт XFAIL, прогон остаётся зелёным.

Плагины#

Ключевые:

  • pytest-mock — обёртка над unittest.mock, даёт фикстуру mocker.

  • pytest-xdist — параллельный запуск (-n auto). Он заметно меняет поведение фикстур с длинным scope — это на семинаре.

  • pytest-cov — coverage.

  • pytest-benchmark — микробенчмарки и отлов регрессий по скорости; разберём дальше в курсе, вместе с профилированием.

  • pytest-timeout — таймауты, чтобы зависший тест не блокировал CI.

  • pytest-asyncio — тесты для async-функций (async def test_...) — подробно дальше в курсе, там, где asyncio.

Термин: регрессия

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

Термин: профилирование

Измерение того, куда программа тратит время (или память): какие функции вызывались, сколько раз, сколько заняли. Бенчмарк отвечает на вопрос «сколько всего» и даёт одно число; профилировщик отвечает «где именно» и даёт разбивку по коду. Первое говорит, что стало хуже, второе — из-за чего.

Пример pytest-mock:

%%writefile /tmp/test_mock.py
from collections.abc import Callable

from pytest_mock import MockerFixture


def get_user_age(fetch_user: Callable[[int], dict[str, int]], user_id: int) -> int:
    data = fetch_user(user_id)
    return data["age"]


def test_get_user_age(mocker: MockerFixture) -> None:
    fake_fetch = mocker.Mock(return_value={"age": 30})
    age = get_user_age(fake_fetch, 42)
    assert age == 30
    fake_fetch.assert_called_once_with(42)
Writing /tmp/test_mock.py
!uv run pytest /tmp/test_mock.py -v 2>&1 | head -20
============================= test session starts ==============================
platform darwin -- Python 3.14.8, pytest-9.1.1, pluggy-1.6.0 -- /Users/chopik/advanced-python-ami-lectures-2026/course-content/.venv/bin/python3
cachedir: .pytest_cache
hypothesis profile 'default'
rootdir: /tmp
plugins: mock-3.15.1, cov-7.1.0, xdist-3.8.0, asyncio-1.4.0, anyio-4.14.2, hypothesis-6.165.2
asyncio: mode=Mode.STRICT, debug=False, asyncio_default_fixture_loop_scope=None, asyncio_default_test_loop_scope=function
collecting ... collected 1 item
../../../../../../tmp/test_mock.py::test_get_user_age PASSED             [100%]

============================== 1 passed in 0.07s ===============================

Обрати внимание: pytest-mock мы нигде не подключали — поставили пакет, и фикстура mocker просто оказалась в тесте. Установленный плагин pytest находит сам, по записи в метаданных пакета; как выглядит эта запись — на семинаре, когда будем паковать свой плагин.

Объявлять приходится только то, что пакетом не поставлено: например модуль с общими фикстурами, который просто лежит в репозитории. Такой подключают из conftest.py списком pytest_plugins:

# conftest.py на верху репо
pytest_plugins = ["my_project.fixtures.files"]

А сам conftest.py — тоже плагин, только локальный: грузится без объявления и действует лишь внутри своего каталога.

Плагин не обязан жить в conftest.py. Его можно упаковать в wheel, и тогда pytest найдёт его сам, без единой строчки подключения, — по записи в метаданных пакета. Плюс к этому у фикстуры и хука есть штатный способ обменяться данными, Stash. И то и другое понадобится в задаче — разбираем на семинаре.

2. Coverage: что мерить, что не мерить#

Что такое coverage. Процент строк кода, покрытых тестами. Инструмент: coverage.py (или pytest-cov как обёртка).

Три метрики:

  • Line coverage — сколько строк выполнилось.

  • Branch coverage — каждый if и for — обе ветки покрыты.

  • Path coverage — все комбинации веток.

Branch coverage строже: считает не строки, а переходы между ними. Классическая дыра line coverage — if без else: тест заходит внутрь, все строки выполнены, line рисует 100%, а путь «условие ложно» не проверен ни разу — отдельной строки под него нет, пометить нечего. --cov-branch такой переход видит и пишет явно (2->4 в колонке Missing). Включай всегда — стоит дёшево, ловит целый класс дыр.

Пример:

%%writefile /tmp/mylib.py
def clamp(x: int, lo: int, hi: int) -> int:
    if x < lo:
        return lo
    if x > hi:
        return hi
    return x
Writing /tmp/mylib.py
%%writefile /tmp/test_mylib.py
from mylib import clamp


def test_clamp_low() -> None:
    assert clamp(-5, 0, 10) == 0


def test_clamp_mid() -> None:
    assert clamp(5, 0, 10) == 5
Writing /tmp/test_mylib.py
!uv run pytest /tmp/test_mylib.py --cov=mylib --cov-branch --cov-report=term-missing 2>&1 | tail -15
plugins: mock-3.15.1, cov-7.1.0, xdist-3.8.0, asyncio-1.4.0, anyio-4.14.2, hypothesis-6.165.2
asyncio: mode=Mode.STRICT, debug=False, asyncio_default_fixture_loop_scope=None, asyncio_default_test_loop_scope=function
collected 2 items

../../../../../../tmp/test_mylib.py ..                                   [100%]

================================ tests coverage ================================
_______________ coverage: platform darwin, python 3.14.8-final-0 _______________

Name                    Stmts   Miss Branch BrPart  Cover   Missing
-------------------------------------------------------------------
/private/tmp/mylib.py       6      1      4      1    80%   5
-------------------------------------------------------------------
TOTAL                       6      1      4      1    80%
============================== 2 passed in 0.08s ===============================

В колонке Missing — номера строк, до которых тесты не добрались. Здесь это строка 5, то есть return hi: верхнюю границу мы не проверили ни разу. Соседние колонки: Stmts — сколько всего строк с кодом, Miss — сколько из них пропущено. Branch — сколько всего переходов по ветвлениям, BrPart — сколько ветвлений прошли только в одну сторону. Обрати внимание: ветвление внутри кода, который не выполнялся вовсе, в BrPart не попадёт — его строки уйдут в Miss.

А вот случай, ради которого --cov-branch и включают: if без else, и тест заходит внутрь.

%%writefile /tmp/greeting.py
def greeting(name: str) -> str:
    if not name:
        name = "аноним"
    return f"Привет, {name}!"
Writing /tmp/greeting.py
%%writefile /tmp/test_greeting.py
from greeting import greeting


def test_empty_name() -> None:
    assert greeting("") == "Привет, аноним!"
Writing /tmp/test_greeting.py
!uv run pytest /tmp/test_greeting.py --cov=greeting --cov-report=term-missing 2>&1 | tail -8
_______________ coverage: platform darwin, python 3.14.8-final-0 _______________

Name                       Stmts   Miss  Cover   Missing
--------------------------------------------------------
/private/tmp/greeting.py       4      0   100%
--------------------------------------------------------
TOTAL                          4      0   100%
============================== 1 passed in 0.08s ===============================

100%, пропущенных строк нет — и это правда: все четыре строки выполнились. Только вариант «имя не пустое» не проверен ни разу. Добавим --cov-branch:

!uv run pytest /tmp/test_greeting.py --cov=greeting --cov-branch --cov-report=term-missing 2>&1 | tail -8
_______________ coverage: platform darwin, python 3.14.8-final-0 _______________

Name                       Stmts   Miss Branch BrPart  Cover   Missing
----------------------------------------------------------------------
/private/tmp/greeting.py       4      0      2      1    83%   2->4
----------------------------------------------------------------------
TOTAL                          4      0      2      1    83%
============================== 1 passed in 0.09s ===============================

83% и 2->4 в Missing: переход со строки 2 сразу на строку 4, минуя тело if, не случился ни разу. Отдельной строки под него нет, поэтому line coverage его и не заметил.

Читать такие отчёты в консоли быстро надоедает: номера строк надо сопоставлять с кодом глазами. Для этого есть HTML-отчёт — --cov-report=html:

!uv run pytest /tmp/test_greeting.py --cov=greeting --cov-branch --cov-report=html:/tmp/htmlcov 2>&1 | tail -3
Coverage HTML written to dir /tmp/htmlcov
============================== 1 passed in 0.11s ===============================
!ls /tmp/htmlcov
class_index.html                     keybd_closed_cb_900cfef5.png
coverage_html_cb_dd2e7eb5.js         status.json
favicon_32_cb_c827f16f.png           style_cb_4667309f.css
function_index.html                  z_c17dabf9b5af4c98_greeting_py.html
index.html

index.html открывается обычным браузером — там сводка по файлам и раскрашенный исходник.

Правила coverage:

  • 100% != хорошие тесты. Можно покрыть все строки бесполезными ассертами.

  • branch > line — включай --cov-branch всегда.

  • Не гоняйся за 100%. Иногда 85% с хорошими тестами лучше, чем 100% с шаблонными.

  • Используй как сигнал: резкое падение % при изменении кода — насторожись.

Про TDD (и почему он тут)#

Отдельная культура — писать тест до реализации (Test-Driven Development). Идея: тест сначала краснеет, потом ты пишешь минимум кода, чтобы он зазеленел, потом рефакторишь. Роберт Мартин («Uncle Bob») формализовал это как три закона TDD — см. его пост The Cycles of TDD на Clean Coder Blog. Оригинальная книга — Kent Beck, Test-Driven Development: By Example (2002); практическая — Роберт Мартин, Clean Code (2008) и Clean Coder (2011).


3. logging#

Зачем логгер, а не print#

print пишет всегда, всем и в одно место. Этого хватает, пока код твой и запускается руками. Дальше выясняется, что вывод нужен разный: разработчику — подробный, в проде — только ошибки, тестам — никакой, а куда писать (консоль, файл) зависит от того, где код запустили.

Логгер разделяет два решения, которые print склеивает в одно: что записать решает автор кода, что из этого показать и куда — тот, кто его запускает. Библиотека пишет logger.info(...) и ничего не знает про stdout и файлы; приложение один раз настраивает порог и назначение в точке входа, не трогая чужой код.

%%writefile /tmp/noisy_lib.py
import logging

logger = logging.getLogger(__name__)


def fetch(url: str) -> str:
    print(f"[print] качаю {url}")
    logger.info("качаю %s", url)
    return "ok"
Writing /tmp/noisy_lib.py

Приложение, которое просто зовёт библиотеку и ничего не настраивает:

%%writefile /tmp/app_plain.py
import noisy_lib

noisy_lib.fetch("https://example.com")
Writing /tmp/app_plain.py
!uv run python /tmp/app_plain.py 2>&1
[print] качаю https://example.com

Тот же вызов, но теперь приложение включило логи:

%%writefile /tmp/app_logging.py
import logging

import noisy_lib

logging.basicConfig(level=logging.INFO, format="[%(levelname)s] %(name)s: %(message)s")

noisy_lib.fetch("https://example.com")
Writing /tmp/app_logging.py
!uv run python /tmp/app_logging.py 2>&1
[print] качаю https://example.com
[INFO] noisy_lib: качаю https://example.com

print вылез оба раза — отключить его снаружи нельзя никак, только править чужой исходник. logger.info в первом случае промолчал, во втором сказал то же самое, но с уровнем, именем модуля и в нужном формате. Автор библиотеки не выбирал ни то, ни другое — выбрал тот, кто запускает.

Плюс к этому логгер бесплатно кладёт в запись время, уровень, модуль, строку и поток — с print это пишется руками в каждом вызове и всё равно разъезжается.

Базовое использование#

%%writefile /tmp/logging_basic.py
import logging

# Настраиваем минимальную конфигурацию
logging.basicConfig(
    level=logging.DEBUG,
    format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
)

logger = logging.getLogger(__name__)

logger.debug("debug msg")
logger.info("info msg")
logger.warning("warning msg")
logger.error("error msg")
Writing /tmp/logging_basic.py
!uv run python /tmp/logging_basic.py 2>&1
2026-11-08 20:57:15,524 [DEBUG] __main__: debug msg
2026-11-08 20:57:15,524 [INFO] __main__: info msg
2026-11-08 20:57:15,524 [WARNING] __main__: warning msg
2026-11-08 20:57:15,524 [ERROR] __main__: error msg

Отдельно про корневой логгер (root). Это логгер без имени — тот, что возвращает logging.getLogger() без аргумента. Он предок всех остальных, и модульные функции logging.info(...), logging.warning(...) пишут именно в него.

%%writefile /tmp/root_demo.py
import logging

root = logging.getLogger()  # без имени — это и есть корневой
app = logging.getLogger("myapp")

print("имя корневого      :", repr(root.name), flush=True)
print("родитель myapp     :", app.parent is root, flush=True)
print("getLogger() дважды :", logging.getLogger() is root, flush=True)

logging.basicConfig(level=logging.INFO, format="[%(levelname)s] %(name)s: %(message)s")

logging.info("модульная функция пишет прямо в корневой")
app.info("а это myapp — но печатает его хендлер корневого")
Writing /tmp/root_demo.py
!uv run python /tmp/root_demo.py 2>&1
имя корневого      : 'root'
родитель myapp     : True
getLogger() дважды : True
[INFO] root: модульная функция пишет прямо в корневой
[INFO] myapp: а это myapp — но печатает его хендлер корневого

Настраивают почти всегда именно корневой: хендлер, повешенный на него, ловит записи всех логгеров приложения и библиотек разом. А писать в него не надо — по имени root в логе не понять, кто это сказал.

Правила:

  • Всегда logger = getLogger(__name__), никогда не корневой. Настраивать корневой (через basicConfig/dictConfig) — можно и нужно, но только в точке входа приложения; писать в него — нет.

  • Никогда print в коде библиотеки.

  • Уровень — строго по семантике (см. ниже).

Уровни: когда какой#

Уровней пять, а методов на них шесть: logger.debug(), .info(), .warning(), .error(), .critical() и .exception() (последний — особый, разберём ниже). Плюс есть logger.log(level, ...), который принимает уровень аргументом, — он нужен, когда уровень вычисляется. Сами уровни, от низшего к высшему:

Уровень

Числовое

Когда используется

DEBUG

10

Подробности, интересные только при диагностике проблем.

INFO

20

Для подтверждения того, что всё работает как запланировано.

WARNING

30

Случилось неожиданное, или поломка возможна в ближайшем будущем — но программа всё ещё работает как задумано.

ERROR

40

Для серьёзных ошибок, из-за которых программа теряет часть функциональности.

CRITICAL

50

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

Стандартный уровень (если ничего не настраивать) — WARNING, и порог этот стоит в двух местах: на самом логгере и на хендлере. Одного logger.setLevel(DEBUG) поэтому мало — без настроенных хендлеров запись уходит в logging.lastResort, а у него уровень тоже WARNING, и DEBUG молча пропадёт. Рабочий способ — basicConfig(level=...), он ставит хендлер и поднимает уровень root разом.

%%writefile /tmp/logging_levels.py
import logging

logging.basicConfig(format="[%(levelname)s] %(message)s")
logger = logging.getLogger(__name__)

logger.debug("не увидим — уровень root по умолчанию WARNING")
logger.warning("увидим")

logger.log(logging.INFO, "через logger.log()")

# Свои числовые уровни: между стандартными или ниже всех.
TRACE = 5
logging.addLevelName(TRACE, "TRACE")
logger.setLevel(TRACE)
logger.log(TRACE, "ниже DEBUG")
Writing /tmp/logging_levels.py
!uv run python /tmp/logging_levels.py 2>&1
[WARNING] увидим
[TRACE] ниже DEBUG

logger.exception() vs logger.error(exc)#

logger.exception(msg) — то же, что logger.error(msg, exc_info=True). Добавляет traceback текущего активного исключения. Вызывать только внутри except — иначе exc_info пустой.

%%writefile /tmp/logging_exception.py
import logging

logging.basicConfig(format="[%(levelname)s] %(message)s")
logger = logging.getLogger(__name__)


def divide(a: int, b: int) -> float | None:
    try:
        return a / b
    except ZeroDivisionError:
        # ПЛОХО: только сообщение, traceback теряется, отладка мучительна.
        # logger.error(f"division failed: {exc}")

        # ХОРОШО: сообщение + полный traceback + тип исключения.
        logger.exception("division failed")
        return None


divide(10, 0)
Writing /tmp/logging_exception.py
!uv run python /tmp/logging_exception.py 2>&1
[ERROR] division failed
Traceback (most recent call last):
  File "/tmp/logging_exception.py", line 9, in divide
    return a / b
           ~~^~~
ZeroDivisionError: division by zero

str(exc) теряет контекст кадра: где именно упало и какие там были переменные. Разбирать поломку потом придётся по логу, воспроизвести её может и не получиться — а logger.error(f"failed: {exc}") даёт «failed: division by zero» без единой подсказки, где искать. logger.exception даёт полный traceback.

Иерархия логгеров#

Логгеры образуют дерево через . в имени. myapp — родитель myapp.api, тот — родитель myapp.api.client.

%%writefile /tmp/logging_hierarchy.py
import logging

logging.basicConfig(level=logging.INFO, format="%(name)s: %(message)s")

app = logging.getLogger("myapp")
api = logging.getLogger("myapp.api")
client = logging.getLogger("myapp.api.client")

# Настраиваем вершину поддерева myapp (настоящий root logger — это
# logging.getLogger() без имени, его тут трогает только basicConfig)
app.setLevel(logging.WARNING)

# Дочерние наследуют: своего уровня у них нет, берётся ближайший заданный выше
app.info("app info")         # < WARNING, пропускается
api.warning("api warning")        # OK
client.error("client error")      # OK
Writing /tmp/logging_hierarchy.py
!uv run python /tmp/logging_hierarchy.py 2>&1
myapp.api: api warning
myapp.api.client: client error

Handlers, Filters, Formatters#

Термин: LogRecord

Одна запись в логе как объект. Логгер не печатает строку сразу — он собирает LogRecord со всем контекстом (сообщение, уровень, имя логгера, файл, строка, время, поток) и передаёт дальше по конвейеру. Строкой запись становится только в самом конце, в Formatter.

Поймаем такую запись своим хендлером и посмотрим, что внутри:

import logging

captured: list[logging.LogRecord] = []


class Capture(logging.Handler):
    def emit(self, record: logging.LogRecord) -> None:
        captured.append(record)


logger = logging.getLogger("myapp.api")
logger.setLevel(logging.INFO)
logger.addHandler(Capture())

logger.warning("slow request: %s", "GET /users")

rec = captured[0]
print("msg       :", repr(rec.msg), "| args:", rec.args)
print("getMessage:", rec.getMessage())
print("поля      :", sorted(vars(rec)))
msg       : 'slow request: %s' | args: ('GET /users',)
getMessage: slow request: GET /users
поля      : ['args', 'created', 'exc_info', 'exc_text', 'filename', 'funcName', 'levelname', 'levelno', 'lineno', 'module', 'msecs', 'msg', 'name', 'pathname', 'process', 'processName', 'relativeCreated', 'stack_info', 'taskName', 'thread', 'threadName']

Два вывода отсюда. Первый: подстановка аргументов отложена — в записи лежат отдельно шаблон msg и args, а склеиваются они только в getMessage(). Именно поэтому logger.info("user %s", uid) дешевле f-строки: если запись до хендлера не дойдёт, склейки не будет вовсе.

Второй: имена в строке формата — это имена атрибутов записи. %(levelname)s и %(name)s берут rec.levelname и rec.name напрямую. Но не все: в списке полей выше нет ни message, ни asctime — их создаёт уже Formatter, из msg/args и из created соответственно. Свои поля тоже можно класть — через logger.info(..., extra={"user_id": 42}).

Логика конвейера:

logger.info("...")
  1. logger.isEnabledFor(INFO) -- уровень этого логгера (унаследованный,
     если свой не задан). Не прошло -> выходим, LogRecord даже не создаётся
  2. создаётся LogRecord
  3. фильтры самого логгера
  4. идём по цепочке "логгер -> его предки" и для каждого его handlers:
       handler.level        -- уровень предка НЕ проверяется, только хендлера
       фильтры хендлера
       Formatter -> строка
       handler.emit(record) -- пишет в stderr/файл/сеть
     подъём по предкам прекращается, если у логгера propagate = False

Handler — куда пишем (StreamHandler, FileHandler, RotatingFileHandler, SysLogHandler, …). StreamHandler по умолчанию пишет в stderr — поэтому в ячейках этого раздела стоит 2>&1.

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

Formatter — как из LogRecord сделать текст.

Термин: propagate

Флаг логгера (по умолчанию True): отдавать ли запись хендлерам предков. Именно из-за него myapp.api печатается через хендлер, настроенный на root, хотя своих хендлеров у него нет. Ставят False, когда у логгера есть собственный вывод и дублировать его в общий не надо, — как в dictConfig ниже.

dictConfig — правильный способ#

Не собирать логгеры императивно в коде. Один раз на старте приложения:

%%writefile /tmp/logging_dictconfig.py
import logging.config

CONFIG = {
    # Версия схемы конфига. Всегда 1, других значений пока не бывает.
    "version": 1,
    # True (это значение по умолчанию!) выключил бы все созданные до вызова
    # логгеры, кроме перечисленных ниже и их потомков. Библиотеки заводят свои
    # на импорте, и в нашем конфиге их нет, — значит выключило бы. Поэтому False.
    "disable_existing_loggers": False,
    # Форматтеры — рецепты, как превратить запись в строку. Объявляем их
    # с именами, чтобы ниже сослаться по имени.
    "formatters": {
        "verbose": {
            # %-шаблон по атрибутам LogRecord
            "format": "%(asctime)s [%(levelname)s] %(name)s: %(message)s",
        },
        "json": {
            "format": '{"time": "%(asctime)s", "level": "%(levelname)s", "logger": "%(name)s", "msg": "%(message)s"}',
        },
    },
    # Хендлеры — куда писать. Тоже именованные.
    "handlers": {
        "console": {
            "class": "logging.StreamHandler",  # что импортировать и создать
            "level": "INFO",                   # свой порог, поверх порога логгера
            "formatter": "verbose",            # имя из formatters выше
        },
        "file": {
            "class": "logging.FileHandler",
            # всё лишнее уходит в конструктор хендлера как аргументы
            "filename": "/tmp/app.log",
            "mode": "w",          # по умолчанию "a" — файл рос бы между прогонами
            "level": "DEBUG",
            "formatter": "json",
        },
    },
    # Настройки конкретных логгеров, по имени. Корневой настраивается
    # отдельным ключом "root" рядом с "loggers".
    "loggers": {
        "myapp": {
            "level": "DEBUG",                 # порог самого логгера
            "handlers": ["console", "file"],  # имена из handlers выше
            "propagate": False,               # запись не уходит наверх, к root
        },
    },
}

logging.config.dictConfig(CONFIG)

log = logging.getLogger("myapp")
log.debug("only in file")
log.info("in console AND file")
Writing /tmp/logging_dictconfig.py
!uv run python /tmp/logging_dictconfig.py 2>&1
2026-11-08 20:57:16,488 [INFO] myapp: in console AND file

В консоль ушло только то, что INFO и выше. А в файле — оба сообщения, и в другом формате:

!cat /tmp/app.log
{"time": "2026-11-08 20:57:16,488", "level": "DEBUG", "logger": "myapp", "msg": "only in file"}
{"time": "2026-11-08 20:57:16,488", "level": "INFO", "logger": "myapp", "msg": "in console AND file"}

Такой «json» — не JSON

Формат собран %-подстановкой, которая ничего не экранирует. Сообщение с кавычкой или переводом строки (log.info(„user said «hi»“)) порвёт результат, и парсер на той стороне подавится. Для настоящего JSON нужен форматтер, который сериализует поля, — об этом следующий раздел.


4. structlog — structured logs#

Проблема с текстовыми логами#

logger.info("user 42 did login from 1.2.3.4") — читаемо человеком, но:

  • Не парсится инструментами.

  • Не отфильтровать по полю: «покажи всё, что было с user 42».

  • Не агрегируется («сколько раз user 42 логинился»).

Решение — структурированные логи (JSON или key=value), где поля явные.

structlog: простой пример#

%%writefile /tmp/structlog_basic.py
import structlog

structlog.configure(
    processors=[
        structlog.processors.add_log_level,
        structlog.processors.TimeStamper(fmt="iso"),
        structlog.processors.JSONRenderer(),
    ],
)

log = structlog.get_logger()

log.info("user_login", user_id=42, ip="1.2.3.4")
log.warning("slow_request", path="/users", duration_ms=1200)
Writing /tmp/structlog_basic.py
!uv run python /tmp/structlog_basic.py 2>&1
{"user_id": 42, "ip": "1.2.3.4", "event": "user_login", "level": "info", "timestamp": "2026-11-08T17:57:16.869547Z"}
{"path": "/users", "duration_ms": 1200, "event": "slow_request", "level": "warning", "timestamp": "2026-11-08T17:57:16.869764Z"}

Каждая строка — валидный JSON: такие логи уже можно не читать глазами, а разбирать программой.

Context binding#

%%writefile /tmp/structlog_bind.py
import structlog

structlog.configure(processors=[
    structlog.processors.add_log_level,
    structlog.processors.JSONRenderer(),
])

log = structlog.get_logger()

# bind — привязывает контекст к логгеру, копия
request_log = log.bind(request_id="req-abc", user_id=42)

request_log.info("processing_start")
request_log.info("cache_miss", key="user:42")
request_log.info("processing_done", duration_ms=15)
Writing /tmp/structlog_bind.py
!uv run python /tmp/structlog_bind.py 2>&1
{"request_id": "req-abc", "user_id": 42, "event": "processing_start", "level": "info"}
{"request_id": "req-abc", "user_id": 42, "key": "user:42", "event": "cache_miss", "level": "info"}
{"request_id": "req-abc", "user_id": 42, "duration_ms": 15, "event": "processing_done", "level": "info"}

Все три сообщения автоматически содержат request_id и user_id. Не нужно передавать их вручную в каждый log.info.

Логгер с привязанным контекстом всё равно надо передавать дальше руками. От этого избавляет structlog.contextvars — значение кладётся один раз и само подхватывается во всех записях ниже по стеку. Разбираем на семинаре.

Разбираем на семинаре#

Дальше — то, без чего основная нить обходится, но что стоит увидеть: обёртки вокруг хуков, scope во всех четырёх видах, параллельный прогон и его влияние на фикстуры, Stash и упаковка плагина в wheel, контекст логов через contextvars. Первое, Stash и wheel понадобятся в задаче. Читать можно и сейчас, и когда упрёшься.

@pytest.hookimpl — обёртки вокруг хуков#

Один и тот же хук обычно объявлен не в одном месте: у тебя в conftest.py, в conftest.py выше по дереву, в каждом установленном плагине и в самом pytest. Все эти реализации выстраиваются в ряд, и pytest зовёт их по очереди. Декоратор @pytest.hookimpl нужен, когда важно, где в этом ряду стоишь ты.

tryfirst=True и trylast=True просто двигают тебя в начало или в конец ряда.

wrapper=True — другое. Твоя функция перестаёт быть одним из вызовов в ряду и становится обёрткой вокруг всех остальных. Пишут её генератором:

  • код до yield выполняется перед остальными реализациями хука;

  • на yield управление уходит к ним — для pytest_runtest_call это и есть запуск тела теста;

  • код после yield выполняется после, когда уже известно, чем дело кончилось.

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

Имя pytest_runtest_call не выдумано: как у любого хука, оно зарезервировано и означает конкретный момент прогона — фазу, в которой выполняется тело теста. Рядом живут pytest_runtest_setup (тот самый, что выше пропускал тесты) и pytest_runtest_teardown, а pytest_runtest_protocol охватывает все три фазы разом. То есть выбираешь не имя, а момент — имя следует за ним.

И wrapper=True работает с любым хуком, не только с pytest_runtest_call. Ниже обёрнуты сразу два: pytest_runtest_setup покажет изнутри, как отрабатывают фикстуры, а pytest_runtest_call — как выполняется тело теста.

%%writefile /tmp/hookimpl_demo/conftest.py
import pytest


@pytest.hookimpl(wrapper=True)
def pytest_runtest_setup(item: pytest.Item):
    print(f"[setup] готовим {item.name}")
    result = yield  # здесь отрабатывают фикстуры
    print(f"[setup] {item.name} готов")
    return result


@pytest.hookimpl(wrapper=True)
def pytest_runtest_call(item: pytest.Item):
    print(f"[call] до {item.name}")
    try:
        result = yield  # здесь выполняется тело теста
    except Exception as exc:
        print(f"[call] {item.name} упал: {type(exc).__name__}")
        raise
    print(f"[call] {item.name} прошёл")
    return result
Writing /tmp/hookimpl_demo/conftest.py
%%writefile /tmp/hookimpl_demo/test_two.py
import pytest


@pytest.fixture
def prepared() -> str:
    print("  [fixture] готовлю данные")
    return "ok"


def test_ok(prepared: str) -> None:
    assert prepared == "ok"


def test_broken() -> None:
    assert 2 + 2 == 5
Writing /tmp/hookimpl_demo/test_two.py
!uv run pytest /tmp/hookimpl_demo -q -s || true
[setup] готовим test_ok
  [fixture] готовлю данные
[setup] test_ok готов
[call] до test_ok
[call] test_ok прошёл
.[setup] готовим test_broken
[setup] test_broken готов
[call] до test_broken
[call] test_broken упал: AssertionError
F
=================================== FAILURES ===================================
_________________________________ test_broken __________________________________

    def test_broken() -> None:
>       assert 2 + 2 == 5
E       assert (2 + 2) == 5

/tmp/hookimpl_demo/test_two.py:15: AssertionError
=========================== short test summary info ============================
FAILED ../../../../../../tmp/hookimpl_demo/test_two.py::test_broken - assert (2 + 2) == 5
1 failed, 1 passed in 0.08s

Видно, что обёртки охватывают именно свои фазы: [fixture] напечаталось внутри [setup], а тело теста — между строчками [call]. И видно исход: test_broken пришёл в обёртку исключением — это и есть то, ради чего её берут.

Два правила. Результат обязательно вернуть (return result), иначе pytest получит None. И если перехватил исключение — пробросить дальше (raise), иначе упавший тест молча станет зелёным.

Две таких обёртки в одном файле не сделать: это обычная функция, и второе def с тем же именем просто затрёт первое. А в разных местах — сколько угодно: pytest зовёт все реализации, и обёртки вкладываются одна в другую, причём ближний к тесту conftest.py оказывается снаружи.

Положим по обёртке в два conftest.py, вложенных один в другой:

%%writefile /tmp/twohooks_demo/conftest.py
import pytest


@pytest.hookimpl(wrapper=True)
def pytest_runtest_call(item: pytest.Item):
    print("  [верхний] до")
    result = yield
    print("  [верхний] после")
    return result
Writing /tmp/twohooks_demo/conftest.py
%%writefile /tmp/twohooks_demo/sub/conftest.py
import pytest


@pytest.hookimpl(wrapper=True)
def pytest_runtest_call(item: pytest.Item):
    print("  [нижний] до")
    result = yield
    print("  [нижний] после")
    return result
Writing /tmp/twohooks_demo/sub/conftest.py
%%writefile /tmp/twohooks_demo/sub/test_x.py
def test_one() -> None:
    print("  [тест] тело")
    assert True
Writing /tmp/twohooks_demo/sub/test_x.py
!uv run pytest /tmp/twohooks_demo -q -s
  [нижний] до
  [верхний] до
  [тест] тело
  [верхний] после
  [нижний] после
.
1 passed in 0.06s

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

Scope во всех четырёх видах#

Одна фикстура на каждый scope, все autouse, чтобы не загромождать сигнатуры тестов. autouse=True означает, что фикстура применяется ко всем тестам и просить её параметром не надо.

%%writefile /tmp/test_scopes.py
import pytest


@pytest.fixture(scope="session", autouse=True)
def per_session() -> None:
    print("\n  [session]")


@pytest.fixture(scope="module", autouse=True)
def per_module() -> None:
    print("  [module]")


@pytest.fixture(scope="class", autouse=True)
def per_class() -> None:
    print("  [class]")


@pytest.fixture(autouse=True)
def per_function() -> None:
    print("  [function]")


class TestParse:
    def test_title(self) -> None:
        assert True

    def test_body(self) -> None:
        assert True


class TestRender:
    def test_html(self) -> None:
        assert True

    def test_text(self) -> None:
        assert True
Writing /tmp/test_scopes.py
!uv run pytest /tmp/test_scopes.py -v -s
============================= test session starts ==============================
platform darwin -- Python 3.14.8, pytest-9.1.1, pluggy-1.6.0 -- /Users/chopik/advanced-python-ami-lectures-2026/course-content/.venv/bin/python3
cachedir: .pytest_cache
hypothesis profile 'default'
rootdir: /tmp
plugins: mock-3.15.1, cov-7.1.0, xdist-3.8.0, asyncio-1.4.0, anyio-4.14.2, hypothesis-6.165.2
asyncio: mode=Mode.STRICT, debug=False, asyncio_default_fixture_loop_scope=None, asyncio_default_test_loop_scope=function
collecting ... 
collected 4 items                                                              

../../../../../../tmp/test_scopes.py::TestParse::test_title 
  [session]
  [module]
  [class]
  [function]
PASSED
../../../../../../tmp/test_scopes.py::TestParse::test_body   [function]
PASSED
../../../../../../tmp/test_scopes.py::TestRender::test_html   [class]
  [function]
PASSED
../../../../../../tmp/test_scopes.py::TestRender::test_text   [function]
PASSED

============================== 4 passed in 0.08s ===============================

Четыре теста в двух классах одного файла: session сработал один раз, module — один, class — два, function — четыре.

Параллельный прогон и scope#

pytest-xdist раскидывает тесты по нескольким процессам: pytest -n 2 — два воркера, -n auto — по числу ядер. Воркер — это отдельный процесс: свой импорт, своя память, своя сессия pytest. Объекты фикстур между процессами не ездят.

Отсюда поправка к разделу про scope: он означает «не чаще одного раза», а не «ровно один раз за прогон». Единица переиспользования — воркер.

%%writefile /tmp/test_xdist_scope.py
import os
import uuid

import pytest


@pytest.fixture(scope="session")
def token() -> str:
    value = uuid.uuid4().hex[:6]
    print(f"[session setup] token={value} pid={os.getpid()}")
    return value


@pytest.mark.parametrize("i", range(4))
def test_uses_token(i: int, token: str, worker_id: str) -> None:
    print(f"test{i}: worker={worker_id} pid={os.getpid()} token={token}")
Writing /tmp/test_xdist_scope.py
!uv run pytest /tmp/test_xdist_scope.py -q -rP
....                                                                     [100%]
==================================== PASSES ====================================
______________________________ test_uses_token[0] ______________________________
---------------------------- Captured stdout setup -----------------------------
[session setup] token=adda9d pid=32504
----------------------------- Captured stdout call -----------------------------
test0: worker=master pid=32504 token=adda9d
______________________________ test_uses_token[1] ______________________________
----------------------------- Captured stdout call -----------------------------
test1: worker=master pid=32504 token=adda9d
______________________________ test_uses_token[2] ______________________________
----------------------------- Captured stdout call -----------------------------
test2: worker=master pid=32504 token=adda9d
______________________________ test_uses_token[3] ______________________________
----------------------------- Captured stdout call -----------------------------
test3: worker=master pid=32504 token=adda9d
4 passed in 0.08s

Один setup, один pid, один и тот же token во всех четырёх тестах. worker_id — фикстура из xdist, без параллели она отдаёт master. Флаг -rP печатает вывод прошедших тестов, иначе pytest его прячет. Теперь то же самое в два процесса:

!uv run pytest /tmp/test_xdist_scope.py -q -n 2 -rP

bringing up nodes...

bringing up nodes...
bringing up nodes...

bringing up nodes...

bringing up nodes...
bringing up nodes...

bringing up nodes...
....                                                                     [100%]
==================================== PASSES ====================================
______________________________ test_uses_token[2] ______________________________
[gw1] darwin -- Python 3.14.8 /Users/chopik/advanced-python-ami-lectures-2026/course-content/.venv/bin/python3
---------------------------- Captured stdout setup -----------------------------
[session setup] token=383183 pid=32509
----------------------------- Captured stdout call -----------------------------
test2: worker=gw1 pid=32509 token=383183
______________________________ test_uses_token[0] ______________________________
[gw0] darwin -- Python 3.14.8 /Users/chopik/advanced-python-ami-lectures-2026/course-content/.venv/bin/python3
---------------------------- Captured stdout setup -----------------------------
[session setup] token=971c3a pid=32508
----------------------------- Captured stdout call -----------------------------
test0: worker=gw0 pid=32508 token=971c3a
______________________________ test_uses_token[3] ______________________________
[gw1] darwin -- Python 3.14.8 /Users/chopik/advanced-python-ami-lectures-2026/course-content/.venv/bin/python3
----------------------------- Captured stdout call -----------------------------
test3: worker=gw1 pid=32509 token=383183
______________________________ test_uses_token[1] ______________________________
[gw0] darwin -- Python 3.14.8 /Users/chopik/advanced-python-ami-lectures-2026/course-content/.venv/bin/python3
----------------------------- Captured stdout call -----------------------------
test1: worker=gw0 pid=32508 token=971c3a
4 passed in 0.44s

Два setup’а, два pid, два разных token — при том же scope="session". Столько же раз отработал бы и teardown.

Пока это только цена: дорогую подготовку оплачиваем не один раз, а по разу на воркер. Хуже с тестами, которые передают состояние друг другу. Вернём тот файл из раздела про scope и запустим его в два процесса:

!uv run pytest /tmp/test_scope.py -n 2 -q || true

bringing up nodes...

bringing up nodes...
bringing up nodes...

bringing up nodes...
bringing up nodes...

bringing up nodes...
bringing up nodes...
.F                                                                       [100%]
=================================== FAILURES ===================================
_______________________________ test_users_count _______________________________
[gw1] darwin -- Python 3.14.8 /Users/chopik/advanced-python-ami-lectures-2026/course-content/.venv/bin/python3

storage = {'users': []}

    def test_users_count(storage: dict[str, list[str]]) -> None:
        # Тесты делят состояние — предыдущий тест уже добавил alice
>       assert len(storage["users"]) == 1
E       assert 0 == 1
E        +  where 0 = len([])

/tmp/test_scope.py:17: AssertionError
---------------------------- Captured stdout setup -----------------------------
  [setup module] готовлю хранилище
=========================== short test summary info ============================
FAILED ../../../../../../tmp/test_scope.py::test_users_count - assert 0 == 1
1 failed, 1 passed in 0.45s

test_add_user и test_users_count уехали в разные процессы, и общего хранилища у них больше нет. Обрати внимание: xdist ничего не сломал — он вскрыл то, что и так было сломано. Тест, зависящий от соседа, падает и от запуска в одиночку, и от смены порядка; параллель просто делает это заметным.

Что с этим делать:

  • Не делить состояние между тестами. function-scope под xdist работает ровно как обычно — самый надёжный ответ.

  • --dist loadfile — весь файл целиком уезжает на один воркер. Наша пара при нём зеленеет, и module-фикстура срабатывает один раз на файл. Платим параллелизмом: воркеров занято не больше, чем файлов.

  • --dist loadscope группирует мельче: по классу, а тесты вне классов — по файлу. Гарантии «весь модуль в одном воркере» не даёт, модуль с двумя классами он разрежет.

  • Ресурс — на воркер, а не на прогон. Если нужен внешний ресурс, его делают своим у каждого процесса: worker_id в имени каталога, свой порт, свой файл.

Ровно один setup на весь параллельный прогон штатными средствами не получается; в доках xdist для этого есть рецепт с файловой блокировкой.

Stash — как фикстура и хук обмениваются данными#

Фикстура и хук смотрят на тест с разных сторон. Фикстура работает внутри: готовит окружение и может копить что-то, пока тест идёт, — записи лога, запросы, замеры. Хук работает снаружи: он видит исход, видит метки теста и может честно уронить его в фазе call.

Заменить друг друга они не могут. Хук не умеет просить фикстуру: он не участвует в DI, у него нет параметров-фикстур. А фикстура не знает, чем кончился тест, и если уронить прогон из её teardown, получится ERROR отдельной строкой, а сам тест отчитается passed.

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

item._my_plugin_start = time.perf_counter()

Работает, но так делать не стоит: имя может занять другой плагин, mypy на чужой объект ругнётся attr-defined, и что там за значение — никто не знает.

Для этого у pytest есть Stash: типизированный словарь на каждом узле (item.stash), а также на Config и Session. Ключ — объект, а не строка:

%%writefile /tmp/stash_demo/conftest.py
import time

import pytest

# Ключ создаётся один раз на уровне модуля.
STARTED_AT = pytest.StashKey[float]()


@pytest.fixture(autouse=True)
def _remember_start(request: pytest.FixtureRequest) -> None:
    request.node.stash[STARTED_AT] = time.perf_counter()


@pytest.hookimpl(wrapper=True)
def pytest_runtest_call(item: pytest.Item):
    result = yield
    started = item.stash.get(STARTED_AT, None)
    if started is not None:
        print(f"[stash] {item.name}: {time.perf_counter() - started:.3f}s")
    return result
Writing /tmp/stash_demo/conftest.py
%%writefile /tmp/stash_demo/test_slow.py
import time


def test_fast() -> None:
    assert True


def test_slow() -> None:
    time.sleep(0.2)
    assert True
Writing /tmp/stash_demo/test_slow.py
!uv run pytest /tmp/stash_demo -q -s
[stash] test_fast: 0.000s
.
[stash] test_slow: 0.204s
.
2 passed in 0.30s

Фикстура положила, хук забрал. Два свойства, ради которых это не просто словарь:

  • Ключи не сталкиваются. Два плагина заведут StashKey[float]() под одним и тем же смыслом — и не пересекутся, потому что ключи сравниваются по идентичности, а не по имени.

  • Тип привязан к ключу. StashKey[list[str]]() означает, что stash[key] для тайпчекера именно list[str], и mypy --strict поймает попытку положить туда не то.

Как плагин попадает в wheel#

conftest.py работает, пока плагин нужен одному проекту. Чтобы им пользовались другие, его пакуют в wheel — и тогда pytest_plugins прописывать не нужно вовсе: pytest находит установленные плагины сам.

Механизм — entry point группы pytest11 (см. Лекцию 1 про pyproject.toml). Посмотрим, что в нашем окружении:

from importlib.metadata import entry_points

for ep in sorted(entry_points(group="pytest11"), key=lambda e: e.name):
    print(f"{ep.name:18} -> {ep.value}")
anyio              -> anyio.pytest_plugin
asyncio            -> pytest_asyncio.plugin
hypothesispytest   -> _hypothesis_pytestplugin
pytest_cov         -> pytest_cov.plugin
pytest_mock        -> pytest_mock
xdist              -> xdist.plugin
xdist.looponfail   -> xdist.looponfail

Вот откуда берутся mocker и --cov: пакеты объявили точки входа, pytest на старте обошёл эту группу и импортировал модули. Соберём такой же плагин сами — он будет давать одну фикстуру loud.

%%writefile /tmp/pytest_loud_pkg/pytest_loud.py
import pytest


@pytest.fixture
def loud() -> str:
    return "ГРОМКО"
Writing /tmp/pytest_loud_pkg/pytest_loud.py
%%writefile /tmp/pytest_loud_pkg/pyproject.toml
[project]
name = "pytest-loud"
version = "0.1.0"
dependencies = ["pytest>=8"]

[project.entry-points.pytest11]
loud = "pytest_loud"

[tool.setuptools]
py-modules = ["pytest_loud"]

[build-system]
requires = ["setuptools>=68"]
build-backend = "setuptools.build_meta"
Writing /tmp/pytest_loud_pkg/pyproject.toml

Главная строчка — loud = "pytest_loud" в секции [project.entry-points.pytest11]. Слева от = — под каким именем плагин будет числиться у pytest; имя произвольное, оно нужно, чтобы плагин можно было назвать (например выключить через -p no:loud). Справа — что импортировать, когда плагин понадобится: модуль или пакет, откуда pytest заберёт хуки и фикстуры.

Собираем wheel:

!cd /tmp/pytest_loud_pkg && uv build --wheel --no-build-isolation 2>&1 | tail -1
or `uv pip install setuptools` into the environment and re-run with `--no-build-isolation`.

Wheel — обычный zip. Объявление из pyproject.toml при сборке превратилось в файл entry_points.txt внутри метаданных пакета, и читает его уже не наш код, а pytest на старте:

!unzip -p /tmp/pytest_loud_pkg/dist/*.whl "*/entry_points.txt"
fish: No matches for wildcard '/tmp/pytest_loud_pkg/dist/*.whl'. See `help wildcards-globbing`.
unzip -p /tmp/pytest_loud_pkg/dist/*.whl "*/entry_points.txt"
         ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~^

Ставим wheel в отдельный каталог и запускаем тест, рядом с которым нет ни conftest.py, ни импортов:

%%writefile /tmp/loud_test/test_loud.py
def test_fixture_from_wheel(loud: str) -> None:
    assert loud == "ГРОМКО"
Writing /tmp/loud_test/test_loud.py
!uv pip install --target /tmp/loud_site --no-deps /tmp/pytest_loud_pkg/dist/*.whl 2>&1 | tail -1
!PYTHONPATH=/tmp/loud_site uv run pytest /tmp/loud_test -v
fish: No matches for wildcard '/tmp/pytest_loud_pkg/dist/*.whl'. See `help wildcards-globbing`.
uv pip install --target /tmp/loud_site --no-deps /tmp/pytest_loud_pkg/dist/*.whl 2>&1 | tail -1
                                                 ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~^
============================= test session starts ==============================
platform darwin -- Python 3.14.8, pytest-9.1.1, pluggy-1.6.0 -- /Users/chopik/advanced-python-ami-lectures-2026/course-content/.venv/bin/python3
cachedir: .pytest_cache
hypothesis profile 'default'
rootdir: /tmp/loud_test
plugins: mock-3.15.1, cov-7.1.0, xdist-3.8.0, asyncio-1.4.0, anyio-4.14.2, hypothesis-6.165.2
asyncio: mode=Mode.STRICT, debug=False, asyncio_default_fixture_loop_scope=None, asyncio_default_test_loop_scope=function
collecting ... 
collected 1 item                                                               

../../../../../../tmp/loud_test/test_loud.py::test_fixture_from_wheel ERROR [100%]

==================================== ERRORS ====================================
__________________ ERROR at setup of test_fixture_from_wheel ___________________
file /tmp/loud_test/test_loud.py, line 1
  def test_fixture_from_wheel(loud: str) -> None:
E       fixture 'loud' not found
>       available fixtures: _asyncio_loop_factory, _class_scoped_runner, _function_scoped_runner, _module_scoped_runner, _package_scoped_runner, _session_scoped_runner, anyio_backend, anyio_backend_name, anyio_backend_options, cache, capfd, capfdbinary, caplog, capsys, capsysbinary, capteesys, class_mocker, cov, doctest_namespace, event_loop_policy, free_tcp_port, free_tcp_port_factory, free_udp_port, free_udp_port_factory, mocker, module_mocker, monkeypatch, no_cover, package_mocker, pytestconfig, record_property, record_testsuite_property, record_xml_attribute, recwarn, session_mocker, subtests, testrun_uid, tmp_path, tmp_path_factory, tmpdir, tmpdir_factory, unused_tcp_port, unused_tcp_port_factory, unused_udp_port, unused_udp_port_factory, worker_id
>       use 'pytest --fixtures [testpath]' for help on them.

/tmp/loud_test/test_loud.py:1
=========================== short test summary info ============================
ERROR ../../../../../../tmp/loud_test/test_loud.py::test_fixture_from_wheel
=============================== 1 error in 0.06s ===============================

Фикстура нашлась сама — ровно так же, как mocker из pytest-mock. Проверить, что подхватилось, — pytest --trace-config.

Имя пакета и имя модуля — разные вещи

По конвенции пакет зовут pytest-<i>имя</i>, а модуль внутри — pytest_<i>имя</i>. Уникальными должны быть оба имени, и совпадение каждого проходит молча. Если имя entry point займёт чужой плагин, pluggy зарегистрирует того, кто пришёл первым, а твой пропустит — фикстур из него в тестах просто не окажется. Если совпадёт имя модуля, плагины столкнутся в sys.modules, и импортирован будет опять же один.


contextvars — контекст без явной передачи#

В прошлом разделе контекст жил в логгере: log.bind(...) возвращал новый логгер, и его надо было передавать дальше руками. structlog.contextvars убирает и это — кладёшь значение один раз, и его подхватывают все записи, сделанные дальше, даже из функций, которые про контекст ничего не знают.

Держится это на модуле contextvars из stdlib (PEP 567). Думать о нём проще всего так: ContextVar выглядит как глобальная переменная, но глобальной не является. Программа нередко делает несколько дел сразу — скажем, обрабатывает несколько запросов. Обычную глобальную переменную такие дела затирают друг другу: второе записало свой request_id, и первое теперь пишет в лог чужой. У ContextVar значение своё у каждого дела: кто положил, тот и прочитает, соседи не мешают.

Как именно устроена эта «одновременность» — потоки, корутины, — разберём дальше в курсе. Пока достаточно результата: в примере ниже два запроса обрабатываются вперемешку, и request_id у каждого свой.

%%writefile /tmp/structlog_ctxvars.py
import asyncio

import structlog
from structlog.contextvars import bind_contextvars, clear_contextvars

structlog.configure(processors=[
    structlog.contextvars.merge_contextvars,
    structlog.processors.add_log_level,
    structlog.processors.JSONRenderer(),
])

log = structlog.get_logger()


async def load_profile() -> None:
    await asyncio.sleep(0)  # отдаём управление: пусть второй запрос вклинится
    log.info("profile_loaded")  # про request_id ничего не знает


async def handle_request(request_id: str, user_id: int) -> None:
    clear_contextvars()
    bind_contextvars(request_id=request_id, user_id=user_id)
    log.info("request_start")
    await load_profile()


async def main() -> None:
    await asyncio.gather(
        handle_request("req-1", 42),
        handle_request("req-2", 7),
    )


asyncio.run(main())
Writing /tmp/structlog_ctxvars.py
!uv run python /tmp/structlog_ctxvars.py 2>&1
{"event": "request_start", "user_id": 42, "request_id": "req-1", "level": "info"}
{"event": "request_start", "user_id": 7, "request_id": "req-2", "level": "info"}
{"event": "profile_loaded", "user_id": 42, "request_id": "req-1", "level": "info"}
{"event": "profile_loaded", "user_id": 7, "request_id": "req-2", "level": "info"}

Два запроса обрабатывались вперемешку, а request_id у каждого остался свой. И главное: load_profile про контекст ничего не знает и никаких аргументов не принимает — а в её записи request_id всё равно оказался.


Материалы#