Skip to content
Codeloom
Testing

pytest Fixtures: A Complete Guide

Master pytest fixtures for reusable test setup and teardown. Covers scopes, parameterization, factories, autouse, and fixture composition.

·6 min read · By Codeloom
Intermediate 13 min read

What you'll learn

  • What fixtures are and why they replace setup/teardown
  • Fixture scopes: function, class, module, session
  • Parameterized fixtures for testing multiple scenarios
  • Factory fixtures and fixture composition
  • Autouse fixtures and conftest.py organization

Prerequisites

  • Basic Python and pytest knowledge
  • Understanding of test setup and teardown
  • Familiarity with decorators

Fixtures in pytest are functions that provide test data, set up resources, and tear them down. Unlike the setUp/tearDown pattern in unittest, fixtures are explicit (tests declare which fixtures they need by name), composable (fixtures can use other fixtures), and scoped (a fixture can run once per test, per class, per module, or per entire session).

Your first fixture

import pytest

@pytest.fixture
def sample_user():
    return {"name": "Alice", "email": "alice@test.com", "age": 30}

def test_user_name(sample_user):
    assert sample_user["name"] == "Alice"

def test_user_email(sample_user):
    assert "@" in sample_user["email"]

The test function declares that it needs sample_user by including it as a parameter. pytest injects the fixture automatically. Each test gets a fresh instance.

Fixtures with setup and teardown

Use yield to split setup (before yield) from teardown (after yield):

import pytest
import sqlite3

@pytest.fixture
def db_connection():
    # Setup
    conn = sqlite3.connect(":memory:")
    conn.execute("CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, email TEXT)")
    conn.execute("INSERT INTO users VALUES (1, 'Alice', 'alice@test.com')")
    conn.commit()

    yield conn  # This is what the test receives

    # Teardown
    conn.close()

def test_query_users(db_connection):
    cursor = db_connection.execute("SELECT name FROM users WHERE id = 1")
    assert cursor.fetchone()[0] == "Alice"

def test_insert_user(db_connection):
    db_connection.execute("INSERT INTO users VALUES (2, 'Bob', 'bob@test.com')")
    cursor = db_connection.execute("SELECT COUNT(*) FROM users")
    assert cursor.fetchone()[0] == 2

The teardown code after yield runs even if the test fails. This guarantees cleanup.

Fixture scopes

By default, a fixture runs once per test function. You can change the scope to reduce redundant setup:

@pytest.fixture(scope="function")  # Default: runs once per test
def per_test_data():
    return create_data()

@pytest.fixture(scope="class")  # Runs once per test class
def per_class_data():
    return create_data()

@pytest.fixture(scope="module")  # Runs once per test file
def per_module_data():
    return create_data()

@pytest.fixture(scope="session")  # Runs once for the entire test session
def per_session_data():
    return create_data()

Session-scoped database

@pytest.fixture(scope="session")
def database():
    """Create a test database once for all tests."""
    db = create_test_database()
    run_migrations(db)
    yield db
    db.drop()

@pytest.fixture(scope="function")
def db_session(database):
    """Create a transaction for each test and roll back after."""
    session = database.create_session()
    session.begin()
    yield session
    session.rollback()
    session.close()

The database is created once. Each test gets a session that rolls back, keeping tests isolated without recreating the database.

Parameterized fixtures

Run the same test with multiple fixture values:

@pytest.fixture(params=["sqlite", "postgresql", "mysql"])
def database_type(request):
    return request.param

def test_connection(database_type):
    # This test runs 3 times, once for each database type
    conn = connect(database_type)
    assert conn.is_connected()

Parameterized with IDs

@pytest.fixture(params=[
    pytest.param({"name": "Alice", "age": 30}, id="adult"),
    pytest.param({"name": "Bob", "age": 17}, id="minor"),
    pytest.param({"name": "Charlie", "age": 0}, id="newborn"),
])
def user_data(request):
    return request.param

def test_age_validation(user_data):
    # Test output shows: test_age_validation[adult], test_age_validation[minor], etc.
    result = validate_user(user_data)
    assert isinstance(result, bool)

Factory fixtures

When you need to create multiple instances with different parameters:

@pytest.fixture
def make_user():
    """Factory fixture for creating users."""
    created_users = []

    def _make_user(name="Alice", email=None, role="user"):
        email = email or f"{name.lower()}@test.com"
        user = User(name=name, email=email, role=role)
        user.save()
        created_users.append(user)
        return user

    yield _make_user

    # Cleanup all created users
    for user in created_users:
        user.delete()

def test_admin_permissions(make_user):
    admin = make_user(name="Admin", role="admin")
    regular = make_user(name="Regular", role="user")

    assert admin.can_delete_users()
    assert not regular.can_delete_users()

Fixture composition

Fixtures can use other fixtures:

