KAHIBARO
Discord Login Register

19.6. Fixtures

Understanding Fixtures

Fixtures are a way to prepare and share reusable test setup. They let you say: "To run this test, I need X to be ready," without repeating setup code in every test.

In this chapter we assume you already know basic testing ideas and pytest syntax. We focus on how fixtures work and how to use them effectively in backend projects.

Why Fixtures Are Useful

In backend tests you often need:

Without fixtures, you tend to write the same setup code again and again:

python
def test_create_user():
    db = create_test_db()
    app = create_app(db)
    client = app.test_client()
    # test code
def test_get_user():
    db = create_test_db()
    app = create_app(db)
    client = app.test_client()
    # test code

Fixtures allow you to define this setup once, then inject it into many tests:

python
import pytest
@pytest.fixture
def client():
    db = create_test_db()
    app = create_app(db)
    return app.test_client()
def test_create_user(client):
    response = client.post("/users", json={"name": "Alice"})
    assert response.status_code == 201
def test_get_user(client):
    response = client.get("/users/1")
    assert response.status_code == 200

The tests read more clearly, and setup is centralized and easy to change.

Key idea: A fixture is a function marked with @pytest.fixture that returns something your test needs, and pytest injects it into any test function that asks for it by name.

Basic pytest Fixture Syntax

A simple fixture looks like this:

python
import pytest
@pytest.fixture
def sample_user():
    return {"id": 1, "name": "Alice"}

You use it in a test by listing the fixture name as a parameter:

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

pytest sees that test_user_has_name needs sample_user, calls the sample_user fixture function, and passes its return value to the test.

Naming and return values

Examples of common backend fixtures:

python
@pytest.fixture
def config():
    return {"DEBUG": False, "DATABASE_URL": "sqlite:///:memory:"}
@pytest.fixture
def token():
    return "test-token-123"
@pytest.fixture
def sample_todos():
    return [
        {"id": 1, "title": "Write docs"},
        {"id": 2, "title": "Add tests"},
    ]

Fixture Scope

By default, fixtures are created fresh for every test function that uses them. Sometimes you want to reuse the same fixture instance across multiple tests.

pytest supports scopes:

You control this with the scope argument:

python
@pytest.fixture(scope="function")
def user_function():
    return {"id": 1}
@pytest.fixture(scope="module")
def db_connection():
    print("Create DB connection")
    return object()
@pytest.fixture(scope="session")
def redis_client():
    print("Start Redis client")
    return object()

When to use which scope

A simple guideline for backend tests:

ScopeTypical use in backend tests
functionAny state that must be clean for each test, for example an empty database, a fresh HTTP client, a new temporary directory
classShared expensive setup needed for all tests in one test class
moduleShared expensive setup needed for all tests in one test file
sessionVery expensive or global resources, for example spinning up a Dockerized database for all tests

Example of a module scoped database:

python
@pytest.fixture(scope="module")
def db():
    # Run once for test file
    db = create_test_db()
    create_tables(db)
    yield db
    # Run once after all tests in this file
    db.close()

Setup and Teardown with yield

Often you need to set something up before the test, then clean it up after.

With pytest fixtures you use yield:

python
import pytest
@pytest.fixture
def temp_file(tmp_path):
    file_path = tmp_path / "test.txt"
    file_path.write_text("hello")
    # Setup finished, give the file path to tests
    yield file_path
    # Code after yield is teardown
    file_path.unlink()  # delete file

The test uses the fixture normally:

python
def test_temp_file_exists(temp_file):
    assert temp_file.read_text() == "hello"

Internally:

Backend example, FastAPI test client with database cleanup:

python
@pytest.fixture
def client():
    db = create_test_db()
    app = create_app(db)
    test_client = app.test_client()
    yield test_client
    db.drop_all()
    db.close()

Important rule: Use yield inside fixtures when you need both setup and teardown. All code after yield is guaranteed to run, even if the test fails.

Using Built‑in pytest Fixtures

pytest comes with some very useful fixtures, especially for file and path handling.

tmp_path: temporary directories

tmp_path creates a unique temporary directory for each test:

python
def test_write_file(tmp_path):
    file_path = tmp_path / "data.txt"
    file_path.write_text("Backend\n")
    assert file_path.read_text() == "Backend\n"

You can wrap tmp_path into your own fixture:

python
@pytest.fixture
def log_file(tmp_path):
    file_path = tmp_path / "app.log"
    file_path.write_text("")
    return file_path
def test_logging(log_file):
    log_file.write_text("INFO: started\n")
    assert "INFO" in log_file.read_text()

monkeypatch: modifying environment and attributes

monkeypatch lets you change environment variables or attributes during tests.

Changing environment variable:

python
def test_env_config(monkeypatch):
    monkeypatch.setenv("APP_ENV", "test")
    assert load_env() == "test"

Replacing a function:

python
import my_app
def fake_send_email(to, subject, body):
    return True
def test_registration(monkeypatch):
    monkeypatch.setattr(my_app, "send_email", fake_send_email)
    assert my_app.register_user("alice@example.com") is True

This is very useful for backend tests that must not call real external services.

Composing Fixtures

Fixtures can depend on other fixtures. This is how you build layers of setup.

Example: database connection, then repository, then service:

