Тесты, которые успешно проходят по отдельности, но падают при запуске всей тестовой сюиты или при случайном изменении порядка выполнения, — распространенная проблема в Python-разработке. Причиной такого нестабильного поведения (flaky tests) чаще всего становится неявное общее состояние: не удаленные временные файлы на диске, не сброшенные переменные окружения или мутирующие объекты в памяти.
Фреймворк Pytest предоставляет мощный инструментарий для построения полностью изолированных и детерминированных тестов. На примере небольшого Python-модуля разберем, как правильно организовать conftest.py, управлять жизненным циклом фикстур, безопасно параметризовать проверки и изолировать окружение с помощью tmp_path и monkeypatch.
Анатомия задачи: тестируемый модуль и структура проекта
Рассмотрим модуль, выполняющий файловые операции и считывающий конфигурацию из переменных окружения.
Исходный код модуля (app.py)
import os
from pathlib import Path
DEFAULT_NAME = "default.txt"
def save_text(directory: Path, text: str) -> Path:
"""Сохраняет переданный текст в файл внутри указанной директории."""
path = directory / DEFAULT_NAME
path.write_text(text, encoding="utf-8")
return path
def load_text(directory: Path) -> str:
"""Считывает текст из файла в указанной директории."""
path = directory / DEFAULT_NAME
return path.read_text(encoding="utf-8")
def current_user() -> str:
"""Возвращает имя пользователя из переменной окружения APP_USER."""
user = os.getenv("APP_USER")
if user is None:
raise RuntimeError("APP_USER is not set")
return user
Структура каталогов
Организуем стандартную и масштабируемую структуру проекта:
project/
├── app.py
└── tests/
├── conftest.py
└── test_app.py
Файл conftest.py располагается в корне каталога tests/ и служит центральным реестром фикстур и конфигурации тестового окружения.
Фикстуры и области видимости (Scope)
Фикстуры в Pytest инкапсулируют логику подготовки данных и ресурсов (setup), а также гарантируют их корректную очистку (teardown).
Автоматический поиск фикстур через conftest.py
Pytest автоматически обнаруживает conftest.py в текущей директории и родительских каталогах. Импортировать фикстуры через from conftest import ... не требуется: достаточно указать имя фикстуры в сигнатуре тестовой функции или другой фикстуры.
Пример базового tests/conftest.py:
import pytest
@pytest.fixture
def sample_text() -> str:
"""Возвращает тестовую строку данных."""
return "тестовые данные"
@pytest.fixture
def empty_workspace(tmp_path):
"""Предоставляет изолированный временный каталог."""
return tmp_path
Области видимости (Scope) и управление состоянием
Область видимости (scope) задает время жизни фикстуры:
function(по умолчанию): создается заново для каждого теста и уничтожается сразу после его завершения.class: создается один раз для тестового класса.module: создается один раз на весь модуль (файл с тестами).package: создается один раз на пакет тестов.session: создается один раз за весь сеанс выполненияpytest.
# Фикстура со scope="session" живет весь цикл выполнения тестов
@pytest.fixture(scope="session")
def readonly_config():
return {"api_url": "https://api.example.com", "timeout": 30}
Использование широких областей видимости (module, session) ускоряет выполнение за счет сокращения повторных инициализаций, но создает риск утечки состояния: если один тест изменит объект конфигурации, последующие тесты получат модифицированные данные. Для изменяемых структур и файловых операций безопасным стандартом является область function.
Параметризация тестов через @pytest.mark.parametrize
Декоратор @pytest.mark.parametrize позволяет запускать одну и ту же тестовую логику на различных наборах входных данных без дублирования кода.
Практическое применение параметризации
Проверим корректность сохранения и чтения строк различного формата:
import pytest
from app import load_text, save_text
@pytest.mark.parametrize(
"text",
[
"hello",
"",
"строка в UTF-8",
"спецсимволы: \n\t!@#$%^&*()",
],
)
def test_save_and_load(empty_workspace, text):
save_text(empty_workspace, text)
assert load_text(empty_workspace) == text
При запуске Pytest сгенерирует четыре независимых теста: test_save_and_load[hello], test_save_and_load[] и так далее. Если один из вариантов завершится ошибкой, остальные продолжат выполняться штатно.
Подводный камень: изменяемые объекты в параметрах
Значения из списка параметров передаются в тесты по ссылке без создания глубоких копий. Если тест модифицирует переданный список или словарь внутри тела функции, это может повлиять на логику других проверок. Для изменяемых структур безопаснее передавать неизменяемые типы (например, tuple) либо создавать их копии внутри теста.
Полная изоляция окружения: tmp_path и monkeypatch
Для исключения побочных эффектов на уровне операционной системы Pytest предоставляет встроенные фикстуры tmp_path и monkeypatch.
Изоляция файловой системы с tmp_path
Фикстура tmp_path возвращает уникальный объект pathlib.Path, созданный специально для текущего вызова функции. Это устраняет необходимость вручную создавать и удалять временные каталоги через модуль tempfile.
from app import save_text
def test_save_uses_isolated_directory(tmp_path, sample_text):
file_path = save_text(tmp_path, sample_text)
assert file_path.exists()
assert file_path.read_text(encoding="utf-8") == sample_text
def test_workspace_is_empty_for_each_test(tmp_path):
# Директория гарантированно пуста
assert list(tmp_path.iterdir()) == []
Изоляция переменных окружения с monkeypatch
Фикстура monkeypatch позволяет безопасно подменять переменные окружения, системные пути (sys.path) и атрибуты классов или модулей. Все модификации автоматически откатываются сразу после завершения теста.
import pytest
from app import current_user
def test_current_user_success(monkeypatch):
monkeypatch.setenv("APP_USER", "admin_user")
assert current_user() == "admin_user"
def test_current_user_raises_when_not_set(monkeypatch):
monkeypatch.delenv("APP_USER", raising=False)
with pytest.raises(RuntimeError, match="APP_USER is not set"):
current_user()
Устранение зависимости от порядка запуска тестов
Скрытая зависимость между тестами возникает, когда один тест неявно рассчитывает на результаты работы предыдущего: созданный файл, запись в базе данных или установленную переменную окружения.
Пример антипаттерна
# ПЛОХО: запись по фиксированному пути в файловой системе
def test_step_one():
with open("/tmp/shared_test.txt", "w") as f:
f.write("ready")
def test_step_two():
# Завершится ошибкой, если test_step_two запустится раньше test_step_one
with open("/tmp/shared_test.txt", "r") as f:
data = f.read()
assert data == "ready"
При запуске в случайном порядке (например, с плагином pytest-randomly) такой набор тестов не пройдет проверку.
Чеклист независимости тестов
- Исключите статические пути: используйте
tmp_pathвместо жестко заданных путей (/tmp/...,./temp/...). - Не изменяйте
os.environнапрямую: применяйтеmonkeypatch.setenv()иmonkeypatch.delenv(). - Используйте scope="function" для изменяемых данных: фикстуры, создающие сущности или мутирующие объекты, должны пересоздаваться для каждого теста.
- Очищайте ресурсы через yield: если фикстура выделяет внешние ресурсы, используйте генератор
yieldдля гарантированного выполнения кода очистки:
@pytest.fixture
def managed_resource():
resource = setup_resource()
yield resource
resource.cleanup()
Итоговый файл test_app.py
Полный набор тестов для модуля app.py:
import pytest
from app import current_user, load_text, save_text
@pytest.mark.parametrize(
"text",
[
"hello",
"",
"строка в UTF-8",
],
)
def test_save_and_load(empty_workspace, text):
save_text(empty_workspace, text)
assert load_text(empty_workspace) == text
def test_workspace_is_empty_for_each_test(empty_workspace):
assert list(empty_workspace.iterdir()) == []
def test_save_uses_isolated_directory(empty_workspace, sample_text):
path = save_text(empty_workspace, sample_text)
assert path.exists()
assert path.read_text(encoding="utf-8") == sample_text
def test_current_user_set(monkeypatch):
monkeypatch.setenv("APP_USER", "developer")
assert current_user() == "developer"
def test_current_user_missing(monkeypatch):
monkeypatch.delenv("APP_USER", raising=False)
with pytest.raises(RuntimeError, match="APP_USER is not set"):
current_user()
Часто задаваемые вопросы (FAQ)
Нужно ли импортировать фикстуры из conftest.py в тестовые модули?
Нет. Pytest автоматически обнаруживает фикстуры из файлов conftest.py, находящихся в директории тестов или родительских каталогах. Импорт через from conftest import ... не рекомендуется, так как нарушает механизм разрешения зависимостей Pytest.
Чем tmp_path отличается от модуля tempfile?
tmp_path — это встроенная фикстура Pytest, возвращающая объект pathlib.Path. Она изолирована для каждого теста, кроссплатформенна и управляет жизненным циклом каталогов без необходимости вручную вызывать функции удаления и закрывать файловые дескрипторы.
Почему опасно использовать scope="session" для фикстур, меняющих данные?
При scope="session" экземпляр фикстуры создается один раз на весь запуск. Если один тест изменит состояние этого объекта, последующие тесты получат мутированные данные, что нарушит изоляцию и сделает результат зависимым от порядка выполнения.
Что происходит с изменениями monkeypatch после завершения теста?
Фикстура monkeypatch работает в области видимости function. По завершении выполнения теста все модификации (setenv, delenv, setattr, setitem, chdir) автоматически отменяются, возвращая систему в исходное состояние.
Можно ли передавать одну фикстуру в качестве аргумента другой?
Да. Фикстуры в Pytest поддерживают внедрение зависимостей (dependency injection). Фикстура может принимать другие фикстуры в виде аргументов, если соблюдается правило иерархии областей видимости: фикстура с более широким scope не может зависеть от фикстуры с более узким scope.
Вывод
Надежность автоматизированного тестирования опирается на строгую изоляцию каждого тестового сценария. Вынос общих контекстов в conftest.py, параметризация через @pytest.mark.parametrize и активное использование встроенных средств tmp_path и monkeypatch исключают взаимное влияние тестов, гарантируют воспроизводимость и позволяют без рисков запускать проверки параллельно или в произвольном порядке.



.svg.webp)




