Files
claudekit/.claude/skills/testing/pytest/references/fixture-patterns.md
T

6.7 KiB

pytest Fixture Patterns

Catalog of reusable fixture patterns for common testing scenarios.

1. Factory Fixture

Create multiple instances with customizable defaults.

import pytest
from dataclasses import dataclass

@pytest.fixture
def make_user():
    """Factory fixture: creates User instances with sensible defaults."""
    created = []

    def _make_user(
        name: str = "Test User",
        email: str | None = None,
        is_active: bool = True,
    ):
        if email is None:
            email = f"user-{len(created)}@test.com"
        user = User(name=name, email=email, is_active=is_active)
        created.append(user)
        return user

    yield _make_user

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

Usage:

def test_deactivate_user(make_user):
    user = make_user(name="Alice", is_active=True)
    user.deactivate()
    assert not user.is_active

2. Database Session (SQLAlchemy)

Transaction-isolated database session that rolls back after each test.

import pytest
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker

@pytest.fixture(scope="session")
def engine():
    """Create a test database engine (once per test session)."""
    engine = create_engine("postgresql://test:test@localhost:5432/test_db")
    yield engine
    engine.dispose()

@pytest.fixture(scope="session")
def tables(engine):
    """Create all tables once, drop after all tests."""
    Base.metadata.create_all(engine)
    yield
    Base.metadata.drop_all(engine)

@pytest.fixture
def db_session(engine, tables):
    """Provide a transactional database session that rolls back after each test."""
    connection = engine.connect()
    transaction = connection.begin()
    session = sessionmaker(bind=connection)()

    yield session

    session.close()
    transaction.rollback()
    connection.close()

3. Temporary Files and Directories

@pytest.fixture
def sample_config(tmp_path: Path) -> Path:
    """Create a temporary config file with test content."""
    config = tmp_path / "config.yaml"
    config.write_text(
        """\
        database:
          host: localhost
          port: 5432
        debug: true
        """
    )
    return config

@pytest.fixture
def data_dir(tmp_path: Path) -> Path:
    """Create a temporary directory structure for testing."""
    (tmp_path / "input").mkdir()
    (tmp_path / "output").mkdir()
    (tmp_path / "input" / "data.csv").write_text("id,name\n1,Alice\n2,Bob\n")
    return tmp_path

4. Mock External Service

import pytest
from unittest.mock import AsyncMock, MagicMock, patch

@pytest.fixture
def mock_http_client():
    """Mock an HTTP client with pre-configured responses."""
    client = MagicMock()
    client.get.return_value = MagicMock(
        status_code=200,
        json=lambda: {"status": "ok"},
    )
    client.post.return_value = MagicMock(
        status_code=201,
        json=lambda: {"id": "new-123"},
    )
    return client

@pytest.fixture
def mock_payment_gateway():
    """Mock a payment gateway service."""
    with patch("app.services.payment.PaymentGateway") as mock_cls:
        instance = mock_cls.return_value
        instance.charge.return_value = {
            "transaction_id": "txn-test-123",
            "status": "succeeded",
        }
        instance.refund.return_value = {
            "refund_id": "ref-test-456",
            "status": "refunded",
        }
        yield instance

# Async version
@pytest.fixture
def mock_email_service():
    """Mock an async email service."""
    with patch("app.services.email.EmailService") as mock_cls:
        instance = mock_cls.return_value
        instance.send = AsyncMock(return_value={"message_id": "msg-test-789"})
        yield instance

5. Authenticated Test Client (FastAPI)

import pytest
from httpx import AsyncClient, ASGITransport
from app.main import app
from app.auth import create_access_token

@pytest.fixture
def auth_token():
    """Generate a valid JWT token for testing."""
    return create_access_token(
        data={"sub": "test-user-id", "role": "admin"},
        expires_minutes=60,
    )

@pytest.fixture
def auth_headers(auth_token: str) -> dict[str, str]:
    """HTTP headers with Bearer token."""
    return {"Authorization": f"Bearer {auth_token}"}

@pytest.fixture
async def client():
    """Unauthenticated async test client."""
    transport = ASGITransport(app=app)
    async with AsyncClient(transport=transport, base_url="http://test") as c:
        yield c

@pytest.fixture
async def auth_client(auth_headers):
    """Authenticated async test client."""
    transport = ASGITransport(app=app)
    async with AsyncClient(
        transport=transport,
        base_url="http://test",
        headers=auth_headers,
    ) as c:
        yield c

6. Environment Variables

@pytest.fixture
def env_vars(monkeypatch):
    """Set environment variables for the test, automatically restored after."""
    monkeypatch.setenv("DATABASE_URL", "postgresql://test:test@localhost/test")
    monkeypatch.setenv("SECRET_KEY", "test-secret-key")
    monkeypatch.delenv("PRODUCTION_API_KEY", raising=False)

7. Freezing Time

@pytest.fixture
def frozen_time():
    """Freeze time to a specific moment."""
    fixed = datetime(2025, 1, 15, 12, 0, 0, tzinfo=timezone.utc)
    with patch("app.services.datetime") as mock_dt:
        mock_dt.now.return_value = fixed
        mock_dt.utcnow.return_value = fixed
        mock_dt.side_effect = lambda *a, **kw: datetime(*a, **kw)
        yield fixed

Alternative: use freezegun library with @freeze_time("2025-01-15 12:00:00").

8. Parametrized Fixture

@pytest.fixture(params=["sqlite", "postgresql"])
def db_url(request):
    """Run tests against multiple database backends."""
    urls = {
        "sqlite": "sqlite:///test.db",
        "postgresql": "postgresql://test:test@localhost/test",
    }
    return urls[request.param]

Fixture Scope Reference

Scope Lifetime Use For
function (default) Each test Most fixtures, mutable state
class Each test class Shared setup for a class
module Each test file Expensive setup shared across file
session Entire test run Database engine, heavy resources

Tips

  • Use yield (not return) when cleanup is needed after the test.
  • Use autouse=True sparingly -- only for things every test needs.
  • Keep fixtures small and composable -- combine them in tests, not in other fixtures.
  • Use monkeypatch instead of unittest.mock.patch for env vars and attributes when possible.
  • Name fixtures after what they provide, not what they do: db_session not setup_database.