python
@pytest.fixture
def db():
    return create_test_db()
@pytest.fixture
def user_repository(db):
    return UserRepository(db)
@pytest.fixture
def user_service(user_repository):
    return UserService(user_repository)
def test_create_user(user_service):
    user = user_service.create_user("alice@example.com")
    assert user.email == "alice@example.com"

pytest resolves dependencies by matching parameter names with fixture names.

Dependency tree example

text
user_service
  └─ user_repository
       └─ db

When a test needs user_service, pytest will:

  1. Create db
  2. Create user_repository(db)
  3. Create user_service(user_repository)
  4. Run your test with user_service

This pattern matches backend architecture layers and keeps each piece testable.

Autouse Fixtures

Sometimes you want a fixture to run automatically for many or all tests without listing it explicitly.

You can use autouse=True:

python
@pytest.fixture(autouse=True)
def set_test_env(monkeypatch):
    monkeypatch.setenv("ENV", "test")

Now every test in that file will have ENV="test".

Another example, automatically cleaning a global cache:

python
@pytest.fixture(autouse=True)
def clear_cache():
    cache.clear()

Use autouse carefully:

Fixtures in conftest.py

If you define a fixture in a normal test file, only tests in that file can use it.

To share fixtures across multiple test files, put them in conftest.py:

text
tests/
  conftest.py
  test_users.py
  test_orders.py

conftest.py example:

python
# tests/conftest.py
import pytest
from my_app import create_app, create_test_db
@pytest.fixture
def db():
    db = create_test_db()
    yield db
    db.drop_all()
    db.close()
@pytest.fixture
def client(db):
    app = create_app(db)
    return app.test_client()

Now both test_users.py and test_orders.py can simply use client and db:

python
# tests/test_users.py
def test_create_user(client):
    response = client.post("/users", json={"email": "alice@example.com"})
    assert response.status_code == 201

You do not need to import fixtures from conftest.py. pytest discovers them automatically.

Parameterized Fixtures

Sometimes you want the same fixture to provide multiple variations. For example, test with multiple database backends or various user roles.

You can parameterize a fixture:

python
@pytest.fixture(params=["user", "admin"])
def role(request):
    return request.param
def test_access(role):
    if role == "admin":
        assert can_access_admin_panel(role) is True
    else:
        assert can_access_admin_panel(role) is False

pytest will run test_access twice:

  1. With role == "user"
  2. With role == "admin"

Backend example, testing two hash algorithms:

python
@pytest.fixture(params=["bcrypt", "argon2"])
def hasher(request):
    return get_hasher(algorithm=request.param)
def test_password_hashing(hasher):
    password = "secret"
    hashed = hasher.hash(password)
    assert hasher.verify(password, hashed)

This keeps your test code simple while covering multiple configurations.

Fixtures for Database Tests

Database tests are common in backend development, and fixtures are essential to:

Example using an in memory SQLite database:

python
import pytest
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from my_app.models import Base
@pytest.fixture(scope="session")
def engine():
    engine = create_engine("sqlite:///:memory:")
    Base.metadata.create_all(engine)
    return engine
@pytest.fixture
def db_session(engine):
    Session = sessionmaker(bind=engine)
    session = Session()
    yield session
    session.rollback()
    session.close()

Test using the session:

python
def test_create_user(db_session):
    user = User(email="alice@example.com")
    db_session.add(user)
    db_session.commit()
    assert user.id is not None

You can also integrate this with your FastAPI app fixture, so your API tests use the same database session fixture.

Fixtures for API Client Tests

For HTTP API tests in FastAPI or Flask, you typically have a client fixture.

FastAPI example:

python
import pytest
from fastapi.testclient import TestClient
from my_app.main import create_app
@pytest.fixture
def client():
    app = create_app(testing=True)
    return TestClient(app)
def test_root(client):
    response = client.get("/")
    assert response.status_code == 200

You can layer fixtures:

python
@pytest.fixture
def auth_token(client):
    response = client.post("/login", json={"username": "alice", "password": "secret"})
    return response.json()["access_token"]
def test_get_profile(client, auth_token):
    headers = {"Authorization": f"Bearer {auth_token}"}
    response = client.get("/me", headers=headers)
    assert response.status_code == 200

This reduces duplication of authentication logic across many tests.

Best Practices for Backend Fixtures

Some practical rules to keep your test suite maintainable:

  1. Keep fixtures small and focused
    Each fixture should do one clear thing, for example "create app" or "create user". Compose them instead of making one giant fixture.
  2. Avoid hidden magic
    Prefer explicit fixtures in test function parameters. Use autouse only when behavior should truly apply to everything.
  3. Use clear, descriptive names
    For example db_session, client, admin_user, normal_user, valid_token, expired_token.
  4. Reset state between tests
    Use function scoped fixtures and proper teardown so tests do not affect each other, for example clearing tables, caches, and environment variables.
  5. Share common fixtures via conftest.py
    Put cross cutting fixtures there, such as app, client, database, and configuration.
  6. Mock external services
    Use fixtures plus monkeypatch or libraries like responses to avoid calling real payment providers, email services, or external APIs.

By using fixtures well, your backend tests become cleaner, faster to write, and easier to maintain as your application grows.

Views: 6

Comments

Please login to add a comment.

Don't have an account? Register now!