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.
В этом уроке:
- Why fixtures — DRY pattern для shared setup.
@pytest.fixture— return-style — простой setup без teardown.yield-style fixtures — setup + teardown — климакс М06 урок 05 reuse.- Scope hierarchy —
function(default) /class/module/package/session(Pitfall 16). - Fixture dependencies — fixture requests fixture.
- 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
Что произошло:
@pytest.fixturedecorator регистрирует function как fixture.- pytest сканирует test signature — видит
auth_with_aliceparameter. - pytest вызывает fixture function, передаёт return value как argument.
- 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).
yield-style fixtures — climax cross-link M06 урок 05
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/finally — defensive 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):
| Scope | Lifetime | Invocations |
|---|---|---|
function (default) | Per test function | N for N tests |
class | Per Test* class (test methods inside share) | C for C classes |
module | Per *.py test file | M for M files |
package | Per Python package (directory с __init__.py) | P for P packages |
session | Per pytest command invocation | 1 total |
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:
- pytest sees
test_alice_can_login(alice, auth_service). - pytest needs
alice— seesalice(db)requestsdb. - pytest needs
auth_service— seesauth_service(db)requestsdb. - Single
dbinstance shared (session scope) — invoked once. alice(db)invoked, returns user.auth_service(db)invoked, returns service.- test runs с
alice+auth_serviceinjected.
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
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.pyExpected 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
Что мы получили:
app_config— session-scoped, immutable, read once.db_client— function-scoped (fresh per test), reuses session-scoped config.alice— function-scoped, reusesdb_client. Both teardowns вtry/finally— defensive.- 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
SparkSessionfixture (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.
Ключевые выводы
@pytest.fixture— decorator-based dependency injection. pytest резолвит fixtures черезinspect.signatureintrospection (M07 урок 04 cross-link).yield-style fixtures — setup + teardown черезyieldboundary. Climax cross-link M06 урок 05: pytest’s_pytest/fixtures.pyreuses_GeneratorContextManagerAPI изcontextlib.contextmanager(closure + generator + protocol).- Scope hierarchy (Pitfall 16):
function(default) /class/module/package/session. Broader scope — cheaper runtime, но shared mutable state risk. Match scope с test independence. - Fixture dependencies — fixtures request other fixtures via parameters. pytest resolves graph automatically. Scope rule: fixture scope ≥ scope of fixtures it requests (else
ScopeMismatch). yield+try/finally— defensive teardown гарантия. Same pitfall что M06 урок 05 (Pitfall 10) — безfinallyteardown skipped под exception.- 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.