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 для разных уровней:

Вложенные conftest.py: tests/conftest.py - общие fixtures, под-директории unit и integration со своими conftest.py для специфичных fixtures

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_factoryFactory для нескольких tmp dirs
capsysCaptured stdout/stderr
capsysbinaryТо же, но bytes
caplogCaptured logs
monkeypatchМокинг attributes, env vars, etc
pytestconfigДоступ к pytest config
requestИнформация о текущем тесте
cachePersistent 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 и автоматически инжектируется. Разные подходы к одной проблеме.

Мини-задание

  1. Простая 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
  1. 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"
  1. Параметризованная 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.

Зарегистрируйтесь бесплатно, чтобы пройти квиз, решить задание с автопроверкой и вести прогресс.