Недавно на работе передо мной возникла задача максимально быстро погрузиться в автоматизированное тестирование с ранее мной не использовавшимся фреймворком pytest. Почитав порядка десяти статей на Хабре я понял, что в каждой из статей есть много всего интересного, а чтобы системно погрузиться - необходимо идти читать документацию. Я решил, в привычной мне манере, разобраться и систематизировать самый сок для того, чтобы быстро въехать в суть и важные тонкости положив основу для дальнейшего использования. 

Всем интересующимся - добро пожаловать под кат!

Дисклеймер. Сразу же хотелось бы оставить за собой право на ошибки, а также размытые и не полные интерпретации вещей, о которых собираюсь рассказать т.к. я не являюсь профессиональным программистом и специалистом автоматизированном тестировании. Но с другой стороны, любые конструктивные замечания или исправления дадут почву для саморазвития и самокоррекции, и ваша обоснованная обратная связь будет очень ценной для меня.

Что такое pytest?

Pytest - это самый популярный фреймворк для тестирования на Python. Pytest появился, чтобы сделать тестирование в Python простым и приятным: меньше церемоний, больше читаемости и расширяемости. Он применяется везде - от библиотек и веб‑сервисов до ML‑проектов и инфраструктуры - и подходит как одиночным разработчикам, так и большим командам с CI/CD.

Первые массовые тесты в Python строились на unittest (xUnit‑подобный фреймворк из стандартной библиотеки). Он надежен, но в большей степени "церемониален": классы, наследование, методы setUp/tearDown, громоздкие assert*‑методы. Это тормозило внедрение тестов в повседневную практику: слишком много шаблонного кода ради простых проверок.

Со временем появлялись надстройки (например, nose), но им не хватило долгосрочной поддержки. Pytest предложил другой путь:

  1. Простота синтаксиса: тест - это обычная функция и обычный assert.

  2. Лучшие сообщения об ошибках: "расшифровка" выражений в assert и наглядные tracebacks.

  3. Фикстуры вместо классовой магии: декларативная система подготовки/очистки состояния без наследования.

  4. Параметризация: один тест - много входов/ожидаемых выходов.

  5. Плагины: архитектура, которую можно расширять под любые сценарии (распараллеливание, ретраи, бенчмарки, отчеты и т. д.).

Иными словами, первопричина появления - снизить порог входа и стоимость владения тестовой базой, сохранив мощь для сложных проектов.

Основные термины

Сразу приведу основные термины объясненные своим языком, поскольку они употребляются сразу же по ходу материала:

  • Assert-интроспекция - это обычная инструкция Python вида assert <условие>[, <сообщение>],   которая при падении показывает разбор выражения: левую/правую части, значения подвыражений, красивый дифф коллекций/строк.

  • Traceback - это отчёт о том, по какой цепочке функций Python дошел до места где произошло исключение.

  • Фикстуры в pytest - это функции, которые готовят ресурсы для тестов (данные, подключения, конфиги) и отдают его тесту как аргумент по имени и затем корректно убирают. Это удобный способ интегрировать в тест необходимые зависимости.

  • Mark-функции - это ярлыки для тестов в виде функций-декораторов. Ими помечают функции тестов, чтобы исключать/выбирать при запуске, условно пропускать или ожидать падения, группировать и т.д.

  • Хуки - это специальный-функции, с помощью которых можно настраивать поведение pytest без изменения самих тестов. Рассмотрим позже на примерах. 

  • Моки - это подменные объекты, которые имитируют поведение внешних зависимостей в тестах: БД, сеть, время, файл-система и т.п. С моками вы проверяете как ваш код взаимодействует с зависимостью (какие функции вызвал, с какими аргументами), не трогая реальный мир.

В чем он лучше других?

  1. Отсутствие избыточного кода, как это в unittest. Код тестов pytest состоит из коротких самодокументирующихся функций.

  2. Достаточно простая подготовка окружения для работы, с явными повторно используемыми фикстурами с зависимостями. 

  3. Детально подсвечиваются различия, контекстов и значений assert для более полной диагностики падений.

  4. Через @pytest.mark.parametrize можно использовать один и тот же тест с разными данными без дублирования. 

  5. Есть возможность делать параллельные запуски тестов, делать ретраи, таймауты и бенчмарки.

  6. Единая, минималистичная идиома, которую легко читать и поддерживать. 

  7. Огромное количество разнообразных плагинов.

Какой уровень входа?

Данный фреймворк вполне себе подходит для новичков, потому что для старта нужно знать лишь базовые функции Python и assert. Первые тесты пишутся на обычных assert - без классов и SetUp/tearDown. Необходимо понимать, плюсом к этому, что такое виртуальное окружение и установка пакетов из индекса pip. 

То есть никаких особых знаний для его использования не требуется. Едем дальше. 

Как начать работу с pytest?

Первым делом необходимо установить pytest. Я в работе использую uv, т.к. он существенно быстрее простого pip. Рекомендую начать пользоваться им и вам.

Сначала установим uv:

pip install uv
uv --version

Создаем проект и виртуальное окружение:

mkdir pytest_tests && cd pytest_tests
uv init                       	# создаст pyproject.toml и основу проекта
uv venv --python 3.12         	# локальная .venv с нужной версией Python

# (можно зафиксировать нужную версию версию для проекта)
uv python pin 3.12            	# создаст/обновит .python-version

uv автоматически находит .venv и использует её в следующих командах. Активировать окружение (не обязательно при использовании uv run) можно классическим способом:

source .venv/bin/activate

Добавим pytest как dev-зависимость:

uv add --dev pytest

Это добавит pytest в секцию зависимостей для разработчика и установит его в вашу .venv. По умолчанию uv синхронизирует dev-группу. 

Далее настроим pytest в pyproject.toml. Добавьте секцию конфигурации (pytest читает её из tool.pytest.ini_options) в созданный файл:

[tool.pytest.ini_options]
minversion = "6.0"
testpaths = ["tests"]
python_files = test_*.py *_test.py
python_functions = test_*
addopts = [
  "-vv",				        # подробный вывод хода теста
  "--import-mode=importlib"  	# меньше проблем с импортами
]

Общая структура тестового окружения

Для того, чтобы тесты интегрировать в существующий проект необходимо соблюсти примерно вот такую структуру директорий:

project/
├─ src/ # Ваш исходный код (опционально)
├─ app/ # Или пакет приложения (Flask, etc.)
├─ tests/ # Все тесты здесь
│ ├─ test_smoke.py
│ ├─ test_api.py
│ └─ conftest.py # Общие фикстуры/хуки для tests/
└─ pytest.ini # Конфигурация pytest

Сразу стоит сказать, что существуют правила обнаружения тестов: файлы test_*.py или _test.py, функции test_, классы Test* без init.py.

Все очень просто. Идём дальше.

Напишем первый тест

Переходим в директорию для тестов:

cd tests/

И сделаем первый тест test_example.py:

import pytest 

def add(a, b): 
    return a + b

def test_math():
    assert add(2, 3) == 5

Запустим тест через рекомендуемый способ, то есть через uv, чтобы гарантированно использовать среду проекта:

uv run pytest -vv tests/test_example.py

Или можно запустить с прямым указанием версии Python, кейс достаточно частый для проверки совместимости кода между версиями:

# или явно указать версию Python:
uv run --python 3.12 pytest -vv tests/test_example.py

При этом способе запуска нет необходимости активировать виртуальное окружение .venv. Помимо этого вы можете поэкспериментировать с флагами для запуска:

uv run pytest 			# стандартный запуск
uv run pytest -q 			# тише
uv run pytest -vv 			# подробные имена кейсов
uv run pytest -k sum 		# фильтр по выражению/подстроке имени теста
uv run pytest -m "not slow" 	# запуск без помеченных slow
uv run pytest -x --maxfail=1 	# остановиться на первом падении

И после получим вывод:

uv run pytest tests/*

============== test session starts ==============
...                                   
collected 1 item 
tests/test.py::test_math PASSED                                       [100%]

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

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

uv run pytest tests/*

============== test session starts ==============
...
collected 1 item    

tests/test.py::test_math FAILED                                       [100%]

============== FAILURES ==============
______________ test_math _____________

    def test_math():
>       assert 2 + 3 == 6
E       assert (2 + 3) == 6

tests/test.py:2: AssertionError
============== short test summary info ==============
FAILED tests/test.py::test_math - assert (2 + 3) == 6
============== 1 failed in 0.09s ==============

Сразу же видно, где происходит ошибка и pytest выводит соответствующее сообщение об этом. Выглядит все очень просто, немного усложним выходные условия.

Разберемся с assert

Инструкция assert - это ключевой элемент тестового кейса. Именно он проверяет, есть ли соблюдение условий прохождения теста или нет. Проверки должны быть максимально читаемые и выглядеть просто как спецификация. При ее использовании нет необходимости выводить сообщения вручную почему упало. В трейслоге видно какие данные были переданы и где они в итоге не сошлись. 

В assert работает стандартный набор рабочих выражений: ==, !=, <, >, in, is, составных выражений, списков/словарей/строк.

Но есть некоторые интересные кейс. Например в тестах вот такого вида:

def test_list_diff():
    assert [1, 2, 3] == [1, 2, 4]   # покажет diff, что отличается последний элемент

def test_str_diff():
    assert "hello\nworld" == "hello\nWorld"  # построчный дифф с подсветкой

В качестве результата будет выведен следующий лог: 

collected 2 items                                                                                                             

tests/test_example.py::test_list_diff FAILED                                  [ 50%]
tests/test_example.py::test_str_diff FAILED                                   [100%]

============== FAILURES ==============
______________ test_list_diff ______________

    def test_list_diff():
>       assert [1, 2, 3] == [1, 2, 4]   # покажет diff, что отличается последний элемент
        ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
E       AssertionError: assert [1, 2, 3] == [1, 2, 4]
E         
E         At index 2 diff: 3 != 4
E         
E         Full diff:
E           [
E               1,
E               2,...
E         
E         ...Full output truncated (5 lines hidden), use '-vv' to show

tests/test_example.py:4: AssertionError
______________ test_str_diff ______________

    def test_str_diff():
>       assert "hello\nworld" == "hello\nWorld"  # построчный дифф с подсветкой
        ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
E       AssertionError: assert 'hello\nworld' == 'hello\nWorld'
E         
E           hello
E         - World
E         ? ^
E         + world
E         ? ^

tests/test_example.py:7: AssertionError
============== short test summary info ==============
FAILED tests/test_example.py::test_list_diff - AssertionError: assert [1, 2, 3] == [1, 2, 4]
FAILED tests/test_example.py::test_str_diff - AssertionError: assert 'hello\nworld' == 'hello\nWorld'
============== 2 failed in 0.09s ==============

Будет подробно подсвечен конкретное место расхождения. Но это не всё, помимо этого можно производить проверку исключений:

import pytest

def check_num(num):
    if not isinstance(num, int):
        raise ValueError(f"invalid value {num}")

def test_raises():
    with pytest.raises(ValueError, match=r"invalid value \d+"):
        check_num('123123')

Или предупреждений: 

def test_warns():
    with pytest.warns(UserWarning, match="deprecated"):
        warnings.warn("deprecated API", UserWarning)

Также есть возможность добавлять свои собственные сообщения в assert-ах: 

assert user.is_active, "Пользователь должен быть активирован перед входом"

Но имейте ввиду, что указывая сообщение, вы обычно теряете детальную "интроспекцию" условия. Если хотите именно своё описание - используйте выражение pytest.fail("...") после явных проверок.

Вообще из хороших практик оформления теста можно взять за правило несколько моментов: 

  • Сравнивайте явно: assert value is None, assert not items, assert "ok" in resp.text.

  • Для сложных объектов сделайте им информативный repr - отчёты станут гораздо понятнее.

  • Один тест - одна идея. Несколько assert'ов конечно допустимы, но не перемешивайте разные сценарии в рамках одного кейса.

Параметризация тестов

Часто получается так, что необходимо сделать одно и то же действие, но с разными входными данными. Очевидно, что раздувать тест из кучи копий функций с разными входными данными совершенно недопустимо - есть возможность задавать некий набор параметров для запуска. Рассмотрим самый простой вариант параметризации:

import pytest

@pytest.mark.parametrize("a,b,expected", [
    (1, 1, 2),
    (2, 5, 7),
    (-1, 1, 0),
])
def test_add(a, b, expected):
    assert a + b == expected

Параметризация осуществлятся через декоратор @pytest.mark.parametrize. В качестве аргументов перечисляются имена параметров (строка с запятыми или список строк), второй аргумент - список наборов значений. По итогу получается каждый набор - это отдельный тест. 

После запуска теста получается то, что тест перебирает каждый из вариантов:

uv run pytest 
============== test session starts ==============
...                                                 

tests/test_math.py::test_add[-1-1-0] PASSED                                    [ 33%]
tests/test_math.py::test_add[2-5-7] PASSED                                     [ 66%]
tests/test_math.py::test_add[1-1-2] PASSED                                     [100%]

============== 3 passed in 0.07s ==============

Помимо этого можно добавить описания кейсов вместо указания переданных данных:

@pytest.mark.parametrize("a,b,expected", [
    (1, 1, 2),
    (2, 5, 7),
    (-1, 1, 0),
],
ids=["first", "second", "third"])

После запуска видим: 

tests/test_math.py::test_add[third] PASSED                                     [ 33%]
tests/test_math.py::test_add[second] PASSED                                    [ 66%]
tests/test_math.py::test_add[first] PASSED                                     [100%]

В качестве имени кейса можно передать функцию. Такая функция получает значение параметра и должна вернуть строку-имя кейса. Это делает отчёт читаемым и помогает быстро понять, какой именно набор данных сломался.

def idfn(v):
    if isinstance(v, int):
        return f"vid={v}"
    if isinstance(v, dict):
        return f'{v["name"]}:{v["role"]}'
    return repr(v)

@pytest.mark.parametrize("val", [
    1,
    4094,
    {"name": "alice", "role": "admin"},
], ids=idfn)

def test_example(val):
    assert val is not None

Запуск покажет "говорящие" ID:

tests/test_math.py::test_example[vid=1]PASSED                                  [ 33%]
tests/test_math.py::test_example[alice:admin]PASSED                            [ 66%]
tests/test_math.py::test_example[vid=4094]PASSED                               [100%]

Если функция вернет None - то Pytest возьмёт ID по умолчанию. И стоит в этом случае возвращать короткие строки. 

Параметризация через декартово произведение параметров

В pytest есть несколько удобных способов сделать "перебор всех значений". Для этого необходимо указать несколько параметров parametrize:

@pytest.mark.parametrize("num1", [1, 2, 3])
@pytest.mark.parametrize("num2", [1, 2, 3])
def test_service(num1, num2):
    assert num1 + num2 == num1 + num2

После запуска получается следующий перебор вариантов:

tests/test_math.py::test_service[3-3]PASSED                                  [ 11%]
tests/test_math.py::test_service[3-2]PASSED                                  [ 22%]
tests/test_math.py::test_service[3-1]PASSED                                  [ 33%]
tests/test_math.py::test_service[2-1]PASSED                                  [ 44%]
tests/test_math.py::test_service[1-1]PASSED                                  [ 55%]
tests/test_math.py::test_service[2-2]PASSED                                  [ 66%]
tests/test_math.py::test_service[1-3]PASSED                                  [ 77%]
tests/test_math.py::test_service[1-2]PASSED                                  [ 88%]
tests/test_math.py::test_service[2-3]PASSED                                  [100%]

Это позволяет проверить все возможные комбинации параметров и избежать дублирования огромного количества повторяющегося кода.

Метки и фильтрация

Для создания групп тестов можно использовать специальные средства в pytest - маркеры. Они позволяют выбирать/исключать тесты при запуске, условно пропускать или ожидать падения, навешивать нужное поведение, логически группировать и запускать отдельными порциями и т.п.

Задается список маркеров в файле проекта  pyproject.toml в секции [tool.pytest.ini_options]. Например мы можем разделить тесты на группы: 

markers = [
  "slow: долгие тесты",
  "integration: интеграционные тесты",
  "network: сетевые тесты"
]

Далее с помощью декоратора @pytest.mark можно разметить тестовые кейсы в соответствующие группы:

@pytest.mark.slow
def test_long():
    ...

@pytest.mark.skipif(not has_device(), reason="нет стенда")
def test_needs_device():
    ...

@pytest.mark.xfail(reason="известный баг", strict=True)
def test_bug():
    assert 1 == 2

И можно потом запустить "медленные" тесты и без сетевых: 

uv run pytest -m "slow and not network"

Чтобы вывести все маркеры можно выполнить комаду:

uv run pytest --markers

Помимо пользовательских маркеров есть и встроенные, служебные:

  • @pytest.mark.skip(reason="...") - пропустить тест всегда.

  • @pytest.mark.skipif(условие, reason="...") - пропустить при выполнении условия.

  • @pytest.mark.xfail(reason="...", strict=False) - ожидаемый провал; не ломает прогон. strict=True делает "неожиданный успех" (XPASS) ошибкой.

  • @pytest.mark.parametrize(...) - параметризация теста/фикстуры; можно помечать отдельные параметры через pytest.param(..., marks=...).

  • @pytest.mark.usefixtures("fix1", "fix2") - подцепить фикстуры без явных аргументов.

  • @pytest.mark.filterwarnings("ignore::WarningType") - локально подавить/поднять уровень предупреждений.

Помимо маркировки отдельных функций тестов можно промаркировать класс или весь файл целиком:

pytestmark = [pytest.mark.integration]

class TestAPI:
    pytestmark = pytest.mark.network

Также маркировке могут быть подвержены отдельные тест-кейсы созданные в параметризаторе:

@pytest.mark.parametrize("mode", [
    "fast",
    pytest.param("slow", marks=pytest.mark.slow),
    pytest.param("offline", marks=pytest.mark.skip(reason="нет офлайна")),
])
def test_modes(mode):
    ...

Плюсом в файле conftest.py (который рассмотрим чуть позже) можно помечать тесты по имени файла/пути/тегам:

def pytest_collection_modifyitems(config, items):
    for item in items:
        if "tests/integration/" in str(item.fspath):
            item.add_marker(pytest.mark.integration)

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

Файл conftest.py

Теперь разберемся с важным элементом pytest - файл conftest.py. Если коротко - это локальный плагин pytest, который автоматически подхватывается для каталога в котором лежит и для всех его подкаталогов. В нем обычно держат фикстуры, хуки, свои CLI-опции, общие для этой группы тестов настройки и прочее.

Важно отметить, что этот файл не импортируется из тестов напрямую - pytest подгружает его самостоятельно. Но в целом никто не мешает держать свои фикстуры и функции в отдельном модуле и импортировать вручную.

В conftest.py определенно не стоит класть тяжелые импорты и код с побочными эффектами на уровне модуля. Ресурсы рекомендуется загружать только с помощью фикстур. Также любые пользовательские утилиты не стоит туда класть и лучше вынести в отдельный импортируемый модуль. 

То есть этот файл - это место где складываются “элементы инфраструктуры” для выполнения тестов на уровне текущей директории.

Фикстуры в pytest

Для упрощения подготовки предварительных компонентов, данных, подключений, конфигов и прочего - используются фикстуры. Это главный инструмент подготовки к тестам. Эти данные отдаются в тест как аргумент по имени и затем корректно убирает после себя. 

Как это работает? Если pytest видит, что в функции теста необходим аргумент с именем name - он идет искать фикстуру с таким именем. После осуществляет ее вызов (один раз на область видимости), кэширует результат и передает его через return/yield в тест. И после теста/модуля/сессии выполняет teardown, то есть всё что после yield или через addfinalizer.

Давайте сделаем простой пример теста с фикстурой для наглядности:

@pytest.fixture
def ab():
    # фикстура готовит данные и возвращает их тестам
    return 2, 3

def test_add(ab):
    a, b = ab
    assert a + b == 5

def test_mul(ab):
    a, b = ab
    assert a * b == 6Другой пример, более приближенный к реальным задачам. Пример открытия сессии SSH с использованием фикстур. Сделаем первую фикстуру в которой хранятся данные для авторизации:

Другой пример, более приближенный к реальным задачам. Пример открытия сессии SSH с использованием фикстур. Сделаем первую фикстуру в которой хранятся данные для авторизации:

@pytest.fixture(scope="session")
def ssh_params():
    return {"host": "127.0.0.1", "user": "admin", "password": "pass", "port": "22"}

Сделаем вторую, для возврата объекта с подключением:

@pytest.fixture(scope="session")
def ssh(ssh_params):
    client = paramiko.SSHClient()
    client.set_missing_host_key_policy(paramiko.AutoAddPolicy())

    kw = dict(
        hostname=ssh_params["host"],
        username=ssh_params["user"],
        port=ssh_params["port"],
        timeout=10,
        allow_agent=True,
        look_for_keys=False,
    )

    if ssh_params["password"]:
        kw["password"] = ssh_params["password"]

    client.connect(**kw)
    try:
        yield client			# передаем объект в тест
    finally:
        client.close()			# после теста закрываем сессию

Немного отойдем в сторону. В параметре декоратора добавился параметр scope, который отвечает за область жизни фикстуры, то есть как часто ее нужно создавать и когда уничтожать. Pytest кэширует результат фикстуры в рамках ее scope. Выполнение кода после yield происходит когда область жизни заканчивается. Варианты scope:

  • function (по умолчанию) - новая фикстура для каждого теста;

  • class - одна фикстура на класс тестов (Test...);

  • module - одна на файл с тестами;

  • package - одна на пакет (каталог с init.py и его подпакеты);

  • session - одна на весь прогон pytest;

Дам пару рекомендаций по поводу применения. Если это дешевый/одноразовый ресурс - то используем function. Если это "дорогие" подключения, типа SSH, к БД, HTTP-сессия - то лучше сделать module/section. Если это данные которые нельзя переиспользовать между тестами - то лучше оставить function или сделать функцию-конструктор, которая будет создавать свежий, изолированный экземпляр и не будет создавать "утечки состояния" между тест-кейсами. 

Вернемся к кейсу с созданием фикстур для обмена данными по SSH. Добавим следующую фикстуру для исполнения действий с использованием вышеприведенной фикстуры:

@pytest.fixture
def run_ssh(ssh):
    """Функция-запускалка команд по SSH: возвращает (rc, stdout, stderr)."""
    def _run(cmd, timeout=10):
        stdin, stdout, stderr = ssh.exec_command(cmd, timeout=timeout)
        out = stdout.read().decode(errors="replace").strip()
        err = stderr.read().decode(errors="replace").strip()
        rc = stdout.channel.recv_exit_status()
        return rc, out, err
    return _run

Теперь это можно использовать в коде теста, передав данную фикстуру в аргументе:

def test_hostname_matches_expected(run_ssh):
    expected_hostname = "localhost"
    rc, out, err = run_ssh("hostname")
    assert rc == 0, f"'hostname' завершилась с rc={rc}: {err}"
    assert out, "пустой вывод hostname"

    if expected_hostname:
        assert out == expected_hostname, f"ожидали {expected_hostname}, получили {out}"
    else:
        # если ожидание не задано — проверим «здравый» паттерн имени
        assert re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._-]*", out)

Проверим, запустив тест что у нас выйдет:

# uv run pytest

…

collected 1 item                                                                                         

tests/test_example.py::test_hostname_matches_expected FAILED 			[100%]

============== FAILURES ==============
______________ test_hostname_matches_expected ______________

run_ssh = <function run_ssh.<locals>._run at 0x7d87ac928540>

    def test_hostname_matches_expected(run_ssh):
        expected_hostname = "localhost"
        rc, out, err = run_ssh("hostname")
        assert rc == 0, f"'hostname' завершилась с rc={rc}: {err}"
        assert out, "пустой вывод hostname"
    
        if expected_hostname:
>           assert out == expected_hostname, f"ожидали {expected_hostname}, получили {out}"
E           AssertionError: ожидали localhost, получили NB-1135-LNX
E           assert 'NB-1135-LNX' == 'localhost'
E             
E             - localhost
E             + NB-1135-LNX

tests/test_example.py:49: AssertionError
============== short test summary info ==============
FAILED tests/test_example.py::test_hostname_matches_expected - AssertionError: ожидали localhost, получили NB-1135-LNX
============== 1 failed in 0.49s ==============

Думаю пример более чем показательный. Идём дальше.

Хуки в pytest

Разберемся что такое хуки в pytest и как их использовать себе на пользу. Как писал выше в определениях - это специальные функции-обработчики жизненного цикла тестов. Pytest сам вызывает их в определённые моменты (старт сессии, парсинг CLI, коллекция тестов, параметризация, запуск, репортинг). С помощью них можно настраивать поведение pytest без изменения тестов: фильтровать/переименовывать тесты, добавлять опции командной строки, параметризовать «на лету», вмешиваться в отчеты и т.д. К слову parametrize - это частный образец такого хука. 

Технически хуки реализованы через библиотеку pluggy; функции называются по шаблону pytest_<имя_хука> и пишутся в conftest.py или плагинах.

Приведу несколько примеров использования хуков. Объявление хуков производится в conftest.py.

Первый пример - pytest_addoption(parser) позволяет объявить кастомные ключи для запуска тестов, например для нашего теста с SSH можно было бы добавить следующий хук:

def pytest_addoption(parser):
    grp = parser.getgroup("ssh")
    grp.addoption("--ssh-host", help="SSH host")
    grp.addoption("--ssh-user", help="SSH username")
    grp.addoption("--ssh-password", help="SSH password")
    grp.addoption("--ssh-port", type=int, default=22, help="SSH port")
    grp.addoption("--expected-hostname", help="Expected hostname to assert")

А данные потом разобрать фикстурой:

@pytest.fixture(scope="session")
def ssh_params(pytestconfig):
   host = pytestconfig.getoption("--ssh-host") 
   user = pytestconfig.getoption("--ssh-user") 
   password = pytestconfig.getoption("--ssh-password") 
   port = pytestconfig.getoption("--ssh-port") 
   expected = pytestconfig.getoption("--expected-hostname")

   if not host or not user:
      pytest.skip("Нужно задать --ssh-host и --ssh-user")

  return {"host": host, "user": user, "password": password, "port": port, "expected": expected}

Далее - pytest_configure(config) / pytest_unconfigure(config) используется для инициализации/освобождения глобальных объектов (например, лог-файла, временной папки и т.п.)

# conftest.py
import tempfile, os

global_tmp_dir = None

def pytest_configure(config):
    """Выполняется при старте pytest — создаём временный каталог и сохраняем в объект config."""
    global global_tmp_dir
    global_tmp_dir = tempfile.mkdtemp(prefix="pytest_global_")
    config._global_tmp_dir = global_tmp_dir
    # Можно также регистрировать ini-lines: config.addinivalue_line(...)

def pytest_unconfigure(config):
    """Выполняется при завершении pytest — чистим временный каталог."""
    global global_tmp_dir
    if global_tmp_dir and os.path.isdir(global_tmp_dir):
        try:
            import shutil
            shutil.rmtree(global_tmp_dir)
        finally:
            global_tmp_dir = None

Следующий хук - pytest_collection_modifyitems(config, items). Он позволяет отфильтровать, изменить, переупорядочить найденные тесты. Например скипнуть тесты с выбранным маркером, если не передан никакой другой маркер:

# conftest.py
def pytest_addoption(parser):
    parser.addoption("--run-network", action="store_true", help="run network tests")

def pytest_collection_modifyitems(config, items):
    """Если не передан --run-network, помечаем network-тесты как skip."""
    run_network = config.getoption("--run-network")
    if run_network:
        return
    skip = pytest.mark.skip(reason="use --run-network to run")
    for item in items:
        if "network" in item.keywords:
            item.add_marker(skip)

Также можно items.sort(key=...) чтобы переупорядочить запуск (например, быстрые тесты первыми).

# conftest.py
def pytest_collection_modifyitems(config, items):
    """
    Простая сортировка: быстрые первыми, потом медленные (с маркером 'slow')
    Стабильность порядка обеспечивается сравнением по пути и имени.
    """
    items.sort(key=lambda it: ("slow" in it.keywords, str(it.fspath), it.name))
    
# tests/test_fast_slow.py
import pytest
import time

def test_fast_one():
    assert 1 + 1 == 2

def test_fast_two():
    assert "a".upper() == "A"

@pytest.mark.slow
def test_slow_one():
    # имитация медленной операции
    time.sleep(1)
    assert sum(range(10)) == 45

@pytest.mark.slow
def test_slow_two():
    time.sleep(0.05)
    assert "slow".startswith("s")

 Следующий хук - pytest_generate_tests(metafunc), он позволяет осуществлять динамическую параметризацию “на лету”. Например, если тест запрашивает аргументы, то мы передаем ему набор для теста:

# conftest.py
def pytest_generate_tests(metafunc):
    """
    Если тест запрашивает a, b и expected — подставляем набор простых арифметических кейсов.
    """
    if {"a", "b", "expected"} <= set(metafunc.fixturenames):
        cases = [
            (1, 2, 3),
            (2, 2, 4),
            (10, 5, 15),
            (0, 5, 5),
            (-1, 1, 0),
        ]
        ids = [f"{a}+{b}={exp}" for a, b, exp in cases]
        metafunc.parametrize(("a", "b", "expected"), cases, ids=ids)

# tests/test_arith.py
def test_add(a, b, expected):
    assert a + b == expected

Другие полезные хуки - это хуки-этапы запуска теста pytest_runtest_setup(item), pytest_runtest_call(item), pytest_runtest_teardown(item). С помощью них, например, можно логировать, менять окружение перед/после или прерывать тест:

# conftest.py
import time

def pytest_runtest_setup(item):
    """
    Вызывается перед setup-частью теста.
    Здесь можно подготавливать окружение, проверять маркеры и т.п.
    Мы просто запомним время старта и напечатаем, что тест собирается запускаться.
    """
    item._runtest_start = time.time()
    print(f"\n[HOOK setup] Preparing to run: {item.nodeid}")


@pytest.hookimpl(hookwrapper=True)
def pytest_runtest_call(item):
    """
    Обёртка вокруг выполнения самого теста.
    Код до yield выполняется до теста, код после yield — после теста.
    Это удобное место для измерения времени, перехвата исключений и т.п.
    """
    print(f"[HOOK call] About to call test: {item.nodeid}")
    start = time.time()
    outcome = yield  # выполнение реального теста происходит здесь
    duration = time.time() - start

    # Получаем информацию об исполнении (исключение/успех) через outcome.get_result() не даёт report,
    # но мы можем посмотреть, упало ли исключение во время вызова (через outcome.exception() в new pytest?)
    # Простая проверка: если тест выбросил исключение, оно будет проброшено дальше, но мы всё равно логируем.
    print(f"[HOOK call] Finished call: {item.nodeid} (duration: {duration:.4f}s)")


def pytest_runtest_teardown(item, nextitem):
    """
    Вызывается после teardown-части теста.
    Здесь можно сделать финальную отчистку или логирование итоговой длительности.
    """
    total = None
    if hasattr(item, "_runtest_start"):
        total = time.time() - item._runtest_start
    print(f"[HOOK teardown] Completed: {item.nodeid} (total: {total:.4f}s)")

Запустив тест с параметром -s можно увидеть следующий вывод:

# uv run pytest -s
...
tests/test_example.py::test_quick [HOOK setup] Preparing to run: tests/test_example.py::test_quick
[HOOK call] About to call test: tests/test_example.py::test_quick
[HOOK call] Finished call: tests/test_example.py::test_quick (duration: 0.0001s)
PASSED[HOOK teardown] Completed: tests/test_example.py::test_quick (total: 0.0003s)
...

Внимательный читатель заметит, что добавилась директива @pytest.hookimpl(hookwrapper=True) которая превращает весь хук в “обертку” вокруг всей цепочки реализаций этого же хука - то есть можно исполнить код до и после выполнения всех остальных обработчиков хука. Это делается с помощью yield и всё что до yield выполняется перед основной работой хука, а всё что после yield - после нее. То есть это своеобразный способ сделать “around” обёртку вокруг хука. С помощью него можно также контролировать порядок вызова хуков, не будем углубляться в это и идём дальше.

Следующих хук который я рассмотрю - это pytest_runtest_makereport(item, call). Он позволяет добавить кастомный лог в объект отчета для каждого теста и например добавлять свои разделы. Рассмотрим пример с выдачей лога SSH при завале теста, чтобы сразу было видно почему тест завалился:

# conftest.py
@pytest.fixture
def run_ssh(ssh, request):
    """
    Возвращает функцию запуска команды по SSH и параллельно
    пишет сырой DEBUG-лог библиотеки Paramiko в буфер.
    """
    # настраиваем capture лога Paramiko в StringIO
    buf = io.StringIO()
    handler = logging.StreamHandler(buf)
    handler.setLevel(logging.DEBUG)
    handler.setFormatter(logging.Formatter(
        "%(asctime)s %(name)s %(levelname)s: %(message)s"
    ))

    logger = logging.getLogger("paramiko")
    logger.setLevel(logging.DEBUG)        # критично: иначе DEBUG не пойдёт
    logger.addHandler(handler)
    logger.propagate = False              # чтобы не дублировалось в root

    def _run(cmd: str, timeout: int = 10):
        stdin, stdout, stderr = ssh.exec_command(cmd, timeout=timeout)
        out = stdout.read().decode(errors="replace").strip()
        err = stderr.read().decode(errors="replace").strip()
        rc = stdout.channel.recv_exit_status()
        return rc, out, err

    # прикрепим для хука
    _run._paramiko_buf = buf
    _run._paramiko_handler = handler
    _run._paramiko_logger = logger

    # снятие хендлера по окончании теста
    def fin():
        logger.removeHandler(handler)
        handler.flush()
    request.addfinalizer(fin)

    return _run


@pytest.hookimpl(hookwrapper=True)
def pytest_runtest_makereport(item, call):
    # hookwrapper даёт возможность выполнить код до/после получения rep
    outcome = yield
    rep = outcome.get_result()  # pytest.TestReport
    # интересует фаза "call" (сам тест), не setup/teardown
    if rep.when == "call" and rep.failed:
        # если тест использовал фикстуру 'run_ssh' — добавим её содержимое в секцию отчёта
        if "run_ssh" in getattr(item, "funcargs", {}):
            fn = item.funcargs.get("run_ssh")
            if fn and hasattr(fn, "_paramiko_buf"):
                text = fn._paramiko_buf.getvalue()
                rep.sections.append(("paramiko log", text if text.strip() else "<empty>"))
            else:
                rep.sections.append(("paramiko log", "run_ssh fixture not used"))

В этом случае можно будет увидеть в логе тестов свой вывод: \

------------- paramiko log -------------
2025-09-14 01:33:21,394 paramiko.transport DEBUG: [chan 0] Max packet in: 32768 bytes
2025-09-14 01:33:21,586 paramiko.transport DEBUG: Received global request "hostkeys-00@openssh.com"
2025-09-14 01:33:21,586 paramiko.transport DEBUG: Rejecting "hostkeys-00@openssh.com" global request from server.
2025-09-14 01:33:21,628 paramiko.transport DEBUG: [chan 0] Max packet out: 32768 bytes
2025-09-14 01:33:21,628 paramiko.transport DEBUG: Secsh channel 0 opened.
2025-09-14 01:33:21,629 paramiko.transport DEBUG: [chan 0] Sesch channel 0 request ok
2025-09-14 01:33:21,631 paramiko.transport DEBUG: [chan 0] EOF received (0)
2025-09-14 01:33:21,631 paramiko.transport DEBUG: [chan 0] EOF sent (0)

Перейдем к следующим хукам - pytest_report_header(config) / pytest_terminal_summary(terminalreporter, exitstatus, config), которые добавляют заголовок в начало и сводку в конце запуска.  Позволяют снабдить вывод дополнительным сопровождением:

def pytest_report_header(config):
    return f"Project: MyApp | ENV={config.getoption('--env') if config.getoption('--env', None) else 'default'}"

def pytest_terminal_summary(terminalreporter, exitstatus, config):
    tr = terminalreporter
    total = tr._numcollected if hasattr(tr, "_numcollected") else "?"
    passed = len(tr.stats.get("passed", []))
    failed = len(tr.stats.get("failed", []))
    skipped = len(tr.stats.get("skipped", []))
  tr.write_sep("-", f"Summary: collected={total} passed={passed} failed={failed} skipped={skipped}")

Хук pytest_assertrepr_compare(op, left, right) позволяет переопределить вывод assert на вариант, который нужен именно вам. Сделаем простое расширение отладочного вывода на классической ошибке суммирования чисел с плавающей запятой:

def pytest_assertrepr_compare(op, left, right):
    """
    Делает падения assert для чисел более информативными.
    Показываем левое/правое, разницу и относительную погрешность для float.
    """
    if op == "==" and isinstance(left, (int, float)) and isinstance(right, (int, float)):
        lines = ["numbers differ:"]
        lines.append(f"  left : {left!r}")
        lines.append(f"  right: {right!r}")
        diff = right - left
        lines.append(f"  diff (right - left): {diff!r}")
        if isinstance(left, float) or isinstance(right, float):
            denom = max(1.0, abs(right))  # чтобы не делить на 0
            rel = abs(diff) / denom
            lines.append(f"  abs diff: {abs(diff):.17g}, rel≈{rel:.3e} (vs right)")
            lines.append("  tip: for floats use pytest.approx(...)")
        return lines

def test_float_add_fails():
    # классическая проблема с двоичной арифметикой
    assert 0.1 + 0.2 == 0.3

Будет выведен расширенный вывод: 

FAILED tests/test_example.py::test_float_add_fails - assert numbers differ:
    left : 0.30000000000000004
    right: 0.3
    diff (right - left): -5.551115123125783e-17
    abs diff: 5.5511151231257827e-17, rel≈5.551e-17 (vs right)
    tip: for floats use pytest.approx(...)

Есть несколько важных рекомендаций по использованию хуков. Во-первых их нужно делать быстрыми поскольку они вызываются очень часто. Всю логику связанную с ресурсами необходимо держать в фикстурах, а не в хуках. Хуки рекомендуется использовать только для глобальной политики проведения тестов, отчетности.

Mock в pytest

Часто бывает так, что для тестов не доступны реальные объекты и необходимо сделать “заглушку”, для имитации поведения, возвращаемых значений, исключений или записывает, как объект вызывали для анализа.

В pytest есть такая возможность через модуль pytest-mock. Активируется при использовании автоматически без дополнительных импортов и передается через фикстуру mocker. Установка осуществляется стандартно:

uv add --dev pytest-mock

Первое применение - это патчинг, т.е. временная подмена атрибута/функции/класса в точке использования на время теста. Чаще всего используется при замене “тяжелых” объектов и зависимостей. Главное правило патчинга - необходимо применять его там где объект используется (импортирован), а не там где он определен. То есть если в app.py написано from math import sqrt, то цель будет "app.sqrt", а не "math.sqrt". 

Приведу пример где мы пропатчим реальную функцию. Например, есть функция в коде, которая запрашивает функцию, которая возвращает JSON:

# webapp.py
import requests

def get_title(url: str) -> str:
    resp = requests.get(url, timeout=1)
    resp.raise_for_status()
    return resp.json()["title"]

В директории с тестами сделаем файл с тестом:

# tests/test_webapp.py
from webapp import get_title

def test_get_title_ok(mocker):
    # Патчим "там, где используется": webapp.requests.get
    mock_get = mocker.patch("webapp.requests.get")

    # Настраиваем фейковый ответ
    mock_resp = mocker.Mock()
    mock_resp.raise_for_status.return_value = None
    mock_resp.json.return_value = {"title": "Hello"}
    mock_get.return_value = mock_resp

    # Проверяем поведение
    assert get_title("https://example.com/api") == "Hello"
    mock_get.assert_called_once_with("https://example.com/api", timeout=1)
    mock_resp.raise_for_status.assert_called_once()

В этом случае мы сделали импорт через import requests, то цель патчинга “webapp.requests.get”. В этом случае мы не ходим в сеть и просто возвращаем подготовленный мок-объект. 

Следующее применение - “шпион”, который обвешивает вызываемую функцию средствами наблюдения без изменения реализации. Приведем простой пример:

import math

def use_sqrt(x: float) -> float:
    return math.sqrt(x)

def test_spy(mocker):
    spy = mocker.spy(math, "sqrt")
    assert use_sqrt(9) == 3
    assert spy.call_count == 1
    assert spy.spy_return == 3

Множество свойств для отслеживания вызываемой функции вы можете найти в документации самостоятельно. 

Следующее применение - это создание простой заглушки для простых случаев. Допустим есть простой код возвращающий текущее время: 

# app_time.py
import time

def seconds_since_epoch() -> int:
    return int(time.time())

Сделаем простую заглушку для замены на фиксированное время.

# tests/test_app_time.py
from app_time import seconds_since_epoch

def test_seconds_since_epoch_with_stub(mocker):
    time_stub = mocker.stub(name="time")
    time_stub.return_value = 1_700_000_000  # фиксированное «время»

    # В модуле app_time мы делали `import time`, значит патчим здесь:
    mocker.patch("app_time.time.time", time_stub)

    assert seconds_since_epoch() == 1_700_000_000
    time_stub.assert_called_once_with()

Идея простая: mocker.stub — это крошечная вызываемая заглушка. Вы задаёте, что она возвращает (return_value) или как себя ведёт (side_effect), а дальше либо передаёте её как зависимость, либо ставите на место реальной функции через mocker.patch.

В качестве заключения

Я решил дальше не продолжать описание pytest, чтобы не превращать ее в некое подобие руководства по этому очень мощному фреймворку. Целью для меня было бегло раскрыть самые основные функциональные возможности и привести пару-тройку примеров. И кажется рассмотреть все основные возможности получилось.

Вне этой статьи остались вопросы, которые вы можете поизучать после самостоятельно:

  • работу с временные файлами/папками через tmp_path;

  • функции работы для захвата вывода capsys;

  • другие способы подмены окружения и функций через monkeypatch;

  • отчеты и покрытия через плагины;

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

  • бэнчмарки;

  • способы интеграции с CI;

Ну и конечно же, интегрируйте это все в свои проекты и расширяйте тестовое покрытие, постепенно увеличивая покрытие кода тестами, а вслед за этим придет и опыт и знания. 

Удачи!

Комментарии (0)