Learning Platform
Глоссарий Troubleshooting
Урок 09.02 · 25 мин
Средний
pytestFixtures@pytest.fixtureyield fixturesscopefunction scopesession scopeFixture dependenciesRun-on-Your-MachineM06 climax cross-link
Требуемые знания:

Fixtures и scope hierarchy — climax cross-link M06 урок 05

Fixtures — это pytest-механизм для setup/teardown test environment’а. Decorator @pytest.fixture превращает функцию в fixture. pytest автоматически инжектит fixture в test function, если test function запрашивает fixture через parameter name. Это inverse of unittest.setUp/tearDown — вместо overriding methods, declarative dependency injection через parameters.

Главный insight этого урока — @pytest.fixture с yield использует точно тот же pattern, что мы выучили в M06 урок 05 (contextlib.contextmanager). pytest’s _pytest/fixtures.py оборачивает yield-fixtures в _GeneratorContextManager-like handler — architectural reuse, не coincidence.

В этом уроке:

  1. Why fixtures — DRY pattern для shared setup.
  2. @pytest.fixture — return-style — простой setup без teardown.
  3. yield-style fixtures — setup + teardown — климакс М06 урок 05 reuse.
  4. Scope hierarchyfunction (default) / class / module / package / session (Pitfall 16).
  5. Fixture dependencies — fixture requests fixture.
  6. Run-on-Your-Machine — pytest -v -s fixture demo.

Why fixtures — DRY shared setup

Без fixtures каждый test arranges own state — duplicated:

def test_user_can_login():
    db = Database(':memory:')         # ← duplication
    db.create_table('users')          # ← duplication
    db.insert('users', name='Alice')  # ← duplication
    auth = AuthService(db)
    assert auth.login('Alice') is True


def test_user_can_logout():
    db = Database(':memory:')         # ← duplication
    db.create_table('users')          # ← duplication
    db.insert('users', name='Alice')  # ← duplication
    auth = AuthService(db)
    auth.login('Alice')
    auth.logout('Alice')
    assert auth.is_logged_in('Alice') is False

С fixture — один setup, переиспользуется automaticly:

import pytest

@pytest.fixture
def auth_with_alice():
    """Build AuthService с Alice already in DB."""
    db = Database(':memory:')
    db.create_table('users')
    db.insert('users', name='Alice')
    return AuthService(db)


def test_user_can_login(auth_with_alice):
    # ← pytest автоматически инжектит fixture, видя parameter name
    assert auth_with_alice.login('Alice') is True


def test_user_can_logout(auth_with_alice):
    auth_with_alice.login('Alice')
    auth_with_alice.logout('Alice')
    assert auth_with_alice.is_logged_in('Alice') is False

Что произошло:

  1. @pytest.fixture decorator регистрирует function как fixture.
  2. pytest сканирует test signature — видит auth_with_alice parameter.
  3. pytest вызывает fixture function, передаёт return value как argument.
  4. Cleanup — нет (return-style fixture без teardown). См. дальше — yield-style для cleanup.

Pragmatic rule: любой shared setup между тестами → fixture. Это не optimization — это architecture.

Cite: pytest 8 docs — fixtures; pytest automatic injection через inspect.signature(test_fn).parameters (M07 урок 04 cross-link — runtime introspection).


Setup + teardown — с yield boundary:

import pytest

@pytest.fixture
def db_connection():
    """Open DB connection. Cleanup — close после теста."""
    conn = Database(':memory:').connect()  # ← setup
    yield conn                              # ← boundary: тест запускается с этим value
    conn.close()                            # ← teardown: всегда runs


def test_query_returns_rows(db_connection):
    rows = db_connection.execute('SELECT 1').fetchall()
    assert rows == [(1,)]

Recall M06 урок 05 (06-decorators-context-managers/05-contextlib-and-summary.mdx):

from contextlib import contextmanager

@contextmanager
def temp_attr(obj, attr, val):
    original = getattr(obj, attr)
    setattr(obj, attr, val)
    try:
        yield obj                          # ← boundary
    finally:
        setattr(obj, attr, original)        # ← teardown

Это — тот же самый pattern. yield — boundary между setup и teardown, both в pytest fixtures и в @contextlib.contextmanager.

Cite: _pytest/fixtures.py_pytest/fixtures.py:resolve_fixture_function оборачивает yield-fixtures в _GeneratorContextManager-like handler. Это architectural reuse — pytest переиспользует API contextmanager protocol (__enter__/__exit__).

Climax synthesis Phase 65/66/67:

M03 урок 04 (closure / PyCellObject)

M05 урок 02 (generator / PyGenObject / yield = frame suspension)

M06 урок 04 (context-manager protocol / __enter__ + __exit__)

M06 урок 05 (@contextmanager — closure + generator + protocol synthesis)

M08 урок 02 (pytest yield-fixtures — REUSES @contextmanager pattern в _pytest/fixtures.py)

yield в pytest fixture — тот же boundary, что в @contextmanager. Закрывается loop “что мы выучили” — practical reuse в production-grade testing tool.

