Фикстуры и параметризация в pytest

⏱ 35 мин · 🧩 заданий: 5

Проблема: подготовка данных

Многим тестам нужно одно и то же: пользователь, подключение к базе, временная папка, клиент API. Копировать подготовку в каждый тест плохо. В unittest для этого setUp, в pytest — фикстуры.

Фикстура

Фикстура — функция с декоратором @pytest.fixture. Тест получает её результат, просто назвав параметр её именем:

import pytest
from shop.cart import Cart


@pytest.fixture
def cart():
    return Cart()


@pytest.fixture
def cart_with_items(cart):           # фикстура может зависеть от другой
    cart.add("чай", price=100, qty=2)
    cart.add("кофе", price=250)
    return cart


def test_empty_cart(cart):
    assert cart.total() == 0


def test_total(cart_with_items):
    assert cart_with_items.total() == 450

Это внедрение зависимостей: тест объявляет, что ему нужно, pytest сам строит граф и создаёт объекты. Каждый тест получает свежий экземпляр.

yield: подготовка и очистка

Код до yield — подготовка, после — очистка. Очистка выполнится, даже если тест упал:

@pytest.fixture
def db_connection():
    conn = create_connection("sqlite:///:memory:")
    conn.execute("CREATE TABLE users (id INTEGER, name TEXT)")
    yield conn                    # ← здесь выполняется тест
    conn.close()                  # очистка

Механика та же, что у контекстных менеджеров на генераторах:

def user_fixture():
    print("  setup: создаю пользователя")
    user = {"name": "Анна", "balance": 100}
    yield user
    print("  teardown: удаляю пользователя")

def run_test(test, fixture):            # упрощённо — так pytest вызывает тест
    gen = fixture()
    value = next(gen)
    try:
        test(value)
        print(f"  {test.__name__}: PASSED")
    except AssertionError as e:
        print(f"  {test.__name__}: FAILED {e}")
    finally:
        next(gen, None)                  # продолжить генератор после yield → teardown

def test_balance(user):
    assert user["balance"] == 100

def test_name(user):
    assert user["name"] == "Боря", "имя не совпало"

run_test(test_balance, user_fixture)
run_test(test_name, user_fixture)

Области видимости

@pytest.fixture(scope="session")     # один раз на весь запуск
def database():
    db = start_test_database()
    yield db
    db.stop()

@pytest.fixture(scope="module")      # один раз на файл
def api_client(database): ...

@pytest.fixture                      # scope="function" по умолчанию: на каждый тест
def user(database): ...
scope Создаётся Пример
function для каждого теста данные, объекты под тест
class на класс тестов редкость
module на файл клиент, дорогая загрузка
session на весь запуск тестовая база, Docker-контейнер

Широкая область — общий объект

Фикстура с scope="session" одна на все тесты. Если тест её меняет, это влияет на другие тесты. Изменяемые данные держите в function-фикстурах, а общий ресурс (базу) откатывайте после каждого теста (транзакцией).

conftest.py

Фикстуры, нужные многим файлам, кладут в conftest.py — pytest подхватит их сам, без импорта:

tests/ ├── conftest.py ← фикстуры для всех тестов (db, client) ├── unit/ │ └── test_pricing.py └── api/ ├── conftest.py ← фикстуры только для tests/api (auth_headers) └── test_orders.py

autouse и фабрики

@pytest.fixture(autouse=True)          # применяется ко всем тестам автоматически
def reset_cache():
    cache.clear()
    yield


@pytest.fixture
def make_user(db):                     # фабрика: тест сам решает, сколько и каких
    created = []
    def _make(name="Анна", **fields):
        user = User.create(db, name=name, **fields)
        created.append(user)
        return user
    yield _make
    for user in created:
        user.delete()


def test_transfer(make_user):
    alice = make_user("Алиса", balance=100)
    bob = make_user("Боб")
    transfer(alice, bob, 30)
    assert bob.balance == 30

Встроенные фикстуры

def test_save_report(tmp_path):                      # временная папка (pathlib.Path)
    path = tmp_path / "report.csv"
    save_report(path, rows=[{"a": 1}])
    assert path.read_text().startswith("a")


def test_api_key(monkeypatch):                       # временные подмены
    monkeypatch.setenv("API_KEY", "test-key")
    monkeypatch.setattr("shop.clock.now", lambda: FIXED_TIME)
    assert load_settings().api_key == "test-key"     # после теста всё вернётся


def test_greeting(capsys):                           # перехват stdout/stderr
    greet("Анна")
    assert capsys.readouterr().out == "Привет, Анна!\n"


def test_warning(caplog):                            # перехват логов
    with caplog.at_level("WARNING"):
        process(-1)
    assert "отрицательное" in caplog.text

Параметризация

Один тест — много наборов данных:

import pytest


