Lesson 8 · Pytest deep dive
pytest Tutorial #8: Fixture Scopes & Finalisation
On testing real Python code with pytest. session and class scope, finalization order, and a real scope-mismatch gotcha. Let's get into it. scope='session'…
- CoursePytest deep dive
- Lesson8 of 18
- FormatJupyter notebook · 7 code cells
What you'll learn
Data
No separate download needed — the notebook creates or downloads everything it uses.
📓 Full notebook
Download .ipynbpytest Deep-Dive, Video 8: Fixture Scopes and Finalization#
- Video eight of an eighteen-part series on testing real Python code with pytest.
- session and class scope, finalization order, and a real scope-mismatch gotcha.
- Let's get into it.
Part 1: The Helper From Last Time#
import pytest, tempfile, os
def run_pytest(code_str, args=None):
with tempfile.NamedTemporaryFile(mode='w', suffix='_test.py', delete=False) as f:
f.write(code_str)
path = f.name
result = pytest.main([path, *(args or [])])
os.unlink(path)
return result
Part 2: session Scope - Once Per Entire Test Run#
code = '''
import pytest
init_count = {"n": 0}
@pytest.fixture(scope='session')
def shared_resource():
init_count["n"] += 1
return {'value': 42}
def test_first_use(shared_resource):
assert shared_resource['value'] == 42
assert init_count['n'] == 1
def test_second_use(shared_resource):
assert shared_resource['value'] == 42
assert init_count['n'] == 1
def test_third_use(shared_resource):
assert init_count['n'] == 1
'''
exit_code = run_pytest(code, ['-v', '--no-header'])
print(exit_code)
Part 3: class Scope - Once Per Test Class#
code = '''
import pytest
call_log = []
@pytest.fixture(scope='class')
def class_resource():
call_log.append('init')
return {'ready': True}
class TestGroupOne:
def test_a(self, class_resource):
assert class_resource['ready']
def test_b(self, class_resource):
assert class_resource['ready']
class TestGroupTwo:
def test_c(self, class_resource):
assert class_resource['ready']
def test_log_length():
assert len(call_log) == 2
'''
exit_code = run_pytest(code, ['-v', '--no-header'])
print(exit_code)
Part 4: Finalization Order - Stacked yield Fixtures#
code = '''
import pytest
order = []
@pytest.fixture
def outer():
order.append('setup outer')
yield
order.append('teardown outer')
@pytest.fixture
def inner(outer):
order.append('setup inner')
yield
order.append('teardown inner')
def test_order(inner):
order.append('test body')
def test_check_order():
assert order == ['setup outer', 'setup inner', 'test body', 'teardown inner', 'teardown outer']
'''
exit_code = run_pytest(code, ['-v', '--no-header'])
print(exit_code)
Part 5: request.addfinalizer - an Alternative to yield#
code = '''
import pytest
cleanup_order = []
@pytest.fixture
def resource_with_finalizers(request):
request.addfinalizer(lambda: cleanup_order.append('first registered'))
request.addfinalizer(lambda: cleanup_order.append('second registered'))
return {'ready': True}
def test_uses_resource(resource_with_finalizers):
assert resource_with_finalizers['ready']
def test_cleanup_order_after():
assert cleanup_order == ['second registered', 'first registered']
'''
exit_code = run_pytest(code, ['-v', '--no-header'])
print(exit_code)
Part 6: A Real Gotcha - Scope Mismatch#
code = '''
import pytest
@pytest.fixture
def per_test_id():
return object()
@pytest.fixture(scope='session')
def broken_session_fixture(per_test_id):
return per_test_id
def test_uses_broken_fixture(broken_session_fixture):
assert broken_session_fixture is not None
'''
exit_code = run_pytest(code, ['-v', '--no-header'])
print(exit_code)
Part 7: A Real Pattern - an Expensive Session-Scoped Setup#
code = '''
import pytest
build_count = {"n": 0}
def expensive_lookup_table():
build_count["n"] += 1
return {n: n * n for n in range(1, 1001)}
@pytest.fixture(scope='session')
def lookup_table():
return expensive_lookup_table()
def test_lookup_5(lookup_table):
assert lookup_table[5] == 25
def test_lookup_100(lookup_table):
assert lookup_table[100] == 10000
def test_lookup_1000(lookup_table):
assert lookup_table[1000] == 1000000
def test_built_only_once(lookup_table):
assert build_count['n'] == 1
'''
exit_code = run_pytest(code, ['-v', '--no-header'])
print(exit_code)
Wrap-Up: What You Learned#
- scope='session' runs a fixture exactly once for the whole pytest invocation, shared by every requesting test.
- scope='class' runs once per test class, shared by every method inside that class.
- Stacked yield fixtures tear down in reverse of setup order, exactly like nested with blocks.
- request.addfinalizer registers cleanup callbacks directly, run in LIFO order, as an alternative to yield.
- A session-scoped fixture cannot depend on a function-scoped one; pytest raises ScopeMismatch at collection.
- Session scope is ideal for genuinely expensive, safely-shareable, read-only setup like a big lookup table.
- That wraps up fixture scopes and finalization. Next up: Test Organization.
Found this useful?
All lessons, notebooks and datasets here are free. If they helped you, a coffee keeps new lessons coming.