Pitfall (carried forward из M06 урок 05): yield без try/finally — teardown skipped под exception. В fixtures тот же pitfall:

# BAD — teardown skipped if test raises
@pytest.fixture
def bad_db():
    conn = Database().connect()
    yield conn
    conn.close()  # ← skipped if test ASSERT fails!


# GOOD — teardown гарантирован
@pytest.fixture
def good_db():
    conn = Database().connect()
    try:
        yield conn
    finally:
        conn.close()  # ← always runs

Кстати — pytest’s fixture handler обычно обрабатывает this gracefully (через _GeneratorContextManager-like teardown), но explicit try/finallydefensive style для production.


Scope hierarchy (Pitfall 16) — function / class / module / package / session

Без scope (default function) fixture invoked заново для каждого теста:

import pytest

call_count = 0

@pytest.fixture
def counter():
    global call_count
    call_count += 1
    return call_count


def test_a(counter): pass     # call_count == 1
def test_b(counter): pass     # call_count == 2
def test_c(counter): pass     # call_count == 3

С scope='session' fixture invoked один раз per test session:

@pytest.fixture(scope='session')
def counter_once():
    return generate_expensive_value()


def test_a(counter_once): pass   # fixture invoked
def test_b(counter_once): pass   # SAME instance — no new invocation
def test_c(counter_once): pass   # SAME instance

Полная иерархия (от narrow к broad):

ScopeLifetimeInvocations
function (default)Per test functionN for N tests
classPer Test* class (test methods inside share)C for C classes
modulePer *.py test fileM for M files
packagePer Python package (directory с __init__.py)P for P packages
sessionPer pytest command invocation1 total
Fixture scope hierarchy — invocation count
scope='function'N invocationsDefault scope. Fixture invoked заново для каждой test function. Каждый тест получает fresh instance. Use case: isolation, no shared state between tests. Cost — N times setup. Cite pytest docs scope='function'
scope='class'C invocationsOne invocation per Test* class. Все test methods inside class share fixture. Use case: setup тяжёлый, но logical group of tests OK to share state. Cite pytest docs scope='class'
scope='module'M invocationsOne per *.py test file. All tests в одном file share. Use case: file-scoped DB connection, test-data fixture. Cite pytest docs scope='module'
scope='session'1 invocationOne per pytest command invocation. Use case: heavy DB / docker container / network mock — нельзя setup repeat. Cost — full fixture lifetime; teardown в самом конце session. Cite pytest docs scope='session' + Pitfall 16

Pitfall 16 — scope sized to test independence:

Чем broader scope, тем дешевле runtime, но более opportunity for shared mutable state между тестами (anti-pattern).

Example trade-off:

# Heavy fixture — DB schema creation expensive
@pytest.fixture(scope='session')
def db_schema():
    """Create schema once per test run."""
    db = Database()
    db.run_migrations()        # ← 5 sec — too expensive per-test
    yield db
    db.drop_all()


@pytest.fixture(scope='function')
def fresh_user(db_schema):
    """Insert fresh user per test — независимый state."""
    user = db_schema.insert_user('alice')
    yield user
    db_schema.delete_user(user.id)

Pragmatic rule:

  • scope='session' — read-only / immutable resources (config, schema, mock server start).
  • scope='function' (default) — anything mutated by test (rows, files, in-memory state).
  • Mixed approach — common: heavy schema fixture session-scoped, mutating fixtures function-scoped, depending on schema.

Cite: pytest 8 docs — fixture scopes.


Fixture dependencies — fixture requests fixture

Fixtures сами могут запрашивать другие fixtures — pytest resolves graph automatically:

@pytest.fixture(scope='session')
def db():
    db = Database(':memory:')
    db.run_migrations()
    yield db
    db.drop_all()


@pytest.fixture
def alice(db):                            # ← requests db fixture
    user = db.insert_user('alice')
    yield user
    db.delete_user(user.id)


@pytest.fixture
def auth_service(db):                     # ← requests db fixture (same instance)
    return AuthService(db)


def test_alice_can_login(alice, auth_service):  # ← requests both
    assert auth_service.login(alice.name) is True

Resolution order:

  1. pytest sees test_alice_can_login(alice, auth_service).
  2. pytest needs alice — sees alice(db) requests db.
  3. pytest needs auth_service — sees auth_service(db) requests db.
  4. Single db instance shared (session scope) — invoked once.
  5. alice(db) invoked, returns user.
  6. auth_service(db) invoked, returns service.
  7. test runs с alice + auth_service injected.

Scope mismatch rule — fixture scope must be ≥ scope of fixtures it requests:

@pytest.fixture(scope='session')
def heavy(): ...

@pytest.fixture(scope='function')
def light(): ...

@pytest.fixture(scope='session')
def composed(heavy, light):    # ← ERROR! session-scoped requests function-scoped
    ...

pytest raises ScopeMismatch (will revisit Pitfall 17 in lesson 05 mocks/monkeypatch).

