Статьи
April 17, 2023

Pytest: фикстуры, маркеры, плагины и лучшие практики тестирования

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

Сегодня мы изучаем pytest. Забудьте о громоздких классах unittest (хотя pytest с ними тоже дружит, об этом позже 😉) и бесконечных self.assertSomething() методах. Мы поговорим о том, как pytest с его элегантными фикстурами, гибкими маркерами, мощной системой плагинов и здравыми практиками может упростить рутину написания тестов.

pytest vs unittest: краткое сравнение

Если вы изучали тестирования на Python, то, скорее всего, начинали с модуля unittest, встроенного в стандартную библиотеку. Это, без сомнения, надежный инструмент, этакий "дедушка" тестирования в Python.

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

Меньше шаблонного кода, больше фокуса на логике

Одно из первых, что бросается в глаза при переходе с unittest на pytest, – это значительное сокращение бойлерплейта.

В unittest для каждого набора тестов нам нужно:

  1. Импортировать TestCase из unittest.
  2. Создать класс, наследующийся от TestCase.
  3. Определить каждый тест как метод этого класса, имя которого начинается с test_.
  4. Использовать специальные методы self.assert*() для проверок.

Давайте посмотрим на простой пример – тест, который всегда проходит, и тест, который всегда падает.

unittest вариант:

# 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.

pytest вариант:

# 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. Рекомендуется делать это в виртуальном окружении вашего проекта, чтобы изолировать зависимости.

Если у вас еще нет виртуального окружения, создайте и активируйте его:

Windows (PowerShell):

python -m venv venv
.\venv\Scripts\Activate.ps1

Linux/macOS (bash/zsh):

python3 -m venv venv
source venv/bin/activate

Теперь, когда виртуальное окружение активно (вы должны видеть (venv) в начале строки терминала), устанавливаем pytest:

pip install pytest

Вот и всё! pytest установлен и готов к работе.

Структура тестов: файлы test_*.py и функции test_*

pytest придерживается простых соглашений для обнаружения тестов:

  1. Файлы с тестами: Он ищет файлы с именами, начинающимися с test_ (например, test_example.py) или заканчивающимися на _test.py (например, example_test.py).
  2. Тестовые функции и методы: Внутри этих файлов он ищет функции, имена которых начинаются с test_ (например, def test_my_feature():).
  3. Тестовые классы: Если вы предпочитаете группировать тесты в классы (хотя это и не обязательно, как в unittest), то класс должен называться TestSomething (начинаться с Test), и его методы, начинающиеся с test_, будут считаться тестами. pytest не требует, чтобы эти классы наследовались от чего-либо.

Давайте создадим наш первый тестовый файл. Назовем его test_app.py в корне нашего проекта (или в специальной директории tests/, если проект большой).

test_app.py:

# 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. Давайте создадим простую фикстуру, которая возвращает словарь с данными пользователя.

test_users.py:

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":
    • Фикстура выполняется один раз за всю тестовую сессию (за один запуск pytest).
    • Используется для глобальных ресурсов, которые нужны многим тестам в разных модулях, и их создание/уничтожение – очень ресурсоемкая операция (например, запуск внешнего сервиса-заглушки).

Давайте модифицируем наш пример, чтобы показать 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:

# 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:

# 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.ini:

[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 с использованием параметризации:

test_even.py:

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 ===============================

Смотрите, что произошло:

  1. У нас всего одна тестовая функция test_is_even_parametrized.
  2. pytest сгенерировал 8 отдельных тестов на основе списка значений, который мы передали в parametrize.
  3. Имена сгенерированных тестов включают значения параметров (например, [2-True]), что помогает легко идентифицировать, какой именно набор данных привел к ошибке, если тест упадет.
  4. Если один из этих параметризованных тестов упадет, остальные все равно будут выполнены.

Можно параметризовать и по одному аргументу:

# 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, которые будут отображаться в отчете, делая его более читаемым:
    • test_process_data_with_params[user_config_small_positive_case]
    • test_process_data_with_params[boundary_large_positive_no_config]

Это очень удобно для сложных тестовых сценариев.

Когда параметризация – благо, а когда лучше создать отдельные тесты

Параметризацию нужно использовать с умом.

Когда параметризация — это хорошо:

  • Однотипные проверки: Когда у вас есть одна и та же логика проверки, но с разными входными данными и/или ожидаемыми результатами.
  • Тестирование граничных условий: Легко добавить множество граничных значений, не раздувая код.
  • Читаемость тестовых данных: Все тестовые случаи собраны в одном месте, что упрощает их анализ.
  • Комбинаторное тестирование: Можно параметризовать по нескольким аргументам, чтобы проверить различные их комбинации (хотя для очень большого числа комбинаций могут потребоваться более продвинутые техники или плагины).

Когда стоит задуматься об отдельных тестах (или более мелкой параметризации):

  • Разная логика проверки: Если для разных входных данных вам нужно выполнять совершенно разные наборы 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_pathscope="function") предоставляет объект pathlib.Path к уникальной временной директории для каждого теста.
  • tmp_path_factoryscope="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 — это целая философия, направленная на то, чтобы сделать процесс написания тестов более продуктивным, интуитивным и, не побоюсь этого слова, приятным.

Конечно, всё это требует некоторого времени на освоение всех нюансов. Но это всё окупится.

Не бойтесь экспериментировать, пробовать разные подходы и плагины. Изучайте документацию, читайте статьи (вроде этой 😉) и делитесь своим опытом с коллегами. Надеюсь, эта статья дала вам хороший старт или помогла систематизировать уже имеющиеся знания. Теперь дело за вами — идите и напишите несколько тестов! Удачи, и пусть ваши билды всегда будут зелеными! 🚀