Pytest: фикстуры, маркеры, плагины и лучшие практики тестирования
Тестирование гарантирует предсказуемость поведения кода и даёт уверенность в том, что изменения не поломают всё к чертям. Написание и поддержка тестов — штука непростая, однако существуют инструменты, помогающие сделать процесс менее болезненным.
Сегодня мы изучаем pytest
. Забудьте о громоздких классах unittest
(хотя pytest
с ними тоже дружит, об этом позже 😉) и бесконечных self.assertSomething()
методах. Мы поговорим о том, как pytest
с его элегантными фикстурами, гибкими маркерами, мощной системой плагинов и здравыми практиками может упростить рутину написания тестов.
pytest
vs unittest
: краткое сравнение
Если вы изучали тестирования на Python, то, скорее всего, начинали с модуля unittest
, встроенного в стандартную библиотеку. Это, без сомнения, надежный инструмент, этакий "дедушка" тестирования в Python.
pytest
же предлагает более современный и более питонический подход. Давайте кратко сравним ключевые моменты, чтобы понять, почему многие разработчики выбирают именно его.
Меньше шаблонного кода, больше фокуса на логике
Одно из первых, что бросается в глаза при переходе с unittest
на pytest
, – это значительное сокращение бойлерплейта.
В unittest
для каждого набора тестов нам нужно:
- Импортировать
TestCase
изunittest
. - Создать класс, наследующийся от
TestCase
. - Определить каждый тест как метод этого класса, имя которого начинается с
test_
. - Использовать специальные методы
self.assert*()
для проверок.
Давайте посмотрим на простой пример – тест, который всегда проходит, и тест, который всегда падает.
# test_with_unittest.py import unittest class MyUnittestTests(unittest.TestCase): def test_always_passes(self): self.assertTrue(True) def test_always_fails(self): self.assertTrue(False) if __name__ == '__main__': unittest.main()
Чтобы это запустить из консоли (если не используем if __name__ == '__main__':
): python -m unittest discover
.
# test_with_pytest.py def test_always_passes_pytest(): assert True def test_always_fails_pytest(): assert False
И все! Никаких классов, никаких специальных импортов (кроме самого pytest
, если он не установлен глобально, но для запуска тестов этого не требуется явно импортировать в файл). pytest
сам обнаружит файлы, начинающиеся с test_
или заканчивающиеся на _test.py
, и функции или методы внутри них, начинающиеся с test_
.
Меньше кода — меньше мест, где можно ошибиться, и больше концентрации на самой логике теста.
Стандартные assert
'ы: проще и интуитивнее
Как мы уже увидели, pytest
позволяет использовать стандартный Python-оператор assert
. Больше не нужно запоминать десятки различных методов self.assertEqual()
, self.assertIn()
, self.assertIsInstance()
, self.assertRaises()
и так далее.
Если вы можете написать выражение, которое должно быть истинным, вы можете его проверить:
# examples_of_asserts.py def test_string_manipulation(): assert "hello".upper() == "HELLO" def test_list_membership(): assert 1 in [1, 2, 3] def test_dict_values(): my_dict = {"a": 1, "b": 2} assert my_dict.get("a") == 1 # Для проверки исключений есть специальный механизм, о нем позже
Эта простота не только снижает порог вхождения, но и делает тесты более читаемыми. Вы просто читаете утверждение на почти естественном языке.
Конечно, unittest
– это классика, и pytest
полностью с ним совместим: он умеет находить и запускать тесты, написанные с использованием unittest.TestCase
. Так что переход может быть постепенным. Но попробовав однажды лаконичность и мощь pytest
, возвращаться к "старой школе" обычно уже не хочется. 😉
Установка Pytest и первый тест
Установить pytest
и написать свой первый тест — дело нескольких минут.
Установка
Как и большинство Python-пакетов, pytest
легко устанавливается из PyPI с помощью pip
. Рекомендуется делать это в виртуальном окружении вашего проекта, чтобы изолировать зависимости.
Если у вас еще нет виртуального окружения, создайте и активируйте его:
python -m venv venv .\venv\Scripts\Activate.ps1
python3 -m venv venv source venv/bin/activate
Теперь, когда виртуальное окружение активно (вы должны видеть (venv) в начале строки терминала), устанавливаем pytest:
pip install pytest
Вот и всё! pytest установлен и готов к работе.
Структура тестов: файлы test_*.py
и функции test_*
pytest
придерживается простых соглашений для обнаружения тестов:
- Файлы с тестами: Он ищет файлы с именами, начинающимися с
test_
(например,test_example.py
) или заканчивающимися на_test.py
(например,example_test.py
). - Тестовые функции и методы: Внутри этих файлов он ищет функции, имена которых начинаются с
test_
(например,def test_my_feature():
). - Тестовые классы: Если вы предпочитаете группировать тесты в классы (хотя это и не обязательно, как в
unittest
), то класс должен называтьсяTestSomething
(начинаться сTest
), и его методы, начинающиеся сtest_
, будут считаться тестами.pytest
не требует, чтобы эти классы наследовались от чего-либо.
Давайте создадим наш первый тестовый файл. Назовем его test_app.py
в корне нашего проекта (или в специальной директории tests/
, если проект большой).
# test_app.py def test_sample_addition(): assert 1 + 1 == 2 def test_another_sample_check(): my_string = "hello pytest" assert "pytest" in my_string assert len(my_string) == 12 # Пример с тестовым классом (необязательно) class TestMyStuff: def test_numbers_equal(self): assert 10 == 10 def test_string_not_empty(self): assert "some_value" != ""
Просто, не правда ли? Никакого лишнего кода, только суть проверок.
Запуск тестов и анализ первого вывода
Теперь самое интересное – запуск! Откройте терминал в корневой директории вашего проекта (там, где лежит test_app.py
или директория tests/
) и выполните команду: pytest
.
Вы увидите следующую информацию:
platform ...
: Информация о вашей системе, версии Python,pytest
и установленных плагинах.rootdir ...
: Корневая директория, из которойpytest
начал поиск тестов.collected 4 items
: pytest нашел 4 тестовых элемента (наши две функции и два метода в классе).test_app.py ....
: Имя файла и результат выполнения каждого теста в нем. Точка (.
) означает, что тест прошел успешно. Если бы тест упал, мы бы увиделиF
. Если возникло исключение –E
.[100%]
: Прогресс выполнения тестов.==== 4 passed in 0.01s ====
: Итоговая сводка: сколько тестов прошло и за какое время.
Совет: Вы можете добавить опцию -v
(verbose
) для более детального вывода, где будут показаны имена всех найденных и запущенных тестов: pytest -v
. А опция -q
(quiet
) наоборот, сделает вывод максимально коротким.
Поздравляю! 🎉 Вы только что установили pytest
, написали и успешно запустили свои первые тесты. Как видите, начать очень просто. Дальше мы погрузимся в более мощные возможности pytest
, такие как фикстуры, которые делают тестирование еще более гибким и удобным.
Фикстуры: управление состояниями и зависимостями
Тесты редко существуют в вакууме. Часто для их выполнения нужно подготовить какие-то данные, настроить окружение, создать объекты-заглушки (моки) или подключиться к тестовой базе данных. В unittest
для этого обычно используются методы setUp()
и tearDown()
(или их аналоги на уровне класса/модуля). pytest
предлагает более гибкий и явный механизм — фикстуры.
Простыми словами, фикстура в pytest
— это функция, которая выполняется перед (а иногда и после) тестом (или группой тестов) и предоставляет ему данные или ресурсы. Тест "запрашивает" фикстуру, просто указав ее имя в качестве аргумента. pytest сам позаботится о том, чтобы выполнить нужную фикстуру и передать ее результат в тест.
Представьте, что у вас есть несколько тестов, которым нужен объект пользователя с определенными данными. Вместо того чтобы создавать этот объект в каждом тесте, вы можете определить фикстуру, которая его создает один раз, а затем все тесты будут его использовать.
- Предоставление тестовых данных: От простых значений до сложных объектов.
- Настройка состояния системы: Например, создание временных файлов/директорий, инициализация соединения с БД, запуск тестового сервера.
- Очистка ресурсов (Teardown): После того как тест отработал, фикстура может выполнить действия по очистке (удалить временные файлы, закрыть соединение с БД).
- Инкапсуляция логики подготовки: Позволяют вынести сложную логику подготовки окружения из тестов, делая сами тесты короче и чище.
- Переиспользование: Одну фикстуру могут использовать множество тестов.
Фикстуры делают зависимости ваших тестов явными и легко отслеживаемыми. Глядя на аргументы тестовой функции, вы сразу понимаете, какие ресурсы ей необходимы.
Создание фикстур: декоратор @pytest.fixture
и его возможности
Для определения фикстуры используется декоратор @pytest.fixture
. Давайте создадим простую фикстуру, которая возвращает словарь с данными пользователя.
import pytest @pytest.fixture def sample_user_data(): """Фикстура, предоставляющая тестовые данные пользователя.""" print("\n[Фикстура sample_user_data]: Создание данных...") user = { "username": "testuser1", "email": "testuser1@example.com", "is_active": True } return user def test_user_has_username(sample_user_data): """Тест проверяет наличие ключа 'username'.""" print("\n[Тест test_user_has_username]: Выполнение...") assert "username" in sample_user_data assert sample_user_data["username"] == "testuser1" def test_user_is_active(sample_user_data): """Тест проверяет, что пользователь активен.""" print("\n[Тест test_user_is_active]: Выполнение...") assert sample_user_data["is_active"] is True
Если запустить pytest -s -v
(-s
чтобы увидеть print
из фикстур и тестов, -v
для подробного вывода):
============================= test session starts ============================== # ... служебная информация ... collected 2 items test_users.py::test_user_has_username [Фикстура sample_user_data]: Создание данных... [Тест test_user_has_username]: Выполнение... PASSED test_users.py::test_user_is_active [Фикстура sample_user_data]: Создание данных... [Тест test_user_is_active]: Выполнение... PASSED ============================== 2 passed in 0.02s ===============================
Обратите внимание:
1. Мы декорировали функцию sample_user_data
с помощью @pytest.fixture
.
2. Тестовые функции test_user_has_username
и test_user_is_active
принимают sample_user_data
в качестве аргумента. Имя аргумента должно совпадать с именем функции-фикстуры.
3. pytest
автоматически вызвал фикстуру перед каждым тестом, который ее запросил. Значение, которое вернула фикстура (user
), было передано в тест.
4. Важно: фикстура sample_user_data
вызывалась для каждого теста отдельно. Это поведение по умолчанию (область видимости function
).
Декоратор @pytest.fixture
имеет несколько полезных параметров, самый важный из которых — scope
.
scope
: области видимости фикстур (function
, class
, module
, session
) и их влияние
Параметр scope
декоратора @pytest.fixture
определяет, как часто фикстура будет создаваться и уничтожаться. Это ключевой момент для оптимизации и управления состоянием.
Доступные значения для scope
:
scope="function"
(по умолчанию):- Фикстура выполняется один раз для каждой тестовой функции, которая ее запрашивает.
- Идеально для изоляции тестов, когда каждый тест должен работать со своим "чистым" экземпляром ресурса.
- Пример выше с
sample_user_data
использовал эту область видимости. scope="class"
:- Фикстура выполняется один раз для каждого тестового класса, методы которого ее запрашивают.
- Полезна, если у вас есть группа тестов в классе, которые могут работать с одним и тем же ресурсом, созданным один раз для всего класса.
scope="module"
:- Фикстура выполняется один раз для каждого модуля (файла
.py
), тесты из которого ее запрашивают. - Подходит для ресурсов, которые дороги в создании, и могут быть разделены между всеми тестами в одном файле (например, соединение с тестовой БД, которое нужно установить один раз на модуль).
scope="session"
:
Давайте модифицируем наш пример, чтобы показать scope="module"
.
test_users_scoped.py:
import pytest @pytest.fixture(scope="module") def module_scoped_data(): """Эта фикстура будет вызвана один раз на модуль.""" print("\n[Фикстура module_scoped_data SCOPE=MODULE]: Создание данных...") data = {"value": 0} return data class TestUserOperations: def test_increment_value(self, module_scoped_data): print("\n[Тест test_increment_value]: Выполнение...") module_scoped_data["value"] += 1 assert module_scoped_data["value"] > 0 def test_check_value_again(self, module_scoped_data): # Этот тест увидит измененное значение, так как фикстура одна на модуль print("\n[Тест test_check_value_again]: Выполнение...") assert module_scoped_data["value"] == 1 # Зависит от предыдущего теста! Осторожно! def test_another_func_in_module(module_scoped_data): print("\n[Тест test_another_func_in_module]: Выполнение...") # Этот тест также использует тот же экземпляр фикстуры assert "value" in module_scoped_data assert module_scoped_data["value"] == 1 # Все еще 1
Запуск pytest -s -v test_users_scoped.py
:
============================= test session starts ============================== # ... collected 3 items test_users_scoped.py [Фикстура module_scoped_data SCOPE=MODULE]: Создание данных... [Тест test_increment_value]: Выполнение... PASSED [Тест test_check_value_again]: Выполнение... PASSED [Тест test_another_func_in_module]: Выполнение... PASSED ============================== 3 passed in 0.02s ===============================
Как видите, [Фикстура module_scoped_data SCOPE=MODULE]: Создание данных...
вывелось только один раз, хотя фикстуру использовали три теста.
Важно: Использование scope
шире, чем function
, может привести к тому, что тесты станут зависимы друг от друга через общее состояние фикстуры (как в примере test_check_value_again
, который полагается на изменение module_scoped_data["value"]
в test_increment_value
). Это часто считается антипаттерном, так как нарушает изоляцию тестов. Используйте широкие scope осознанно, когда это действительно оправдано производительностью и ресурс не изменяется тестами, либо изменения предсказуемы и являются частью дизайна тестов.
conftest.py
: делаем фикстуры доступными глобально
Если у вас есть фикстуры, которые вы хотите использовать в нескольких тестовых файлах, выносить их в специальный файл conftest.py
. pytest
автоматически обнаруживает фикстуры (и хуки) из файлов conftest.py в директории с тестами и во всех родительских директориях.
- Фикстуры из
conftest.py
в текущей директории доступны всем тестам в этой директории и ее поддиректориях. - Фикстуры из
conftest.py
в родительской директории доступны всем тестам в этой родительской директории и всех ее дочерних директориях.
Это позволяет удобно структурировать общие тестовые утилиты.
my_project/ ├── src/ │ └── my_module.py ├── tests/ │ ├── conftest.py # Фикстуры, общие для всех тестов │ ├── unit/ │ │ ├── conftest.py # Фикстуры, специфичные для unit-тестов │ │ └── test_something.py │ └── integration/ │ └── test_api.py └── pytest.ini
В tests/unit/test_something.py
будут доступны фикстуры и из tests/unit/conftest.py
, и из tests/conftest.py
.
# tests/conftest.py import pytest @pytest.fixture(scope="session") def global_config(): print("\n[Фикстура global_config из tests/conftest.py SCOPE=SESSION]: Загрузка...") return {"api_url": "http://test.example.com", "timeout": 5}
# tests/unit/test_something.py # Не нужно импортировать global_config, pytest найдет ее сам! def test_api_url_is_correct(global_config): # global_config из tests/conftest.py print(f"\n[Тест test_api_url_is_correct]: Используем global_config...") assert global_config["api_url"] == "http://test.example.com"
Теперь фикстура global_config
доступна всем тестам без явного импорта.
autouse=True
: автоматически применяемые фикстуры (и когда их использовать с осторожностью)
Иногда нужно, чтобы фикстура применялась ко всем тестам в определенной области видимости (scope
) без явного указания ее в аргументах каждого теста. Для этого используется параметр autouse=True
.
tests/conftest.py (дополненный):
import pytest import time # ... (фикстура global_config) ... @pytest.fixture(autouse=True, scope="function") def log_test_duration(): """Автоматически логирует начало и конец каждого теста (scope=function).""" start_time = time.time() print(f"\n[AUTOUSE Фикстура log_test_duration]: Тест НАЧАТ") yield # Управление передается тесту # Код после yield выполнится после теста (финализация) duration = time.time() - start_time print(f"\n[AUTOUSE Фикстура log_test_duration]: Тест ЗАВЕРШЕН за {duration:.4f} сек.")
Теперь для каждого теста (в области видимости, где доступна эта фикстура) log_test_duration
будет выполняться автоматически.
Осторожно с autouse=True
!
Хотя autouse фикстуры могут быть удобны для сквозной функциональности (логирование, мокинг глобальных ресурсов типа сети), их чрезмерное использование может сделать зависимости тестов неявными и усложнить понимание того, что именно влияет на тест. Используйте их, когда действие фикстуры действительно должно применяться ко всем тестам в ее scope и не является специфической зависимостью конкретного теста.
"Запрос" фикстур: как тесты получают свои зависимости
Мы уже видели это много раз: тест "запрашивает" фикстуру, просто указывая имя функции-фикстуры в качестве одного из своих аргументов.
# test_example.py import pytest @pytest.fixture def db_connection(): print("\n[Фикстура db_connection]: Подключение к БД...") conn = {"status": "connected"} # Имитация соединения yield conn # Передаем управление и объект соединения print("\n[Фикстура db_connection]: Закрытие соединения с БД...") conn["status"] = "disconnected" @pytest.fixture def user_model(db_connection): # Эта фикстура запрашивает другую фикстуру print(f"\n[Фикстура user_model]: Инициализация модели пользователя с {db_connection}...") if db_connection["status"] != "connected": raise Exception("DB not connected for user_model!") return {"name": "Default User", "id": 1} def test_get_user_details(user_model): # Тест запрашивает user_model print(f"\n[Тест test_get_user_details]: Работаем с {user_model}...") assert user_model["name"] == "Default User" assert "id" in user_model
pytest
построит граф зависимостей: test_get_user_details
требует user_model
, а user_model
требует db_connection
. pytest
выполнит их в правильном порядке. Это позволяет создавать мощные и хорошо структурированные цепочки подготовки данных и ресурсов.
Финализаторы фикстур: yield
и addfinalizer
для очистки ресурсов
Часто фикстуре нужно не только что-то создать (setup
), но и что-то почистить после использования (teardown
) – например, закрыть соединение с базой данных, удалить временные файлы и т.д.
pytest
предлагает два основных способа для этого:
- Использование
yield
(рекомендуемый способ):
Функция-фикстура выполняется доyield
. Значение, которое передается вyield
, становится результатом фикстуры. После того как тест (или все тесты в scope фикстуры) отработают, код послеyield
выполняется как блок очистки.
@pytest.fixture(scope="function") def temp_file_handler(): print("\n[Фикстура temp_file_handler]: Создание временного файла...") # Допустим, мы создали файл temp.txt f = open("temp.txt", "w") f.write("Hello from fixture!") f.close() file_path = "temp.txt" yield file_path # Передаем путь к файлу в тест # Код очистки после yield print(f"\n[Фикстура temp_file_handler]: Удаление файла {file_path}...") import os if os.path.exists(file_path): os.remove(file_path) def test_read_from_temp_file(temp_file_handler): file_path = temp_file_handler print(f"\n[Тест test_read_from_temp_file]: Чтение из {file_path}...") with open(file_path, "r") as f: content = f.read() assert content == "Hello from fixture!"
После выполнения test_read_from_temp_file
, код после yield
в temp_file_handler
удалит созданный файл.
- Использование
request.addfinalizer
:
Если по какой-то причинеyield
использовать неудобно (например, в очень старом коде или при сложной логике), можно использовать объектrequest
(специальная встроенная фикстура), чтобы зарегистрировать функцию-финализатор.
@pytest.fixture(scope="function") def legacy_resource(request): print("\n[Фикстура legacy_resource]: Инициализация ресурса...") resource = {"state": "active"} def finalizer(): print("\n[Фикстура legacy_resource - finalizer]: Очистка ресурса...") resource["state"] = "cleaned" request.addfinalizer(finalizer) # Регистрируем финализатор return resource def test_use_legacy_resource(legacy_resource): print(f"\n[Тест test_use_legacy_resource]: Используем {legacy_resource}...") assert legacy_resource["state"] == "active"
Финализатор finalizer
будет вызван после того, как тест test_use_legacy_resource
завершится.
Совет: Использование yield
для setup
/teardown
обычно предпочтительнее, так как код подготовки и очистки находится в одной функции, что делает его более читаемым и легким для понимания. addfinalizer
может быть полезен в более сложных сценариях или при работе с контекстными менеджерами.
Фух! Мы рассмотрели основы работы с фикстурами. Главное — понять их scope
и как они передают зависимости. В следующем разделе мы поговорим о маркерах, которые помогут нам еще лучше управлять нашими тестами.
Маркеры (marks
): Наводим порядок и управляем выполнением тестов
По мере роста вашего проекта количество тестов может легко перевалить за сотни, а то и тысячи. Запускать их все каждый раз может быть долго и не всегда необходимо. Иногда хочется запустить только определенную категорию тестов (например, только быстрые "юнит-тесты" или только тесты, связанные с определенным модулем). Вот тут-то на помощь и приходят маркеры.
Маркеры в pytest
— это специальные метки, которые вы можете присваивать тестовым функциям или классам. Они позволяют:
- Категоризировать тесты.
- Пропускать тесты: Условно или безусловно.
- Отмечать ожидаемо падающие тесты (
xfail
). - Параметризовать тесты.
- Влиять на поведение плагинов: Некоторые плагины используют маркеры для своей работы.
- Набор быстрых "дымовых" тестов (
smoke
), которые проверяют самую базовую функциональность и должны выполняться очень часто. - Более полный набор регрессионных тестов (
regression
), которые проверяют все аспекты системы, но могут занимать значительное время. - Тесты, требующие доступа к базе данных (
database_access
). - Тесты, которые вы временно хотите отключить (
skip
).
С помощью маркеров вы можете легко управлять, какие из этих групп тестов будут запущены.
Встроенные маркеры
pytest поставляется с несколькими полезными встроенными маркерами.
skip
и skipif
: пропускаем тесты
@pytest.mark.skip(reason="...")
: Безусловно пропускает тест. reason — это необязательное описание, почему тест пропущен.
# test_skipping.py import pytest import sys @pytest.mark.skip(reason="Этот тест еще не готов, WIP") def test_new_feature_in_progress(): assert False # Этот код не выполнится def test_another_one(): assert True
@pytest.mark.skipif(condition, reason="...")
: Пропускает тест, если condition истинно. condition может быть любым Python-выражением.
# test_skipping.py (продолжение) IS_WINDOWS = sys.platform.startswith("win") @pytest.mark.skipif(IS_WINDOWS, reason="Тест специфичен для Linux/macOS") def test_linux_specific_functionality(): # Код, который работает только на не-Windows системах assert True @pytest.mark.skipif(sys.version_info < (3, 10), reason="Требуется Python 3.10+") def test_feature_for_python310_plus(): # Код, использующий возможности Python 3.10+ assert True
При запуске pytest -v
вы увидите, что пропущенные тесты отмечены буквой s
(skipped) и указана причина.
test_skipping.py::test_new_feature_in_progress SKIPPED (Этот тест еще не готов, WIP) [ 25%] test_skipping.py::test_another_one PASSED [ 50%] test_skipping.py::test_linux_specific_functionality SKIPPED (Тест специфичен для Linux/macOS) [ 75%] test_skipping.py::test_feature_for_python310_plus SKIPPED (Требуется Python 3.10+) [100%]
xfail
: ожидаемые падения и их обработка
@pytest.mark.xfail(condition=None, reason="...", raises=None, run=True, strict=False)
: Означает "expected failure" (ожидаемый провал).
- Если тест с таким маркером падает, он отмечается как xfailed (ожидаемо упавший,
x
в выводе), и это не проваливает всю тестовую сессию. - Если тест с таким маркером неожиданно проходит, он отмечается как XPASS (
X
в выводе). По умолчанию это тоже не проваливает сессию, но может указывать на то, что баг был исправлен, и маркер xfail можно убирать. condition
: ЕслиTrue
, тест маркируется как xfail.reason
: Описание.raises=ExpectedException
: Указывает, что тест должен упасть именно с этим исключением. Если упадет с другим или не упадет, будет XPASS или обычный FAIL.run=False
: Тест вообще не будет запускаться, сразу помечается как xfailed.strict=True
: Если тест сxfail(strict=True)
неожиданно проходит (XPASS), это будет считаться ошибкой и провалит тестовую сессию. Полезно, чтобы не забывать убиратьxfail
после исправления бага.
# test_xfail.py import pytest @pytest.mark.xfail(reason="Известный баг #123, будет исправлен") def test_known_bug_division_by_zero(): result = 1 / 0 # Ожидаемо упадет с ZeroDivisionError assert result # До сюда не дойдет @pytest.mark.xfail(raises=ValueError, reason="Ожидаем ValueError") def test_specific_exception_expected(): raise ValueError("Что-то пошло не так, как мы ожидали") @pytest.mark.xfail(strict=True, reason="Этот баг должен быть уже исправлен!") def test_should_pass_now_but_marked_xfail(): # Представим, что баг исправили assert 1 + 1 == 2 # Тест пройдет, но из-за strict=True будет FAIL
Вывод для test_known_bug_division_by_zero
:
test_xfail.py::test_known_bug_division_by_zero XFAIL (Известный баг #123, будет исправлен)
Вывод для test_should_pass_now_but_marked_xfail
(если он пройдет):
test_xfail.py::test_should_pass_now_but_marked_xfail FAILED (XPASS...)
xfail
полезен для отслеживания известных багов, которые еще не исправлены, но тесты для них уже написаны.
Пользовательские маркеры: создаем свои категории
Вы можете создавать свои собственные маркеры для категоризации тестов. Имя маркера может быть любым (но лучше придерживаться правил именования идентификаторов Python).
# test_custom_markers.py import pytest @pytest.mark.smoke def test_login_page_loads(): # Логика проверки загрузки страницы логина assert True @pytest.mark.regression @pytest.mark.api def test_user_creation_api(): # Логика теста API создания пользователя assert True @pytest.mark.slow @pytest.mark.database def test_full_report_generation(): import time time.sleep(2) # Имитация долгой операции # Логика генерации большого отчета с запросами к БД assert True class TestCheckout: @pytest.mark.smoke @pytest.mark.ui def test_add_to_cart_button_visible(self): assert True @pytest.mark.regression @pytest.mark.ui def test_full_checkout_process(self): assert True
Здесь мы использовали маркеры smoke
, regression
, api
, slow
, database
, ui
. Тест или класс может иметь несколько маркеров.
Регистрация маркеров в pytest.ini
или pyproject.toml
Если вы используете пользовательские маркеры, pytest
при запуске выдаст предупреждение (PytestUnknownMarkWarning
), если они не зарегистрированы. Это сделано для того, чтобы избежать опечаток в именах маркеров.
Зарегистрировать маркеры можно в конфигурационном файле pytest.ini
(в корне проекта) или в pyproject.toml
.
[pytest] markers = smoke: тесты для быстрого дымового тестирования regression: полные регрессионные тесты api: тесты, взаимодействующие с API slow: медленные тесты, которые можно пропускать при быстром прогоне database: тесты, требующие доступа к базе данных ui: тесты пользовательского интерфейса
После имени маркера можно указать его описание.
pyproject.toml (альтернативный вариант):
[tool.pytest.ini_options] markers = [ "smoke: тесты для быстрого дымового тестирования", "regression: полные регрессионные тесты", "api: тесты, взаимодействующие с API", "slow: медленные тесты, которые можно пропускать при быстром прогоне", "database: тесты, требующие доступа к базе данных", "ui: тесты пользовательского интерфейса", ]
Теперь pytest
не будет выдавать предупреждений и даже покажет описания маркеров, если запустить pytest --markers
.
Совет: Всегда регистрируйте свои пользовательские маркеры! Это помогает поддерживать порядок и избегать случайных ошибок из-за опечаток. Флаг --strict-markers при запуске pytest превратит предупреждения о неизвестных маркерах в ошибки, что еще надежнее.
Запуск тестов по маркерам: опция -m
Самое главное преимущество маркеров — возможность выборочно запускать тесты. Для этого используется опция командной строки -m <выражение_маркера>
.
- Запустить тесты с определенным маркером:
pytest -m smoke
(запуститtest_login_page_loads
иtest_add_to_cart_button_visible
) - Запустить тесты, НЕ имеющие определенного маркера:
pytest -m "not slow"
(запустит все тесты, кромеtest_full_report_generation
) - Запустить тесты, имеющие маркер А И маркер Б (логическое И):
pytest -m "api and regression"
(запуститtest_user_creation_api
) - Запустить тесты, имеющие маркер А ИЛИ маркер Б (логическое ИЛИ):
pytest -m "smoke or api"
(запуститtest_login_page_loads
,test_add_to_cart_button_visible
,test_user_creation_api
) - Сложные выражения: Можно использовать скобки для группировки.
pytest -m "(smoke or ui) and not slow"
Опция -m
гибкая и позволяет очень тонко настраивать, какие именно тесты вы хотите выполнить в данный момент. Это особенно полезно в CI/CD пайплайнах, где можно настроить разные этапы тестирования (например, быстрые smoke-тесты на каждый коммит, полные regression-тесты ночью).
Маркеры, в сочетании с фикстурами, составляют основу гибкости pytest
. В следующем разделе мы подробно разберем маркер @pytest.mark.parametrize
, который позволяет переиспользовать тестовую логику.
Параметризация тестов с @pytest.mark.parametrize
: один тест – множество проверок
Часто бывает так, что одну и ту же логику нужно проверить с разными наборами входных данных и ожидаемых результатов. Например, тестируя функцию, которая форматирует строки, вы захотите проверить ее поведение с пустой строкой, строкой с пробелами, строкой со спецсимволами, очень длинной строкой и так далее.
Без параметризации вам пришлось бы писать отдельные тестовые функции для каждого случая, что привело бы к большому количеству дублирующегося кода:
# НЕ ОЧЕНЬ ХОРОШИЙ ПРИМЕР (без параметризации) def test_is_even_two(): assert is_even(2) is True def test_is_even_four(): assert is_even(4) is True def test_is_even_negative_two(): assert is_even(-2) is True def test_is_even_three(): assert is_even(3) is False def test_is_even_one(): assert is_even(1) is False # ... и так далее для других чисел
Это не только утомительно писать, но и сложно поддерживать. Если логика is_even
изменится, или вы захотите добавить новый аспект в проверку, придется править все эти функции.
Вот здесь-то и поможет @pytest.mark.parametrize
! Маркер @pytest.mark.parametrize
позволяет вам определить одну тестовую функцию и затем запускать ее несколько раз с различными аргументами, которые вы ей передадите через маркер. Каждый такой запуск рассматривается pytest как отдельный тест.
Основная идея: отделить логику теста от тестовых данных.
Синтаксис и примеры: от простых значений до сложных наборов данных
Синтаксис маркера следующий:@pytest.mark.parametrize("имя_аргумента_1, имя_аргумента_2, ...", [список_значений])
- Первый аргумент маркера (
"имя_аргумента_1, имя_аргумента_2, ..."
) – это строка, содержащая имена аргументов, которые будут переданы в тестовую функцию, разделенные запятыми. - Второй аргумент маркера (
[список_значений]
) – это список (или другой итерируемый объект) кортежей. Каждый кортеж в этом списке представляет один набор значений для аргументов, указанных в первой строке. Количество элементов в каждом кортеже должно совпадать с количеством имен аргументов.
Давайте перепишем наш пример с is_even
с использованием параметризации:
import pytest # Допустим, у нас есть такая функция для тестирования def is_even(number): return number % 2 == 0 @pytest.mark.parametrize("number, expected_result", [ (2, True), (4, True), (-2, True), (0, True), (3, False), (1, False), (-7, False), (1000000, True), # Можно добавлять сколько угодно тестовых случаев ]) def test_is_even_parametrized(number, expected_result): """Проверяет функцию is_even с различными входными данными.""" print(f"\nТестируем число: {number}, ожидаем: {expected_result}") # Для наглядности в выводе assert is_even(number) == expected_result
Теперь, если запустить pytest -v -s
(чтобы видеть print
и имена тестов):
============================= test session starts ============================== # ... collected 8 items test_even.py::test_is_even_parametrized[2-True] Тестируем число: 2, ожидаем: True PASSED test_even.py::test_is_even_parametrized[4-True] Тестируем число: 4, ожидаем: True PASSED test_even.py::test_is_even_parametrized[-2-True] Тестируем число: -2, ожидаем: True PASSED test_even.py::test_is_even_parametrized[0-True] Тестируем число: 0, ожидаем: True PASSED test_even.py::test_is_even_parametrized[3-False] Тестируем число: 3, ожидаем: False PASSED test_even.py::test_is_even_parametrized[1-False] Тестируем число: 1, ожидаем: False PASSED test_even.py::test_is_even_parametrized[-7-False] Тестируем число: -7, ожидаем: False PASSED test_even.py::test_is_even_parametrized[1000000-True] Тестируем число: 1000000, ожидаем: True PASSED ============================== 8 passed in 0.02s ===============================
- У нас всего одна тестовая функция
test_is_even_parametrized
. pytest
сгенерировал 8 отдельных тестов на основе списка значений, который мы передали в parametrize.- Имена сгенерированных тестов включают значения параметров (например,
[2-True]
), что помогает легко идентифицировать, какой именно набор данных привел к ошибке, если тест упадет. - Если один из этих параметризованных тестов упадет, остальные все равно будут выполнены.
Можно параметризовать и по одному аргументу:
# test_palindrome.py import pytest def is_palindrome(text): processed_text = "".join(filter(str.isalnum, text)).lower() return processed_text == processed_text[::-1] @pytest.mark.parametrize("palindrome_candidate", [ "", "a", "Bob", "Never odd or even", "Do geese see God?", "racecar", ]) def test_is_palindrome_positive(palindrome_candidate): assert is_palindrome(palindrome_candidate) is True @pytest.mark.parametrize("non_palindrome_candidate", [ "abc", "abab", "almostomla", # Почти, но не палиндром ]) def test_is_palindrome_negative(non_palindrome_candidate): assert is_palindrome(non_palindrome_candidate) is False
Использование pytest.param
для установки маркеров и ID для параметризованных тестов
Иногда для отдельных наборов параметров в parametrize
хочется задать специальные маркеры (например, xfail
для известного бага с конкретными входными данными) или более осмысленные идентификаторы (ID) для лучшей читаемости отчетов. Для этого используется pytest.param
.
pytest.param(*values, marks=(), id=None)
*values
: Значения для аргументов теста.marks
: Один маркер или список маркеров, которые будут применены только к этому набору параметров.id
: Строковый идентификатор для этого набора параметров. Если не указан,pytest
генерирует ID автоматически из значений.
# test_advanced_parametrize.py import pytest def process_data(data, config=None): if config == "special_case" and data == 0: raise ValueError("0 is not allowed with special_case config") if data < 0: return "negative" if data == 0: return "zero" if data > 0 and data < 10: return "small_positive" return "large_positive" @pytest.mark.parametrize("input_data, config_param, expected_output", [ (5, None, "small_positive"), (-10, None, "negative"), (0, None, "zero"), (100, "some_config", "large_positive"), pytest.param(0, "special_case", None, marks=pytest.mark.xfail(raises=ValueError, reason="Bug #456: 0 with special_case")), pytest.param(7, "user_config", "small_positive", id="user_config_small_positive_case"), pytest.param(99, None, "large_positive", id="boundary_large_positive_no_config"), ]) def test_process_data_with_params(input_data, config_param, expected_output): if config_param: result = process_data(input_data, config=config_param) else: result = process_data(input_data) assert result == expected_output
- Для набора
(0, "special_case", None)
мы ожидаем, что тест упадет сValueError
из-за известного бага, поэтому помечаем егоxfail
. - Для наборов
(7, "user_config", ...)
и(99, None, ...)
мы задали кастомныеid
, которые будут отображаться в отчете, делая его более читаемым:
Это очень удобно для сложных тестовых сценариев.
Когда параметризация – благо, а когда лучше создать отдельные тесты
Параметризацию нужно использовать с умом.
Когда параметризация — это хорошо:
- Однотипные проверки: Когда у вас есть одна и та же логика проверки, но с разными входными данными и/или ожидаемыми результатами.
- Тестирование граничных условий: Легко добавить множество граничных значений, не раздувая код.
- Читаемость тестовых данных: Все тестовые случаи собраны в одном месте, что упрощает их анализ.
- Комбинаторное тестирование: Можно параметризовать по нескольким аргументам, чтобы проверить различные их комбинации (хотя для очень большого числа комбинаций могут потребоваться более продвинутые техники или плагины).
Когда стоит задуматься об отдельных тестах (или более мелкой параметризации):
- Разная логика проверки: Если для разных входных данных вам нужно выполнять совершенно разные наборы
assert
'ов или разную логику подготовки/очистки внутри самого теста (а не в фикстурах), то параметризация может сделать тест слишком сложным и нечитаемым. В этом случае лучше разделить на несколько более сфокусированных параметризованных тестов или даже на отдельные непараметризованные тесты. - Очень разные сценарии: Если наборы параметров представляют собой кардинально отличающиеся сценарии использования, параметризация может скрыть эту разницу за общим именем теста. Иногда более явные имена отдельных тестовых функций улучшают понимание.
- Чрезмерно длинный список параметров: Если список параметров становится огромным (десятки или сотни строк), это может затруднить его восприятие. Возможно, стоит сгруппировать их по какому-то признаку в несколько параметризованных тестов.
Золотое правило: Тест должен быть читаемым и понятным. Если параметризация усложняет понимание того, что и как тестируется, возможно, это не лучший выбор для данного конкретного случая. Ищите баланс.
Плагины: расширяем возможности Pytest
Одна из самых сильных сторон pytest
— его архитектура, основанная на плагинах. Практически любая часть фреймворка может быть расширена или изменена с помощью плагинов. Это привело к появлению огромной экосистемы плагинов, созданных сообществом, которые решают самые разнообразные задачи — от интеграции с другими фреймворками до улучшения отчетов и добавления новых способов тестирования.
Философия плагинов Pytest: как это работает
pytest
имеет хорошо продуманную систему хуков (hooks) — специальных функций, которые плагины могут реализовывать для вмешательства в различные этапы жизненного цикла тестирования. Например, есть хуки для:
- Сбора тестов (
pytest_collect_file
,pytest_pycollect_makeitem
) - Запуска тестов (
pytest_runtest_setup
,pytest_runtest_call
,pytest_runtest_teardown
) - Формирования отчетов (
pytest_report_header
,pytest_terminal_summary
) - Добавления опций командной строки (pytest_addoption)
- Регистрации фикстур и маркеров из плагина.
Когда pytest
запускается, он обнаруживает установленные плагины (в том числе и ваши собственные из conftest.py
, которые по сути тоже являются плагинами) и регистрирует их хуки. Во время выполнения тестовой сессии pytest
вызывает эти хуки в определенных точках, позволяя плагинам добавлять свою логику.
Нам не нужно "патчить" ядро фреймворка, чтобы добавить нужную функциональность — скорее всего, это можно сделать через плагин.
Несколько плагинов, которые упростят жизнь
Существует огромное количество плагинов для pytest
. Вот лишь несколько популярных и очень полезных, которые стоит рассмотреть для своих проектов:
pytest-cov:
измеряем покрытие кода тестами
Установка: pip install pytest-cov
Этот плагин интегрирует pytest с инструментом Coverage.py для измерения того, какая часть вашего кода покрыта тестами. Отчеты о покрытии — важный показатель качества вашего тестового набора (хотя и не единственный!).
Использование:
После установки, просто добавьте опцию --cov
при запуске pytest
:
pytest --cov=my_project_module # Укажите ваш модуль или пакет
Или для всего текущего каталога (если там ваш код):
pytest --cov=.
Вы увидите таблицу с процентом покрытия для каждого файла:
----------- coverage: platform ... ----------- Name Stmts Miss Cover --------------------------------------------------- my_project_module/__init__.py 2 0 100% my_project_module/main.py 25 5 80% my_project_module/utils.py 12 1 92% --------------------------------------------------- TOTAL 39 6 85%
Можно также генерировать HTML-отчеты для более детального анализа:
pytest --cov=my_project_module --cov-report=html
Это создаст директорию htmlcov/
, в которой будет лежать index.html
с интерактивным отчетом, где подсвечены покрытые и не покрытые строки кода.
pytest-xdist
: распараллеливаем выполнение тестов для ускорения
Установка: pip install pytest-xdist
Если у вас большой тестовый набор, его выполнение может занимать много времени. pytest-xdist
позволяет распараллелить выполнение тестов на несколько CPU-ядер вашего компьютера или даже на несколько машин. Это может значительно сократить время ожидания результатов.
Использование:
Добавьте опцию -n
(или --numprocesses
) с указанием количества процессов:
pytest -n auto # Автоматически определить количество доступных CPU-ядер
pytest -n 4 # Запустить тесты в 4 процессах
Важно при использовании pytest-xdist
:
Ваши тесты должны быть независимыми друг от друга! Если тесты изменяют общее состояние (например, файлы на диске без должной изоляции, или глобальные переменные, что само по себе плохо), распараллеливание может привести к непредсказуемым результатам или конфликтам. Фикстуры с scope
шире function
также требуют внимательного подхода при распараллеливании.
pytest-xdist
также предоставляет другие режимы, например, pytest --dist=each
для запуска всех тестов на каждой из нескольких удаленных машин (полезно для кросс-платформенного тестирования).
pytest-randomly
: выявляем скрытые зависимости между тестами
Установка: pip install pytest-randomly
Иногда тесты могут проходить успешно только потому, что они выполняются в определенном порядке. Это указывает на скрытые зависимости между ними (например, один тест подготавливает состояние, которое использует другой, но это не сделано явно через фикстуры). Такие зависимости — это бомба замедленного действия.
pytest-randomly
помогает выявить такие проблемы, запуская ваши тесты каждый раз в случайном порядке. Если после установки этого плагина ваши тесты внезапно начинают падать (хотя раньше проходили), это верный признак наличия неявных зависимостей.
Использование:
Плагин активируется автоматически после установки. Порядок тестов будет меняться при каждом запуске pytest
.
Если вам нужно воспроизвести определенный "случайный" порядок, который привел к ошибке, pytest-randomly
выводит seed значение, которое можно использовать для повторного запуска: pytest --randomly-seed=12345
.
pytest-sugar:
делаем вывод ещё приятнее
Установка: pip install pytest-sugar
pytest-sugar
— это плагин, который не добавляет новой функциональности для написания тестов, но делает стандартный вывод pytest
более наглядным и приятным для глаз. Он добавляет прогресс-бар, лучше выделяет ошибки и в целом улучшает пользовательский опыт при работе с консолью.
Использование: Просто установите его, и он автоматически изменит вид вывода pytest
.
И это лишь малая часть доступных плагинов. Есть плагины для интеграции с веб-фреймворками (pytest-django
, pytest-flask
), асинхронного тестирования (pytest-asyncio
), работы с БД, генерации тестовых данных (pytest-factoryboy
) и многого другого. Исследуйте экосистему и находите инструменты, которые помогут вам писать тесты еще эффективнее.
В следующем разделе мы соберем воедино все, что узнали, и поговорим о лучших практиках.
Лучшие практики
Знать инструменты — это полдела. pytest
предоставляет огромную гибкость, но с большой силой приходит и большая ответственность. 😉 Вот несколько советов и практик, которые помогут вам выжать максимум из pytest
и избежать распространенных ловушек.
Структура тестовой директории и именование файлов/тестов
Хотя pytest
довольно гибок в обнаружении тестов, следование общепринятым соглашениям упрощает навигацию и понимание проекта:
- Директория
tests/
: Обычно тесты размещают в отдельной директории tests/ в корне проекта. Это отделяет тестовый код от кода приложения.
my_project/ ├── src/ # Или my_app/ - ваш основной код │ └── ... ├── tests/ │ ├── unit/ │ │ └── test_utils.py │ ├── integration/ │ │ └── test_api.py │ └── conftest.py # Общие фикстуры для всех тестов ├── pytest.ini └── ...
- Зеркальная структура: Иногда внутри
tests/
создают структуру, зеркально отражающую структуру вашего приложения вsrc/
. Например, если у вас естьsrc/services/payment_service.py
, тесты для него могут лежать вtests/services/test_payment_service.py
. Это помогает быстро находить тесты для конкретного модуля. - Именование файлов:
test_*.py
или *_test.py. Классика — test_*.py (например, test_user_model.py). - Именование тестовых функций/методов:
test_*
. Имя должно четко описывать, что именно проверяет тест. Например, вместоtest_user()
лучшеtest_user_creation_with_valid_data()
илиtest_user_cannot_be_created_with_invalid_email()
. - Именование тестовых классов (если используете):
Test*
(например,TestUserModel
). Классы полезны для группировки связанных тестов, особенно если они используют общие фикстуры сscope="class"
.
Атомарность и независимость тестов
Это золотое правило тестирования, и pytest
помогает ему следовать:
- Один тест — одна проверка (по возможности): Каждый тест должен фокусироваться на проверке одного конкретного аспекта поведения или одной логической единицы. Не пытайтесь впихнуть в один тест проверку всего и вся. Если тест падает, вы должны сразу понимать, какая именно функциональность сломалась.
- Тесты не должны зависеть друг от друга: Порядок выполнения тестов не должен влиять на их результат.
pytest
по умолчанию запускает тесты в порядке их обнаружения, но плагины типаpytest-randomly
илиpytest-xdist
могут этот порядок менять. Если ваши тесты зависимы, они станут хрупкими. - Избегайте изменения общего состояния: Если фикстура имеет
scope
шире, чемfunction
, убедитесь, что тесты не изменяют ее состояние таким образом, что это повлияет на другие тесты. Если нужно изменяемое состояние, используйтеscope="function"
или создавайте копию данных из фикстуры с более широким scope. - Явные зависимости через фикстуры: Все, что нужно тесту для работы, он должен получать через фикстуры.
Читаемость тестов: пишем код, который легко понять
Тесты — это тоже код. И они должны быть не менее (а то и более) читаемыми, чем основной код приложения. Ведь тесты служат еще и документацией поведения системы.
- Описательные имена: Как уже говорилось, имена тестовых функций, фикстур и переменных должны быть говорящими.
- Структура "Arrange-Act-Assert" (AAA): Старайтесь придерживаться этой структуры внутри теста:
def test_user_can_change_password(active_user_fixture, new_password_data): # Arrange (фикстуры) # Arrange (дополнительно, если нужно) old_password_hash = active_user_fixture.password_hash # Act success = active_user_fixture.change_password(new_password_data["new_password"]) # Assert assert success is True assert active_user_fixture.password_hash != old_password_hash # ... другие проверки ...
- Arrange (Подготовка): Подготовка данных, моков, состояния. Часто это вынесено в фикстуры.
- Act (Действие): Вызов тестируемой функции или метода.
- Assert (Проверка): Проверка того, что результат соответствует ожиданиям.
- Минимум логики в тестах: Сами тесты должны быть простыми. Сложная логика (циклы, условия) внутри теста затрудняет его понимание и может сама содержать ошибки. Если нужна сложная подготовка данных, выносите ее в фикстуры или вспомогательные функции.
- Комментарии по делу: Комментируйте неочевидные моменты или важные предположения, но не загромождайте код избыточными комментариями. Хорошо написанный тест часто говорит сам за себя.
Использование встроенной фикстуры tmp_path
для работы с временными файлами
Если вашим тестам нужно создавать временные файлы или директории (например, для тестирования функций, работающих с файловой системой), используйте встроенную фикстуру tmp_path
.
tmp_path
(сscope="function"
) предоставляет объектpathlib.Path
к уникальной временной директории для каждого теста.tmp_path_factory
(сscope="session"
) — это фабрика, которую можно использовать для создания временных директорий с более широким scope, если нужно (например, одна директория на весь модуль).
# test_file_operations.py import pytest def write_and_read_file(base_path, filename, content): file_path = base_path / filename file_path.write_text(content) return file_path.read_text() def test_file_creation_and_read(tmp_path): # tmp_path - это фикстура! # tmp_path это объект pathlib.Path, указывающий на временную директорию # созданную специально для этого теста # Arrange test_filename = "my_test_file.txt" test_content = "Hello, pytest temporary files!" # Act result_content = write_and_read_file(tmp_path, test_filename, test_content) # Assert assert result_content == test_content assert (tmp_path / test_filename).exists() # Директория, созданная tmp_path, будет автоматически удалена после теста.
tmp_path
— это чистый и надежный способ работать с временными файлами, не загрязняя файловую систему и не беспокоясь об очистке.
Отчеты о длительности (--durations
) и оптимизация медленных тестов
Медленные тесты — это боль. Они замедляют цикл разработки и могут привести к тому, что разработчики будут реже их запускать. pytest
помогает выявлять "узкие места":
pytest --durations=N
: Показывает N самых медленных тестов в отчете. Например,pytest --durations=10
покажет топ-10 медленных тестов.
=========================== slowest 10 durations =========================== 5.32s call tests/integration/test_heavy_processing.py::test_generate_large_report 2.15s setup tests/integration/test_database_setup.py::test_complex_db_query (my_db_fixture) 1.08s call tests/api/test_external_service.py::test_timeout_handling ... (7 durations < 0.005s hidden. Use -vv to show these durations.)
Это отличная отправная точка для оптимизации.
- Анализируйте медленные тесты:
- Действительно ли тесту нужно делать столько работы?
- Можно ли заменить реальные внешние вызовы (сеть, БД) моками или стабами, если это не интеграционный тест?
- Эффективно ли используются фикстуры (например, не создается ли дорогой ресурс на каждый тест, если достаточно
scope="module"
илиscope="session"
)? - Маркируйте медленные тесты: Используйте маркер (например,
@pytest.mark.slow
), чтобы можно было их пропускать при локальных быстрых прогонах (pytest -m "not slow"
), но обязательно запускать в CI.
Ловушки и антипаттерны: чего стоит избегать
- Слишком много моков (Over-mocking): Моки — полезный инструмент, но если вы мокаете почти все зависимости тестируемого объекта, ваш тест может стать бессмысленным — он будет проверять только взаимодействие с моками, а не реальную логику. Тестируйте контракт, а не реализацию.
- Тесты, которые ничего не проверяют: Убедитесь, что каждый тест содержит хотя бы один
assert
. Тест безassert
'ов всегда будет проходить (если не упадет с исключением) и создаст ложное чувство безопасности. - Хрупкие тесты (Flaky tests): Тесты, которые то проходят, то падают без видимых изменений в коде. Часто это связано с проблемами асинхронности, зависимостью от времени, неконтролируемым внешним состоянием или гонками данных при параллельном выполнении. Отлавливать и исправлять такие тесты — приоритетная задача.
- Игнорирование упавших тестов: Не откладывайте исправление упавших тестов "на потом". "Потом" часто не наступает, а красный билд в CI демотивирует команду.
- Чрезмерное использование
autouse=True
для фикстур: Как уже говорилось, это может сделать зависимости неявными. - Слишком сложные фикстуры: Если фикстура становится очень большой и сложной, возможно, ее стоит разбить на несколько более мелких и сфокусированных фикстур, которые могут зависеть друг от друга.
- Тестирование приватных методов: Как правило, стоит тестировать публичный интерфейс ваших классов и модулей. Если вам кажется, что нужно протестировать приватный метод, это может быть сигналом, что этот метод выполняет слишком много работы и его стоит вынести в отдельный класс/функцию с собственным публичным интерфейсом.
Помните: Тесты — это инвестиция. Хорошо написанные тесты экономят время и нервы в долгосрочной перспективе, помогая быстрее находить баги, увереннее проводить рефакторинг и поддерживать высокое качество кода.
Заключение
Что ж, мы с вами совершили довольно глубокое погружение в мир pytest
. От первых шагов и сравнения с "классикой" в лице unittest
до продвинутых техник с использованием фикстур, маркеров, параметризации и плагинов — надеюсь, вы увидели, насколько мощным и гибким может быть этот инструмент в умелых руках.
pytest
— это целая философия, направленная на то, чтобы сделать процесс написания тестов более продуктивным, интуитивным и, не побоюсь этого слова, приятным.
Конечно, всё это требует некоторого времени на освоение всех нюансов. Но это всё окупится.
Не бойтесь экспериментировать, пробовать разные подходы и плагины. Изучайте документацию, читайте статьи (вроде этой 😉) и делитесь своим опытом с коллегами. Надеюсь, эта статья дала вам хороший старт или помогла систематизировать уже имеющиеся знания. Теперь дело за вами — идите и напишите несколько тестов! Удачи, и пусть ваши билды всегда будут зелеными! 🚀