Перейти к содержимому
К программе курса
Pytest: Профессиональные инструменты
4 / 850%

Fixture factories и indirect parametrization

25 минут

Нужно протестировать функцию с 5 разными пользователями: admin, moderator, user, guest, banned. Создавать 5 фикстур? Есть способ лучше!

Цель: Освоить fixture factories и indirect parametrization для гибкого создания тестовых данных.

Вы точно готовы?

Убедитесь, что умеете:

# Параметризация тестов
@pytest.mark.parametrize("value", [1, 2, 3])
def test_values(value):
    assert value > 0
 
# Фикстуры с scope
@pytest.fixture(scope="module")
def database():
    return connect_to_db()

Если parametrize или scope непонятны — вернитесь к предыдущим урокам.

Проблема: множество похожих фикстур

Дублирование фикстур

# ❌ ПЛОХО — 5 почти одинаковых фикстур
 
@pytest.fixture
def admin_user():
    return User(name="Admin", role="admin", permissions=["all"])
 
@pytest.fixture
def moderator_user():
    return User(name="Moderator", role="moderator", permissions=["moderate"])
 
@pytest.fixture
def regular_user():
    return User(name="User", role="user", permissions=["read", "write"])
 
@pytest.fixture
def guest_user():
    return User(name="Guest", role="guest", permissions=["read"])
 
@pytest.fixture
def banned_user():
    return User(name="Banned", role="banned", permissions=[])

Проблемы:

  • ❌ Дублирование кода
  • ❌ Сложно добавить новые роли
  • ❌ Сложно изменить структуру User

Решение 1: Fixture factory

Фикстура возвращает функцию

@pytest.fixture
def make_user():
    """Factory для создания пользователей"""
 
    def _make_user(role="user", name=None, permissions=None):
        if name is None:
            name = f"{role.capitalize()}User"
        if permissions is None:
            permissions = {
                "admin": ["all"],
                "moderator": ["read", "write", "moderate"],
                "user": ["read", "write"],
                "guest": ["read"],
                "banned": []
            }[role]
        return User(name=name, role=role, permissions=permissions)
    return _make_user

Использование:

def test_admin_can_delete(make_user):
    admin = make_user(role="admin")
    assert admin.can("delete")
 
def test_guest_cannot_write(make_user):
    guest = make_user(role="guest")
    assert not guest.can("write")
 
def test_custom_user(make_user):
    custom = make_user(role="custom", permissions=["special"])
    assert custom.can("special")

Одна фикстура, любые вариации!

Factory с cleanup

@pytest.fixture
def make_database_user(database):
    """Factory для пользователей в БД"""
 
    created_users = []
 
    def _make_user(**kwargs):
        user = User.create(database, **kwargs)
        created_users.append(user)
        return user
 
    yield _make_user
 
    # Cleanup: удаляем всех созданных пользователей
    for user in created_users:
        user.delete()

Использование:

def test_multiple_users(make_database_user):
    alice = make_database_user(name="Alice", age=25)
    bob = make_database_user(name="Bob", age=30)
    charlie = make_database_user(name="Charlie", age=35)
 
    assert User.count() == 3
    # После теста все 3 пользователя удалятся автоматически!

Практический пример: order factory

@pytest.fixture
def make_order(database):
    """Factory для заказов"""
 
    orders = []
 
    def _make_order(user_id=1, items=None, status="pending"):
        if items is None:
            items = [{"product": "Laptop", "qty": 1}]
 
        order = Order.create(
            database,
            user_id=user_id,
            items=items,
            status=status,
            total=sum(item["qty"] * 100 for item in items)
        )
        orders.append(order)
        return order
 
    yield _make_order
 
    for order in orders:
        order.delete()
 
def test_order_total(make_order):
    order = make_order(items=[
        {"product": "Laptop", "qty": 2},
        {"product": "Mouse", "qty": 5},
    ])
    assert order.total == 700
 
def test_order_status_flow(make_order):
    order = make_order(status="pending")
    order.process()
    assert order.status == "processing"

Решение 2: Indirect parametrization

Проблема: параметризация фикстур

# Хочу запустить тест с разными пользователями
@pytest.mark.parametrize("user", ["admin", "moderator", "user"])
def test_access(user):
    # user — это строка "admin", не объект User!
    assert user.can("read")  # ❌ Не работает

Решение: indirect=True

@pytest.fixture
def user(request):
    """Создаёт пользователя по роли из параметра"""
    role = request.param  # Получаем параметр из parametrize
    return User(name=role.capitalize(), role=role)
 
@pytest.mark.parametrize("user", ["admin", "moderator", "user"], indirect=True)
def test_access(user):
    # user — это объект User!
    assert user.can("read")  # ✅ Работает

Что происходит:

  1. @pytest.mark.parametrize("user", ["admin", ...], indirect=True)
  2. pytest передаёт "admin" в фикстуру user через request.param
  3. Фикстура создаёт User(role="admin")
  4. Тест получает объект User

✅ Тест запустится 3 раза с разными пользователями!

Множественная indirect параметризация

@pytest.fixture
def user(request):
    role = request.param
    return User(role=role)
 
@pytest.fixture
def database(request):
    db_type = request.param
    return connect_to_db(db_type)
 
@pytest.mark.parametrize("user", ["admin", "user"], indirect=True)
@pytest.mark.parametrize("database", ["postgres", "mysql"], indirect=True)
def test_user_database(user, database):
    """Тест комбинации user × database"""
 
    user.save(database)
    assert user.id is not None

