Skip to content
ExponaBLOG
PYTHON BACKEND DEVELOPMENT · DAY 057 / 100

pytest fixtures explained: practical tips and trade-offs

Reusable setup that keeps tests short.

Expona Admin7 min read

Introduction

When you've written your tenth test that spins up a database connection, or your twentieth that mocks the same API client, you start to feel the friction. pytest fixtures explained: practical tips and trade-offs will change how you organize test setup. Fixtures let you extract repetitive initialization logic into reusable, composable functions that inject themselves into your tests by name—no manual wiring, no boilerplate. We'll walk through the mechanics, scope strategies, teardown patterns, and how to share fixtures across your entire test suite.

Fixtures are injected by name

The core magic of pytest is dependency injection. When you define a fixture and reference it as a function parameter, pytest matches by name and calls the fixture before your test runs. This is cleaner than setUp methods and more explicit than global state.

Here's a basic example:

python
import pytest

@pytest.fixture
def user_repo():
    """Fixture that returns a UserRepository instance."""
    return UserRepository(db_connection="sqlite:///:memory:")

def test_user_creation(user_repo):
    """The user_repo fixture is injected automatically."""
    user = user_repo.create(name="Alice", email="alice@example.com")
    assert user.id is not None
    assert user.name == "Alice"

pytest sees user_repo in the test signature, finds the fixture with that name, runs it, and passes the result to your test. You can inject the same fixture into multiple tests, and each test gets a fresh instance (by default). This beats creating helpers or factory functions because the fixture system knows about lifecycle and can clean up after itself.

You can also compose fixtures—have one fixture depend on another:

python
@pytest.fixture
def db_session(user_repo):
    """A fixture that depends on another fixture."""
    session = user_repo.get_session()
    yield session
    session.close()

def test_user_update(db_session):
    # db_session depends on user_repo, both are set up in order
    db_session.update_user(1, name="Bob")
    assert db_session.get_user(1).name == "Bob"

This composition keeps setup logic modular and readable.

Scope: function, module, session

Fixture scope controls how often a fixture is created and destroyed. The three main scopes are function (default), module, and session. Choosing the right scope is crucial for test speed and correctness.

Function scope (the default) creates a fresh fixture instance for every test function that uses it. This is the safest choice because each test starts clean.

python
@pytest.fixture(scope="function")
def app():
    """Fresh Flask app instance per test."""
    app = Flask(__name__)
    app.config['TESTING'] = True
    return app

def test_index(app):
    # Gets a new app instance
    pass

def test_about(app):
    # Gets another new app instance
    pass

Module scope creates the fixture once per test module (Python file). Use this when setup is expensive and tests won't interfere with each other's state:

python
@pytest.fixture(scope="module")
def postgres_db():
    """Expensive to spin up; reused across all tests in this module."""
    db = PostgresDB(host="localhost", port=5432)
    db.setup_schema()
    yield db
    db.teardown_schema()

def test_query_1(postgres_db):
    # Reuses the same postgres_db instance
    pass

def test_query_2(postgres_db):
    # Same instance, but tests must not conflict
    pass

Session scope creates the fixture once for the entire test run. This is best for truly shared, immutable resources like a test data warehouse or configuration that every test can safely read:

python
@pytest.fixture(scope="session")
def test_config():
    """Loaded once, never changes."""
    return load_config("test.yml")

def test_api_url(test_config):
    assert test_config['api_url'] == "http://localhost:8000"

The trade-off: function scope is safest but slower if setup is heavy; module and session scope are faster but risk state leaking between tests. In production systems, we often use function scope with lightweight fixtures or module scope with careful isolation (transactions, fresh data per test).

Yield fixtures handle teardown

Many resources need cleanup. Database connections must close, temporary files must be deleted, mocks must be uninstalled. The yield pattern lets you run setup before the test and cleanup after:

python
@pytest.fixture
def temp_file():
    """Create a temp file, yield it, then delete it."""
    path = "/tmp/test_file.txt"
    with open(path, "w") as f:
        f.write("test data")
    
    yield path  # Test runs here
    
    # Cleanup after test
    os.remove(path)

