Mathew K Analytics

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'…

⬇ Download notebook

📓 Full notebook

Download .ipynb

pytest 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)
============================= test session starts =============================
collecting ... collected 3 items

..\..\..\Users\makmw\AppData\Local\Temp\tmpouwn0bcy_test.py::test_first_use PASSED [ 33%]
..\..\..\Users\makmw\AppData\Local\Temp\tmpouwn0bcy_test.py::test_second_use PASSED [ 66%]
..\..\..\Users\makmw\AppData\Local\Temp\tmpouwn0bcy_test.py::test_third_use PASSED [100%]

============================== 3 passed in 0.22s ==============================
0

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)
============================= test session starts =============================
collecting ... collected 4 items

..\..\..\Users\makmw\AppData\Local\Temp\tmp7oe37to6_test.py::TestGroupOne::test_a PASSED [ 25%]
..\..\..\Users\makmw\AppData\Local\Temp\tmp7oe37to6_test.py::TestGroupOne::test_b PASSED [ 50%]
..\..\..\Users\makmw\AppData\Local\Temp\tmp7oe37to6_test.py::TestGroupTwo::test_c PASSED [ 75%]
..\..\..\Users\makmw\AppData\Local\Temp\tmp7oe37to6_test.py::test_log_length PASSED [100%]

============================== 4 passed in 0.11s ==============================
0

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)
============================= test session starts =============================
collecting ... collected 2 items

..\..\..\Users\makmw\AppData\Local\Temp\tmpr07i4awj_test.py::test_order PASSED [ 50%]
..\..\..\Users\makmw\AppData\Local\Temp\tmpr07i4awj_test.py::test_check_order PASSED [100%]

============================== 2 passed in 0.10s ==============================
0

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)
============================= test session starts =============================
collecting ... collected 2 items

..\..\..\Users\makmw\AppData\Local\Temp\tmp21oss2p5_test.py::test_uses_resource PASSED [ 50%]
..\..\..\Users\makmw\AppData\Local\Temp\tmp21oss2p5_test.py::test_cleanup_order_after PASSED [100%]

============================== 2 passed in 0.11s ==============================
0

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)
============================= test session starts =============================
collecting ... collected 1 item

..\..\..\Users\makmw\AppData\Local\Temp\tmpi7gsd0ei_test.py::test_uses_broken_fixture ERROR [100%]

=================================== ERRORS ====================================
_________________ ERROR at setup of test_uses_broken_fixture __________________
ScopeMismatch: You tried to access the function scoped fixture per_test_id with a session scoped request object. Requesting fixture stack:
tmpi7gsd0ei_test.py:8:  def broken_session_fixture(per_test_id)
Requested fixture:
tmpi7gsd0ei_test.py:4:  def per_test_id()
=========================== short test summary info ===========================
ERROR ..\..\..\Users\makmw\AppData\Local\Temp\tmpi7gsd0ei_test.py::test_uses_broken_fixture - Failed: ScopeMismatch: You tried to access the function scoped fixture per_...
============================== 1 error in 0.11s ===============================
1

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)
============================= test session starts =============================
collecting ... collected 4 items

..\..\..\Users\makmw\AppData\Local\Temp\tmp4r444uw__test.py::test_lookup_5 PASSED [ 25%]
..\..\..\Users\makmw\AppData\Local\Temp\tmp4r444uw__test.py::test_lookup_100 PASSED [ 50%]
..\..\..\Users\makmw\AppData\Local\Temp\tmp4r444uw__test.py::test_lookup_1000 PASSED [ 75%]
..\..\..\Users\makmw\AppData\Local\Temp\tmp4r444uw__test.py::test_built_only_once PASSED [100%]

============================== 4 passed in 0.12s ==============================
0

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.