Результат: 4 теста (2 пользователя × 2 БД)

test_user_database[postgres-admin] PASSED
test_user_database[postgres-user] PASSED
test_user_database[mysql-admin] PASSED
test_user_database[mysql-user] PASSED

Практический пример: тестирование permissions

@pytest.fixture
def user(request):
    """Фикстура создаёт пользователя по роли"""
 
    role = request.param
    permissions = {
        "admin": ["read", "write", "delete", "admin"],
        "moderator": ["read", "write", "moderate"],
        "user": ["read", "write"],
        "guest": ["read"],
        "banned": []
    }
    return User(role=role, permissions=permissions[role])
 
@pytest.mark.parametrize("user", ["admin", "moderator"], indirect=True)
def test_can_moderate(user):
    """Админ и модератор могут модерировать"""
 
    assert user.can("moderate") or user.role == "admin"
 
@pytest.mark.parametrize("user", ["user", "guest", "banned"], indirect=True)
def test_cannot_moderate(user):
    """User, guest и banned не могут модерировать"""
 
    assert not user.can("moderate")
 
@pytest.mark.parametrize("user", ["admin", "moderator", "user", "guest", "banned"], indirect=True)
def test_everyone_can_read(user):
    """Все кроме banned могут читать"""
 
    if user.role != "banned":
        assert user.can("read")
    else:
        assert not user.can("read")

Request object: доступ к контексту теста

request.param — параметры из parametrize

@pytest.fixture
def resource(request):
    param = request.param
    return create_resource(param)

request.node — информация о тесте

@pytest.fixture
def logger(request):
    """Логирует информацию о тесте"""
    test_name = request.node.name
    test_file = request.node.fspath
    print(f"\n🧪 Running: {test_name} in {test_file}")
    yield
    print(f"✅ Finished: {test_name}")

request.config — доступ к pytest config

@pytest.fixture
def base_url(request):
    """Получает base_url из pytest config"""
    return request.config.getoption("--base-url", default="http://localhost")
 
def test_api(base_url):
    response = requests.get(f"{base_url}/api/users")
    assert response.status_code == 200

Запуск:

pytest --base-url=https://api.example.com

request.addfinalizer — альтернатива yield

@pytest.fixture
def resource(request):
    """Фикстура с finalizer"""
    res = create_resource()
 
    # Регистрируем cleanup
    def cleanup():
        res.close()
 
    request.addfinalizer(cleanup)
 
    return res

Преимущество: Можно зарегистрировать несколько finalizers:

@pytest.fixture
def complex_resource(request):
    resource = create_resource()
    request.addfinalizer(lambda: resource.close())
    request.addfinalizer(lambda: cleanup_files())
    request.addfinalizer(lambda: send_metrics())
    return resource

Комбинация: factory + indirect (bonus)

@pytest.fixture
def make_user():
    """Factory внутри фикстуры"""
    users = []
 
    def _make(role):
        user = User(role=role)
        users.append(user)
        return user
 
    yield _make
 
    for user in users:
        user.delete()
 
@pytest.fixture
def user(request, make_user):
    """Indirect фикстура использует factory"""
    role = request.param
    return make_user(role)
 
@pytest.mark.parametrize("user", ["admin", "moderator", "user"], indirect=True)
def test_permissions(user):
    assert user.role in ["admin", "moderator", "user"]
    # Все пользователи удалятся после теста благодаря factory!

Что вы изучили

  • Fixture factories — фикстуры возвращают функции для создания объектов
  • indirect=True — параметризация фикстур
  • request.param — доступ к параметрам из parametrize
  • request object — node, config, addfinalizer
  • Комбинация — factory + indirect для мощных паттернов
  • Best practices — DRY для тестовых данных

Следующий урок

Отлично! Теперь вы умеете создавать гибкие фикстуры. Но как создать собственные маркеры для организации тестов?

Переходите к уроку 4: Custom markers и strict mode

В следующем уроке вы узнаете:

  • Регистрация кастомных маркеров
  • --strict-markers для защиты от опечаток
  • Маркеры с аргументами
  • Best practices

Устранение неисправностей

Фикстура ожидает параметр из parametrize, но вы забыли indirect.

Как починить:

# ✅ ПРАВИЛЬНО
@pytest.mark.parametrize("user", ["admin"], indirect=True)

# ❌ НЕПРАВИЛЬНО

@pytest.mark.parametrize("user", ["admin"]) # Забыли indirect

Созданные сущности остаются в БД/файлах после теста.

Как починить:

@pytest.fixture
def make_user():
users = []

  def _make(**kwargs):
      user = User(**kwargs)
      users.append(user)
      return user

  yield _make

  for user in users:
      user.delete()

По умолчанию indirect применится ко всем параметрам кортежа.

Как починить:

@pytest.mark.parametrize(
"user,data",
[("admin", {"key": "value"})],
indirect=["user"], # Только user indirect
)
def test(user, data):
...

Передавайте словари/объекты через pytest.param с id.

@pytest.mark.parametrize("user", [
pytest.param({"role": "admin", "age": 30}, id="admin-30"),
pytest.param({"role": "user", "age": 25}, id="user-25"),
], indirect=True)
def test(user):
...

@pytest.fixture
def user(request):
config = request.param # Это dict!
return User(**config)
Fixture factories и indirect parametrization — Pytest: Профессиональные инструменты — Potapov.me