def test_read_file(temp_file):
    with open(temp_file, "r") as f:
        content = f.read()
    assert content == "test data"
    # temp_file is deleted after test completes

This is cleaner than the old setup and teardown methods because the logic lives in one place. If you need to handle exceptions or ensure cleanup even if the test fails, yield handles that automatically:

python
@pytest.fixture
def db_transaction():
    """Start a transaction, rollback after."""
    conn = sqlite3.connect(":memory:")
    cursor = conn.cursor()
    cursor.execute("BEGIN")
    
    yield conn
    
    # Always rolls back, even if test raised an exception
    cursor.execute("ROLLBACK")
    conn.close()

def test_insert_and_rollback(db_transaction):
    db_transaction.execute("INSERT INTO users (name) VALUES ('Eve')")
    # After test, transaction is rolled back; changes never persist

For resources that need multiple teardown steps or error handling, you can use try/finally or context managers inside your fixture. The key principle: yield is the fixture's way of saying "set me up, run the test, then clean me up."

conftest.py shares fixtures

Fixtures defined in individual test files are local. To share fixtures across multiple test modules (or your entire project), put them in a conftest.py file. pytest discovers and loads conftest.py automatically, and any fixture defined there is available to all tests in that directory and subdirectories.

Here's a typical structure:

python

# tests/conftest.py
import pytest
from app.repositories import UserRepository
from app.models import User

@pytest.fixture
def user_repo():
    """Shared across all tests."""
    return UserRepository(db_url="sqlite:///:memory:")

@pytest.fixture
def sample_user(user_repo):
    """Depends on user_repo; also shared."""
    return user_repo.create(name="Test User", email="test@example.com")

@pytest.fixture
def mock_email(monkeypatch):
    """Monkeypatch is a built-in fixture."""
    def fake_send(to, subject, body):
        return {"status": "sent", "to": to}
    
    monkeypatch.setattr("app.email.send", fake_send)
    return fake_send

Then in any test file under tests/:

python

# tests/test_users.py
def test_create_user(user_repo):
    # user_repo fixture is found in conftest.py
    user = user_repo.create(name="Bob", email="bob@example.com")
    assert user.id is not None

def test_welcome_email(sample_user, mock_email):
    # Both fixtures come from conftest.py
    from app.email import send_welcome
    send_welcome(sample_user)
    # mock_email intercepted the call

You can have multiple conftest.py files at different levels (root, tests/, tests/unit/, tests/integration/) and pytest will load them in order. This lets you organize fixtures by concern: shared ones at the top level, specific ones in subdirectories.

A practical pattern we use in production: put basic fixtures (database, app, config) in the root conftest.py`, and domain-specific ones (user factory, order factory) in subdirectory conftest files. This keeps the root clean and makes it obvious where each fixture lives.

Common mistakes

Forgetting to yield in teardown-heavy fixtures. If you open a database connection but just return it without yield, the connection never closes. Always use yield if you need cleanup.

Using function scope when session scope would speed up tests. Sometimes we get lazy and create a new database for every test when we could create one per module and roll back transactions. Profile your tests and measure; don't assume.

Mixing fixture responsibilities. A fixture that creates a user AND sends an email AND validates the response is doing too much. Keep fixtures focused; compose them if you need multiple steps.

Naming fixtures ambiguously. `db` and `client` are vague. Use `postgres_db`, `http_client`, or `order_service_client`. When someone reads def test_list_orders(postgres_db, http_client), they immediately know what's being set up.

Not documenting fixture scope. If a fixture is session-scoped and therefore shared state, add a docstring and a comment. Future you (and your teammates) will thank you.

Key takeaways

  • Fixtures are injected by name and reduce boilerplate compared to manual setup or global state.
  • Use function scope for isolation and safety; module or session scope for expensive resources, but measure the trade-off.
  • The yield pattern elegantly handles setup and teardown in one function, ensuring cleanup even on test failure.
  • Organize shared fixtures in conftest.py to keep tests DRY and composable across modules.
  • Name fixtures clearly, document scope, and keep them focused on a single responsibility.
  • Compose fixtures by having one depend on another; this scales better than deep inheritance or monolithic setups.
  • Profile your tests; don't assume a wider scope is always better just because it's faster to write.
---
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles