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

Дисклеймер. Сразу же хотелось бы оставить за собой право на ошибки, а также размытые и не полные интерпретации вещей, о которых собираюсь рассказать т. к. я не являюсь профессиональным программистом и специалистом автоматизированном тестировании. Но с другой стороны, любые конструктивные замечания или исправления дадут почву для саморазвития и самокоррекции, и ваша обоснованная обратная связь будет очень ценной для меня.
Что такое pytest?
Pytest — это самый популярный фреймворк для тестирования на Python. Pytest появился, чтобы сделать тестирование в Python простым и приятным: меньше церемоний, больше читаемости и расширяемости. Он применяется везде — от библиотек и веб‑сервисов до ML‑проектов и инфраструктуры - и подходит как одиночным разработчикам, так и большим командам с CI/CD.
Первые массовые тесты в Python строились на unittest (xUnit‑подобный фреймворк из стандартной библиотеки). Он надежен, но в большей степени «церемониален»: классы, наследование, методы setUp/tearDown, громоздкие assert*‑методы. Это тормозило внедрение тестов в повседневную практику: слишком много шаблонного кода ради простых проверок.
Со временем появлялись надстройки (например, nose), но им не хватило долгосрочной поддержки. Pytest предложил другой путь:
Простота синтаксиса: тест — это обычная функция и обычный assert.
Лучшие сообщения об ошибках: «расшифровка» выражений в assert и наглядные tracebacks.
Фикстуры вместо классовой магии: декларативная система подготовки/очистки состояния без наследования.
Параметризация: один тест — много входов/ожидаемых выходов.
Плагины: архитектура, которую можно расширять под любые сценарии (распараллеливание, ретраи, бенчмарки, отчеты и т. д.).
Иными словами, первопричина появления — снизить порог входа и стоимость владения тестовой базой, сохранив мощь для сложных проектов.
Основные термины
Сразу приведу основные термины объясненные своим языком, поскольку они употребляются сразу же по ходу материала:
Assert-интроспекция — это обычная инструкция Python вида assert <условие>[, <сообщение>], которая при падении показывает разбор выражения: левую/правую части, значения подвыражений, красивый дифф коллекций/строк.
Traceback — это отчёт о том, по какой цепочке функций Python дошел до места где произошло исключение.
Фикстуры в pytest — это функции, которые готовят ресурсы для тестов (данные, подключения, конфиги) и отдают его тесту как аргумент по имени и затем корректно убирают. Это удобный способ интегрировать в тест необходимые зависимости.
Mark‑функции — это ярлыки для тестов в виде функций‑декораторов. Ими помечают функции тестов, чтобы исключать/выбирать при запуске, условно пропускать или ожидать падения, группировать и так далее
Хуки — это специальный‑функции, с помощью которых можно настраивать поведение pytest без изменения самих тестов. Рассмотрим позже на примерах.
Моки — это подменные объекты, которые имитируют поведение внешних зависимостей в тестах: БД, сеть, время, файл-система и т. п. С моками вы проверяете как ваш код взаимодействует с зависимостью (какие функции вызвал, с какими аргументами), не трогая реальный мир.
В чем он лучше других?
Отсутствие избыточного кода, как это в unittest. Код тестов pytest состоит из коротких самодокументирующихся функций.
Достаточно простая подготовка окружения для работы, с явными повторно используемыми фикстурами с зависимостями.
Детально подсвечиваются различия, контекстов и значений assert для более полной диагностики падений.
Через @pytest.mark.parametrize можно использовать один и тот же тест с разными данными без дублирования.
Есть возможность делать параллельные запуски тестов, делать ретраи, таймауты и бенчмарки.
Единая, минималистичная идиома, которую легко читать и поддерживать.
Огромное количество разнообразных плагинов.
Какой уровень входа?
Данный фреймворк вполне себе подходит для новичков, потому что для старта нужно знать лишь базовые функции 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 с использованием фикстур. Сделаем первую фикстуру в которой хранятся данные для авторизации:
@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;
Ну и конечно же, интегрируйте это все в свои проекты и расширяйте тестовое покрытие, постепенно увеличивая покрытие кода тестами, а вслед за этим придет и опыт и знания.
Удачи!