@pytest.fixture
def user(db_session):
    """Depends on db_session fixture."""
    user = User(name="Alice", email="alice@test.com")
    db_session.add(user)
    db_session.commit()
    return user

@pytest.fixture
def auth_token(user):
    """Depends on user fixture, which depends on db_session."""
    return generate_token(user.id)

@pytest.fixture
def auth_client(auth_token):
    """Depends on auth_token."""
    client = TestClient(app)
    client.headers["Authorization"] = f"Bearer {auth_token}"
    return client

def test_get_profile(auth_client):
    # All fixtures in the chain are set up automatically
    response = auth_client.get("/profile")
    assert response.status_code == 200

pytest resolves the dependency chain: db_session -> user -> auth_token -> auth_client.

conftest.py

Fixtures defined in conftest.py are available to all tests in the same directory and subdirectories, without needing to import them:

tests/
  conftest.py          # Fixtures available to ALL tests
  test_users.py
  api/
    conftest.py        # Fixtures available to tests in api/
    test_endpoints.py
  models/
    conftest.py        # Fixtures available to tests in models/
    test_user_model.py
# tests/conftest.py
import pytest

@pytest.fixture(scope="session")
def app():
    return create_app(testing=True)

@pytest.fixture
def client(app):
    return app.test_client()
# tests/test_users.py
def test_homepage(client):  # 'client' is found in conftest.py
    response = client.get("/")
    assert response.status_code == 200

Autouse fixtures

Fixtures with autouse=True are applied to all tests in their scope automatically, without tests needing to request them:

@pytest.fixture(autouse=True)
def reset_environment():
    """Reset environment variables for every test."""
    original = os.environ.copy()
    yield
    os.environ.clear()
    os.environ.update(original)

@pytest.fixture(autouse=True, scope="session")
def setup_logging():
    """Configure logging once for the entire session."""
    logging.basicConfig(level=logging.DEBUG)
    yield

Use autouse sparingly. It makes the test setup implicit, which can be confusing.

Built-in fixtures

pytest provides several useful built-in fixtures:

def test_tmp_path(tmp_path):
    """tmp_path provides a temporary directory unique to this test."""
    file = tmp_path / "data.txt"
    file.write_text("hello")
    assert file.read_text() == "hello"

def test_capsys(capsys):
    """capsys captures stdout and stderr."""
    print("hello")
    captured = capsys.readouterr()
    assert captured.out == "hello\n"

def test_monkeypatch(monkeypatch):
    """monkeypatch modifies objects, dicts, and env vars."""
    monkeypatch.setenv("API_KEY", "test-key")
    assert os.environ["API_KEY"] == "test-key"

    monkeypatch.setattr(requests, "get", mock_get)

def test_recwarn(recwarn):
    """recwarn captures warnings."""
    warnings.warn("deprecation", DeprecationWarning)
    assert len(recwarn) == 1
    assert recwarn[0].category == DeprecationWarning

Fixture finalization with addfinalizer

An alternative to yield-based teardown:

@pytest.fixture
def smtp_connection(request):
    conn = smtplib.SMTP("smtp.test.com")

    def cleanup():
        conn.close()

    request.addfinalizer(cleanup)
    return conn

Use yield for simple cases and addfinalizer when you need multiple cleanup steps or conditional cleanup.

Practical example: API test suite

# conftest.py
import pytest
from myapp import create_app, db as _db

@pytest.fixture(scope="session")
def app():
    app = create_app(config="testing")
    with app.app_context():
        _db.create_all()
        yield app
        _db.drop_all()

@pytest.fixture(scope="function")
def db(app):
    with app.app_context():
        _db.session.begin_nested()
        yield _db
        _db.session.rollback()

@pytest.fixture
def client(app):
    return app.test_client()

@pytest.fixture
def make_user(db):
    def _make(name="Test User", email="test@test.com"):
        user = User(name=name, email=email)
        db.session.add(user)
        db.session.commit()
        return user
    return _make

@pytest.fixture
def authenticated_client(client, make_user):
    user = make_user()
    token = create_token(user)
    client.environ_base["HTTP_AUTHORIZATION"] = f"Bearer {token}"
    return client
# test_api.py
def test_create_user(client):
    response = client.post("/api/users", json={
        "name": "Alice",
        "email": "alice@test.com"
    })
    assert response.status_code == 201
    assert response.json["name"] == "Alice"

def test_get_profile(authenticated_client):
    response = authenticated_client.get("/api/profile")
    assert response.status_code == 200
    assert "name" in response.json

Summary

pytest fixtures replace setup/teardown with explicit, composable, scoped functions. Use yield for cleanup, scopes to control lifecycle (function, class, module, session), and parameterization to run tests with multiple inputs. Put shared fixtures in conftest.py for automatic discovery. Factory fixtures create multiple instances with different configurations. The fixture dependency chain is resolved automatically by pytest, so you focus on declaring what each test needs rather than managing setup order.