Лекция 3. Тестирование и логирование#
План#
Сначала коротко — зачем тесты вообще и какими они бывают. Дальше четыре блока:
pytest — discovery, фикстуры и scope,
monkeypatch,parametrize,conftest.py, marks, плагины.Coverage — line vs branch, что мерить и что нет.
logging — уровни, иерархия, handlers/filters/formatters,
dictConfig.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, ...), который принимает уровень
аргументом, — он нужен, когда уровень вычисляется. Сами уровни, от низшего к
высшему:
Уровень |
Числовое |
Когда используется |
|---|---|---|
|
10 |
Подробности, интересные только при диагностике проблем. |
|
20 |
Для подтверждения того, что всё работает как запланировано. |
|
30 |
Случилось неожиданное, или поломка возможна в ближайшем будущем — но программа всё ещё работает как задумано. |
|
40 |
Для серьёзных ошибок, из-за которых программа теряет часть функциональности. |
|
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 всё равно оказался.