19.6. Fixtures
Table of Contents
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:
- A database connection with tables created
- Some example users, products, or tasks in the database
- A configured FastAPI or Flask test client
- Temporary files or directories
- Environment variables set for tests
Without fixtures, you tend to write the same setup code again and again:
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 codeFixtures allow you to define this setup once, then inject it into many tests:
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 == 200The 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:
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:
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
- The fixture name is the function name
- A fixture can return any Python object
- The test does not know or care how the fixture is created, only what it receives
Examples of common backend fixtures:
@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:
function(default)classmodulepackagesession
You control this with the scope argument:
@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:
| Scope | Typical use in backend tests |
|---|---|
| function | Any state that must be clean for each test, for example an empty database, a fresh HTTP client, a new temporary directory |
| class | Shared expensive setup needed for all tests in one test class |
| module | Shared expensive setup needed for all tests in one test file |
| session | Very expensive or global resources, for example spinning up a Dockerized database for all tests |
Example of a module scoped database:
@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:
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 fileThe test uses the fixture normally:
def test_temp_file_exists(temp_file):
assert temp_file.read_text() == "hello"Internally:
- Code before
yieldruns before the test - The value after
yieldis what the test receives - Code after
yieldruns after the test finishes
Backend example, FastAPI test client with database cleanup:
@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:
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:
@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:
def test_env_config(monkeypatch):
monkeypatch.setenv("APP_ENV", "test")
assert load_env() == "test"Replacing a function:
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 TrueThis 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:
@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
user_service
└─ user_repository
└─ db
When a test needs user_service, pytest will:
- Create
db - Create
user_repository(db) - Create
user_service(user_repository) - 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:
@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:
@pytest.fixture(autouse=True)
def clear_cache():
cache.clear()
Use autouse carefully:
- It makes tests shorter
- But it can hide what setup is happening, so prefer explicit fixtures unless you really want global behavior
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:
tests/
conftest.py
test_users.py
test_orders.py
conftest.py example:
# 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:
# 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:
@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:
- With
role == "user" - With
role == "admin"
Backend example, testing two hash algorithms:
@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:
- Create a test database
- Run migrations or create tables
- Provide a clean state for each test
- Roll back changes after each test
Example using an in memory SQLite database:
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:
def test_create_user(db_session):
user = User(email="alice@example.com")
db_session.add(user)
db_session.commit()
assert user.id is not NoneYou 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:
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 == 200You can layer fixtures:
@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 == 200This reduces duplication of authentication logic across many tests.
Best Practices for Backend Fixtures
Some practical rules to keep your test suite maintainable:
- 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. - Avoid hidden magic
Prefer explicit fixtures in test function parameters. Useautouseonly when behavior should truly apply to everything. - Use clear, descriptive names
For exampledb_session,client,admin_user,normal_user,valid_token,expired_token. - 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. - Share common fixtures via conftest.py
Put cross cutting fixtures there, such as app, client, database, and configuration. - Mock external services
Use fixtures plusmonkeypatchor libraries likeresponsesto 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
KAHIBARO