@pytest.mark.parametrize(
    ("score", "expected"),
    [
        (100, "A"),
        (90, "A"),
        (89, "B"),
        (60, "C"),
        (59, "F"),
        (0, "F"),
    ],
)
def test_grade(score, expected):
    assert grade(score) == expected


@pytest.mark.parametrize("bad", [-1, 101, 1000], ids=["минус", "101", "тысяча"])
def test_grade_invalid(bad):
    with pytest.raises(ValueError):
        grade(bad)
$ pytest -v
test_grade.py::test_grade[100-A] PASSED
test_grade.py::test_grade[90-A] PASSED
test_grade.py::test_grade[89-B] PASSED
...
test_grade.py::test_grade_invalid[минус] PASSED

Каждый набор — отдельный тест: падение одного случая не скрывает остальные. Декораторы parametrize можно ставить друг на друга — получится декартово произведение. Параметризовать можно и фикстуры: @pytest.fixture(params=["sqlite", "postgres"]).

Тестовая база данных на практике

  • Django: TestCase оборачивает каждый тест в транзакцию и откатывает её; с pytest — плагин pytest-django и маркер @pytest.mark.django_db.
  • FastAPI + SQLAlchemy: session-фикстура поднимает тестовую базу (часто в Docker через testcontainers), function-фикстура открывает транзакцию и откатывает после теста, app.dependency_overrides подменяет зависимость get_db.

Вопрос на собеседовании

«Что такое фикстуры в pytest?» — Функции, подготавливающие данные или ресурсы для тестов; тест получает их по имени параметра (внедрение зависимостей). Поддерживают очистку через yield, области видимости (function, module, session), общие фикстуры в conftest.py и параметризацию.

Итог

  • Фикстура — @pytest.fixture; тест получает её по имени параметра; фикстуры зависят друг от друга.
  • yield разделяет подготовку и очистку; очистка выполняется и при падении.
  • scope управляет временем жизни; общие фикстуры — в conftest.py.
  • Встроенные: tmp_path, monkeypatch, capsys, caplog.
  • @pytest.mark.parametrize — табличные тесты, каждый набор — отдельный тест.

Практика решено 0 из 5

Вы не вошли — ответы проверяются, но прогресс не сохранится. Войти или зарегистрироваться.
Задание 1 Программа на Python

Перепишите набор тестов функции slugify в табличном стиле: создайте список CASES из пар (вход, ожидаемый результат) (не меньше 5 случаев, включая пустую строку и строку из пробелов) и функцию test_slugify(text, expected), которая проверяет один случай. Так работает @pytest.mark.parametrize — проверка прогонит вашу функцию по всем парам.

Какие тесты проверяют код (4)
assert len(CASES) >= 5, "Нужно не меньше 5 случаев"
assert "" in [c[0] for c in CASES] and any(c[0] and not c[0].strip() for c in CASES), "Добавьте пустую строку и строку из пробелов"
for text, expected in CASES:
    test_slugify(text, expected)

broken = False
try:
    test_slugify("Hello World", "WRONG")
except AssertionError:
    broken = True
assert broken, "test_slugify должна падать, если результат не совпал"

Задание 2 Программа на Python

Реализуйте простую «фикстуру» в стиле pytest без самого pytest: генератор temp_db(), который создаёт словарь-базу {"users": []}, отдаёт его через yield, а после теста очищает — вызывает db.clear() и записывает "closed" в глобальный список LOG. Очистка должна выполниться, даже если тест упал (используйте try/finally).

Какие тесты проверяют код (2)
LOG.clear()
gen = temp_db()
db = next(gen)
db["users"].append("Анна")
assert db == {"users": ["Анна"]}
try:
    next(gen)
except StopIteration:
    pass
assert db == {} and LOG == ["closed"]

LOG.clear()
gen = temp_db()
next(gen)
gen.close()          # так pytest завершает фикстуру и при падении теста
assert LOG == ["closed"], "Очистка должна выполниться и при закрытии генератора"

Задание 3 Один вариант ответа

Что выведет pytest для такого кода?

@pytest.fixture(scope="module")
def conn():
    print("connect")
    yield "db"
    print("disconnect")

def test_a(conn): ...
def test_b(conn): ...

(запуск с -s)

Задание 4 Заполнить таблицу

Какая встроенная фикстура pytest нужна?

tmp_pathmonkeypatchcapsyscaplog
Проверить, что функция напечатала «Готово»
Временно задать переменную окружения API_KEY
Записать тестовый CSV-файл и прочитать его
Проверить, что при ошибке записано WARNING-сообщение
Подменить функцию time.time на возвращающую константу
Отметьте ячейку — «+», пустая — «−».
Задание 5 Один вариант ответа

Для чего нужен файл conftest.py?

Войдите, чтобы отмечать пройденные уроки.