Cite: pytest 8 docs — fixture dependencies; _pytest/fixtures.py:FixtureManager.


Run-on-Your-Machine — fixtures demo

TIP

Run-on-Your-Machine: yield-fixture с scope=‘session’

Установите pytest:

pip install pytest

Создайте файл test_fixtures_demo.py:

# test_fixtures_demo.py
import pytest


@pytest.fixture(scope='session')
def shared_resource():
    print('\n[SETUP] expensive resource init')
    yield {'id': 42, 'name': 'shared'}
    print('\n[TEARDOWN] resource cleanup')


def test_alpha(shared_resource):
    print('  [test_alpha] using', shared_resource['name'])
    assert shared_resource['id'] == 42


def test_beta(shared_resource):
    print('  [test_beta] using', shared_resource['name'])
    assert shared_resource['name'] == 'shared'

Запустите с verbose + show prints (-s):

pytest -v -s test_fixtures_demo.py

Expected output:

test_fixtures_demo.py::test_alpha
[SETUP] expensive resource init
  [test_alpha] using shared
PASSED
test_fixtures_demo.py::test_beta
  [test_beta] using shared
PASSED
[TEARDOWN] resource cleanup

============================== 2 passed in 0.02s ==============================

Key observation: [SETUP] runs once (before first test using fixture). [TEARDOWN] runs once (after last test). Tests share same dict instance — verify через id(shared_resource) если хотите.

Меняйте scope='session'scope='function' и пересmотрите — [SETUP]/[TEARDOWN] теперь runs дважды (once per test).


Recipe — production yield-fixture с try/finally

End-to-end recipe для production code:

# tests/conftest.py (lesson 06 will explain conftest.py)
import pytest

@pytest.fixture(scope='session')
def app_config():
    """Load test config — read-only — session-scoped."""
    return {
        'db_url': 'sqlite:///:memory:',
        'log_level': 'DEBUG',
        'feature_flag_X': True,
    }


@pytest.fixture
def db_client(app_config):
    """Per-test DB connection — function-scoped (default)."""
    client = DatabaseClient(app_config['db_url'])
    client.connect()
    try:
        yield client                # ← boundary
    finally:
        client.close()              # ← always runs (M06 урок 05 pattern)


@pytest.fixture
def alice(db_client):
    """Per-test fixture — Alice user."""
    user = db_client.insert_user('alice', email='[email protected]')
    try:
        yield user
    finally:
        db_client.delete_user(user.id)


# tests/test_auth.py
def test_alice_can_login(alice, db_client):
    auth = AuthService(db_client)
    assert auth.login(alice.name) is True

Что мы получили:

  1. app_config — session-scoped, immutable, read once.
  2. db_client — function-scoped (fresh per test), reuses session-scoped config.
  3. alice — function-scoped, reuses db_client. Both teardowns в try/finallydefensive.
  4. Test code — minimal: declare fixtures via parameters, focus на assertions.

Это — architectural style production-grade pytest tests.


Cross-course context

Cross-course → Spark: 13/01 unit-testing-pyspark — pytest fixtures для Spark: session-scoped SparkSession fixture (build один раз на test run, reuse между tests) + function-scoped DataFrame fixtures (fresh per test). Та же scope hierarchy и dependency injection (function ≤ class ≤ module ≤ session), но материализована поверх Spark runtime; setup в Spark стоит ~5 секунд на JVM warmup, поэтому session-scope почти обязателен для performance.


Ключевые выводы

  1. @pytest.fixture — decorator-based dependency injection. pytest резолвит fixtures через inspect.signature introspection (M07 урок 04 cross-link).
  2. yield-style fixtures — setup + teardown через yield boundary. Climax cross-link M06 урок 05: pytest’s _pytest/fixtures.py reuses _GeneratorContextManager API из contextlib.contextmanager (closure + generator + protocol).
  3. Scope hierarchy (Pitfall 16): function (default) / class / module / package / session. Broader scope — cheaper runtime, но shared mutable state risk. Match scope с test independence.
  4. Fixture dependencies — fixtures request other fixtures via parameters. pytest resolves graph automatically. Scope rule: fixture scope ≥ scope of fixtures it requests (else ScopeMismatch).
  5. yield + try/finally — defensive teardown гарантия. Same pitfall что M06 урок 05 (Pitfall 10) — без finally teardown skipped под exception.
  6. Run-on-Your-Machine convention — pytest invocations live local. Browser challenges используют function-call mode для practical demos (lesson 04+).

Дальше — parametrize (урок 03): таблица arguments → multiple test runs. Параллель с function-call mode testCases array — тот же pattern.

Проверьте понимание

Результат: 0 из 0
Концептуальный
Вопрос 1 из 4. Cross-link M06 урок 05: какой pattern из contextlib **прямо** переиспользуется pytest в `_pytest/fixtures.py` для yield-style fixtures?

Закончили урок?

Отметьте его как пройденный, чтобы отслеживать свой прогресс

Войдите чтобы оценить урок

Прогресс модуля
0 из 7