63.4 pytest Fixtures: Scope, Yield, and Autouse
Right, so you’ve graduated from writing simple test functions and are now staring at a mess of duplicated setup code. You’re thinking, “There has to be a better way.” You are correct. The better way is called fixtures, and pytest’s implementation is so good it feels like cheating. Forget the clunky setUp and tearDown methods from unittest; we’re entering the big leagues now.
At its core, a fixture is just a function you mark with @pytest.fixture. This function’s job is to provide a specific, ready-to-use resource for your tests. When you write a test function and add the fixture’s name as a parameter, pytest magically runs that fixture function and passes its return value into your test. It’s dependency injection for your tests, and it’s beautiful.
# test_my_app.py
import pytest
@pytest.fixture
def a_clean_database():
# This is the setup phase
db = connect_to_test_database()
db.clear_all_tables()
yield db # This is what the test gets
# This is the teardown phase, after the test runs
db.disconnect()
def test_user_creation(a_clean_database):
a_clean_database.create_user("pytest_hero", "secret")
assert a_clean_database.user_exists("pytest_hero")
See what happened there? test_user_creation declares it needs a_clean_database. pytest sees that, finds the fixture, runs it, and passes the db object (what we yielded) into the test. After the test finishes, pytest resumes the fixture function to run the teardown (the db.disconnect() part). This yield statement is the heart of it—everything before is setup, the yield is the value, everything after is cleanup.
Fixture Scope: Sharing is (Sometimes) Caring
By default, a fixture runs once per test function that needs it. This is the function scope. It’s safe and isolated. But if setting up your resource is painfully slow (like spinning up a Docker container), running it for every single test is a recipe for a coffee break. This is where scope comes in.
You can set scope in the fixture decorator to "function" (default), "class", "module", "package", or "session". A session-scoped fixture runs exactly once for the entire test run. It’s the ultimate shared resource.
import pytest
@pytest.fixture(scope="session")
def docker_postgres():
"""Starts a Postgres container for the entire test session. You get one, and only one."""
container = start_postgres_container()
yield container
container.stop() # Tears down at the very, very end
@pytest.fixture(scope="function")
def db_session(docker_postgres): # Fixtures can use other fixtures!
# This runs for every test, but it's fast because it just gets a connection
session = docker_postgres.get_connection()
session.begin()
yield session
session.rollback() # Rolls back any changes made by the test, keeping things clean
session.close()
def test_one(db_session):
db_session.execute("INSERT INTO table VALUES (1)")
# Test does its thing...
def test_two(db_session):
# This test gets a *new* session, so it doesn't see the row from test_one
count = db_session.execute("SELECT COUNT(*) FROM table").scalar()
assert count == 0
The key insight here is that db_session uses the session-scoped docker_postgres fixture. We get the performance benefit of starting the container once, but the isolation benefit of a new database transaction per test. This is the classic way to manage expensive, shared resources.
The Siren Song of autouse
autouse=True is a tempting but dangerous option. It makes a fixture run automatically for every test in its scope, whether the test asks for it or not. It’s global. And like most global state, it’s a fantastic way to create mysterious, hard-to-debug test interactions.
Use it sparingly. A valid use case might be a fixture that patches a global environment variable for every test in a module to ensure a consistent setting. But for 99% of fixtures, explicit is better than implicit. You want to look at a test and know exactly what dependencies it has. If a test doesn’t need a database, it shouldn’t get one just because some fixture author got lazy.
# ✅ Probably okay
@pytest.fixture(autouse=True, scope="session")
def ensure_test_environment():
if os.environ.get("ENV") != "TEST":
pytest.fail("Must run tests in TEST environment")
# ❌ A recipe for confusion
@pytest.fixture(autouse=True)
def automatically_modify_global_state():
some_global_list.append("why?")
yield
some_global_list.pop()
Conftest.py: Your Fixture Hub
You will quickly tire of copying and pasting fixtures between test files. The solution is conftest.py. Any fixture you place in a conftest.py file is automatically available to every test in that directory and its subdirectories. It’s pytest’s way of letting you organize your test infrastructure. You’ll have a project-level conftest.py for your big session-scoped fixtures and maybe module-specific ones for more local concerns. It’s not magic, just sensible configuration.
The real power of fixtures isn’t just eliminating duplication; it’s about building a declarative, composable vocabulary for your test environment. You can build a fixture that depends on another fixture, which depends on another, and pytest handles the entire dependency graph for you. It feels like you’re just describing what you need, and the framework handles the messy how. And that, my friend, is how testing should be.