pytest fixtures: setup через dependency injection
pytest fixtures: setup через dependency injection
В прошлом уроке мы писали простые тесты с inline setup. Реальные тесты требуют сложного setup - подключения к БД, тестовых данных, mock-объектов. Дублировать setup в каждом тесте - плохо. Fixtures в pytest решают это через dependency injection - элегантно и явно.
Зачем fixtures
Без fixtures - дублирование:
def test_user_creation():
db = create_test_database()
user = User("Alice")
db.save(user)
assert db.get_user("Alice") is not None
db.close()
def test_user_deletion():
db = create_test_database()
user = User("Alice")
db.save(user)
db.delete(user)
assert db.get_user("Alice") is None
db.close()
Setup и teardown повторяются. С fixtures:
import pytest
@pytest.fixture
def db():
database = create_test_database()
yield database
database.close()
def test_user_creation(db):
user = User("Alice")
db.save(user)
assert db.get_user("Alice") is not None
def test_user_deletion(db):
user = User("Alice")
db.save(user)
db.delete(user)
assert db.get_user("Alice") is None
db параметр - имя fixture. pytest автоматически вызовет fixture и передаст результат. Это dependency injection.
Базовый синтаксис
import pytest
@pytest.fixture
def sample_user():
return {"name": "Alice", "age": 30}
def test_user_name(sample_user):
assert sample_user["name"] == "Alice"
def test_user_age(sample_user):
assert sample_user["age"] == 30
@pytest.fixture декоратор. Возвращаемое значение передаётся в тесты, которые запрашивают fixture по имени.
yield для setup + teardown
@pytest.fixture
def database():
# Setup
db = create_db()
db.connect()
yield db # передаём в тест
# Teardown - выполняется после теста
db.close()
db.cleanup()
def test_query(database):
result = database.query("SELECT 1")
assert result == [1]
Код до yield - setup, после - teardown. yield обязателен (даже без значения). Механика та же, что у генераторов, а по смыслу это аналог контекстного менеджера или try/finally.
Scope - время жизни fixture
@pytest.fixture # function (default) - для каждого теста
@pytest.fixture(scope="class") # class - один на класс
@pytest.fixture(scope="module") # module - один на файл
@pytest.fixture(scope="package") # package - один на пакет
@pytest.fixture(scope="session") # session - один на весь запуск
Пример:
@pytest.fixture(scope="session")
def expensive_resource():
print("setting up") # один раз на весь test run
yield "resource"
print("tearing down")
def test_a(expensive_resource):
assert expensive_resource == "resource"
def test_b(expensive_resource):
assert expensive_resource == "resource"
# setting up
# (тесты пробегают)
# tearing down
session для дорогих ресурсов (DB, web server). function (default) для изолированности. Trade-off: скорость vs независимость тестов.
conftest.py - shared fixtures
Fixtures, нужные в нескольких файлах, выносятся в conftest.py:
# tests/conftest.py
import pytest
@pytest.fixture
def db():
db = create_test_db()
yield db
db.close()
# tests/test_users.py - не нужен импорт!
def test_user(db):
...
# tests/test_orders.py
def test_order(db):
...
conftest.py обнаруживается автоматически pytest'ом. Все fixtures из него доступны во всех тестах в той же директории и поддиректориях.
Можно иметь несколько conftest.py для разных уровней:
Fixture использующая другие fixtures
@pytest.fixture
def db():
return create_db()
@pytest.fixture
def user(db): # depends on db
u = User("Alice")
db.save(u)
return u
def test_user_email(user):
assert user.email == "alice@example.com"
pytest автоматически разрешает граф зависимостей: при вызове test_user_email → user → db. db создаётся, передаётся в user, потом user в test.
Встроенные fixtures
pytest даёт несколько полезных:
| Fixture | Что даёт |
|---|---|
tmp_path | Уникальная временная директория (Path) |
tmp_path_factory | Factory для нескольких tmp dirs |
capsys | Captured stdout/stderr |
capsysbinary | То же, но bytes |
caplog | Captured logs |
monkeypatch | Мокинг attributes, env vars, etc |
pytestconfig | Доступ к pytest config |
request | Информация о текущем тесте |
cache | Persistent cache между runs |
monkeypatch - изоляция тестов
def test_with_env(monkeypatch):
monkeypatch.setenv("DEBUG", "1")
assert os.environ["DEBUG"] == "1"
# После теста env var автоматически восстановится
def test_with_attribute(monkeypatch):
import requests
monkeypatch.setattr("requests.get", lambda url: "mocked")
assert requests.get("http://anywhere") == "mocked"
def test_with_dict(monkeypatch):
config = {"key": "original"}
monkeypatch.setitem(config, "key", "patched")
assert config["key"] == "patched"
monkeypatch автоматически undo'ит изменения после теста. Аналог temporary patching.
caplog - проверка логов
import logging
def test_log_message(caplog):
with caplog.at_level(logging.INFO):
my_function()
assert "expected message" in caplog.text
assert any(r.levelname == "WARNING" for r in caplog.records)
Полезно когда хочешь проверить что код правильно логирует определённые события.
capsys - проверка print
def test_print(capsys):
print("hello")
captured = capsys.readouterr()
assert captured.out == "hello\n"
Полезно для CLI-утилит. capsys.readouterr() сбрасывает буфер - можно вызывать несколько раз.
Параметризованные fixtures
@pytest.fixture(params=["sqlite", "postgres", "mysql"])
def db(request):
db_type = request.param
return create_db(db_type)
def test_basic_query(db):
assert db.query("SELECT 1") == [1]
Каждый тест с этой fixture запустится 3 раза - для каждого backend. Полезно для cross-database или cross-platform тестирования.
Factory fixtures - параметризация теста
Если нужны разные данные в одном тесте:
@pytest.fixture
def make_user():
def _make(name, age=18):
return User(name=name, age=age)
return _make
def test_users(make_user):
alice = make_user("Alice", 30)
bob = make_user("Bob")
assert alice.name != bob.name
Fixture возвращает функцию-фабрику. Тест может создавать сколько угодно объектов с разными параметрами.
indirect - параметризация через fixture
@pytest.fixture
def number(request):
return request.param * 10
@pytest.mark.parametrize("number", [1, 2, 3], indirect=True)
def test_indirect(number):
assert number >= 10 # 10, 20, 30
indirect=True говорит parametrize передать значения через fixture, не напрямую. Полезно для трансформации параметров.
Cleanup через addfinalizer
Альтернатива yield:
@pytest.fixture
def resource(request):
res = acquire_resource()
def cleanup():
release_resource(res)
request.addfinalizer(cleanup)
return res
addfinalizer полезен когда нужно несколько cleanup действий или они должны выполниться в специальном порядке. В большинстве случаев yield достаточно.
Autouse - применить ко всем
@pytest.fixture(autouse=True)
def setup_logging():
logging.basicConfig(level=logging.DEBUG)
yield
logging.shutdown()
autouse=True автоматически применяется ко всем тестам в scope (тестового файла или conftest). Не нужно явно перечислять в параметрах тестов. Использовать осторожно - тесты получают невидимые зависимости.
Async fixtures
import pytest
import pytest_asyncio
@pytest_asyncio.fixture
async def async_db():
db = await connect_async()
yield db
await db.close()
@pytest.mark.asyncio
async def test_async(async_db):
result = await async_db.query("SELECT 1")
assert result == [1]
С pytest-asyncio fixtures могут быть async. Используется в FastAPI и других async backends.
Хорошие практики
1. Один fixture - одна ответственность
# Плохо - монолитная fixture
@pytest.fixture
def setup():
db = ...
user = ...
permissions = ...
return (db, user, permissions) # tuple - неудобно
# Хорошо - разделить
@pytest.fixture
def db(): ...
@pytest.fixture
def user(db): ...
@pytest.fixture
def permissions(user): ...
2. Использовать conftest для шеринга, не imports
# Плохо
from .fixtures import db, user
# Хорошо - в conftest.py
@pytest.fixture
def db(): ...
@pytest.fixture
def user(): ...
3. Делать fixtures явными в параметрах
# Видишь сразу что тест использует
def test_query(db, user):
...
4. Scope-уровни осмысленно
functionдля test isolation (default)moduleдля дорогих setup в группе тестовsessionдля очень дорогих ресурсов (один web server для всех)
Чем шире scope, тем быстрее тесты, но меньше изоляция.
Распространённые ошибки
1. Mutable shared state в session scope
@pytest.fixture(scope="session")
def shared_list():
return [] # ОПАСНО - все тесты модифицируют один список
def test_a(shared_list):
shared_list.append("a")
def test_b(shared_list):
assert shared_list == [] # FAIL - содержит "a" от предыдущего
Session-scope fixtures должны быть immutable или сбрасываться в каждом тесте.
2. Зависимость от порядка тестов
Уже выше. Зависимые тесты ломаются при параллельном запуске или reordering.
3. Fixture как тест
@pytest.fixture
def test_data(): # ОШИБКА - начинается с test_
return {...}
test_* имена воспринимаются как тесты, не fixtures. Назови fixture без префикса test_: sample_data, mock_user.
4. Не используют автоматическое разрешение зависимостей
# Многословно
def test_x():
db = create_db()
user = create_user(db)
...
# Лучше - через fixtures
def test_x(user): # user → db, всё автоматом
...
5. Изменение глобального state без monkeypatch
def test_with_env():
os.environ["DEBUG"] = "1" # утечка между тестами!
# ...
del os.environ["DEBUG"] # easy забыть в случае failure
Используй monkeypatch - он сам undo:
def test_with_env(monkeypatch):
monkeypatch.setenv("DEBUG", "1") # автоматически восстановится
Полный пример
# conftest.py
import pytest
from myapp.db import Database
from myapp.models import User
@pytest.fixture
def db():
db = Database(":memory:") # SQLite in-memory
db.create_tables()
yield db
db.close()
@pytest.fixture
def user(db):
user = User(email="test@example.com", name="Test User")
db.save(user)
return user
@pytest.fixture
def make_user(db):
def _make(email, name=""):
u = User(email=email, name=name or email)
db.save(u)
return u
return _make
# tests/test_users.py
def test_user_retrieval(db, user):
fetched = db.get_user(user.email)
assert fetched.name == user.name
def test_multiple_users(make_user):
alice = make_user("alice@example.com")
bob = make_user("bob@example.com")
assert alice.email != bob.email
Сравнение с Go
Go использует t *testing.T и явный setup:
func TestUser(t *testing.T) {
db := setupDB(t)
defer db.Close()
user := &User{Name: "Alice"}
db.Save(user)
if got := db.GetUser(user.ID); got == nil {
t.Errorf("user not found")
}
}
func setupDB(t *testing.T) *Database {
t.Helper()
db, err := OpenInMemory()
if err != nil {
t.Fatal(err)
}
return db
}
Go более явный (setup function вызывается в каждом тесте). pytest fixture decoupled и автоматически инжектируется. Разные подходы к одной проблеме.
Мини-задание
- Простая fixture:
import pytest
@pytest.fixture
def sample_dict():
return {"name": "Alice", "age": 30, "email": "alice@example.com"}
def test_dict_keys(sample_dict):
assert "name" in sample_dict
def test_dict_values(sample_dict):
assert sample_dict["age"] == 30
- Fixture с teardown:
import pytest
import tempfile
from pathlib import Path
@pytest.fixture
def temp_file():
fd, path = tempfile.mkstemp(suffix=".txt")
Path(path).write_text("initial content")
yield Path(path)
# Cleanup
Path(path).unlink()
def test_read_temp(temp_file):
assert temp_file.read_text() == "initial content"
def test_modify_temp(temp_file):
temp_file.write_text("modified")
assert temp_file.read_text() == "modified"
- Параметризованная fixture:
import pytest
@pytest.fixture(params=[10, 100, 1000])
def sample_size(request):
return request.param
def test_perf(sample_size):
data = list(range(sample_size))
assert len(data) == sample_size
# Запустится 3 раза с разными размерами
Что дальше
Освоили fixtures. В следующем уроке - mocking: как изолировать тесты от внешних зависимостей через unittest.mock и pytest-mock.