pytest Setup and Fixture Scope
Think of a function-scoped fixture as a fresh, clean setup created uniquely for each test function, guaranteeing isolation. A session-scoped fixture, however, is a single shared resource that's set up once and potentially modified by all tests in the session, like a shared whiteboard that everyone writes on without erasing.
The Setup
You are writing unit tests for an API. You define a pytest fixture to yield a database connection, but notice that mutations made in one test leak into downstream tests.
What Does This Print?
import pytest
# Simulating a mock database with session scope to "save execution time"
@pytest.fixture(scope="session")
def db_conn():
return {"users": ["alice", "bob"]}
def test_delete_user(db_conn):
db_conn["users"].remove("alice")
assert "alice" not in db_conn["users"]
def test_list_users(db_conn):
# This test relies on the original initial state of the database
assert "alice" in db_conn["users"]
The Output
The second test fails because the shared state in the db_conn fixture was mutated by the first test. Since the fixture scope is set to session, pytest initializes the fixture dictionary exactly once and passes a reference to that same dictionary object to every test that requests it. Mutations from individual tests accumulate, causing tests to depend on execution order and leak state across the test suite. This makes tests highly fragile and guarantees failures when running parallel test execution suites. To prevent state leaks, you must manage fixture scopes appropriately, ensuring that mutable data structures are regenerated for each unit test.
Why Python Does This
Pytest manages fixture lifetimes using a caching registry. When a test function references a fixture, pytest looks up its scope configuration. If a fixture has a broad scope, the framework runs the generator once, saves the output value in memory, and returns that exact object reference for all matching test cases. Because Python dictionaries, lists, and custom database connections are mutable objects, any in-place mutation alters the single cached instance in memory. CPython passes these objects by reference, meaning no automatic isolation or deep copying occurs between test cases unless explicitly coded inside the fixture itself.
The Fix
import pytest
# function scope: fresh state created before each test, torn down after
@pytest.fixture(scope="function")
def db_conn():
conn = {"users": ["alice", "bob"]}
yield conn
# Teardown: runs after every test, even if the test fails
conn["users"].clear() # reset state — prevents leaking into other fixtures
def test_delete_user(db_conn):
db_conn["users"].remove("alice")
assert "alice" not in db_conn["users"]
def test_list_users(db_conn):
# gets a fresh {"users": ["alice", "bob"]} — unaffected by previous test
assert "alice" in db_conn["users"]
Setting scope="function" ensures that the fixture's setup code is run before each test function that requests it, and its teardown (if any) runs after. This creates a completely isolated and pristine instance of the fixture for every test, preventing state from one test from affecting another.
How This Fails in Real Systems
A SaaS startup's CI pipeline began randomly failing tests in pull requests. Local developers couldn't reproduce the failures because they ran tests individually. The issue was a session-scoped mock Redis client mutating auth tokens. It took a week to discover that parallel execution using pytest-xdist caused random, flaky test failures because of shared global state in broad-scope fixtures.
Key Takeaway
session or module to save setup/teardown time, failing to account for mutable shared state leaking between tests and causing unexpected failures.