Test Fixtures and Temp Files
What You'll Learn
How to use pytest fixtures to avoid repeating setup code, work with temporary files in tests, and manage shared test state.
What Are Fixtures?
A fixture is a function that provides test setup (and teardown) to tests that request it. Instead of repeating setup in every test:
# ❌ Repeated setup
def test_create_user():
db = Database(":memory:")
db.migrate()
user = db.create_user("Alice", "alice@example.com")
assert user["id"] is not None
def test_get_user():
db = Database(":memory:") # same setup repeated
db.migrate()
user = db.create_user("Alice", "alice@example.com")
found = db.get_user(user["id"])
assert found["name"] == "Alice"
Use a fixture:
# ✅ Fixture used by multiple tests
import pytest
from myapp.database import Database
@pytest.fixture
def db():
database = Database(":memory:")
database.migrate()
yield database # provide the object to the test
database.close() # teardown after the test
def test_create_user(db): # pytest injects the fixture automatically
user = db.create_user("Alice", "alice@example.com")
assert user["id"] is not None
def test_get_user(db):
user = db.create_user("Alice", "alice@example.com")
found = db.get_user(user["id"])
assert found["name"] == "Alice"
yield — Setup and Teardown in One Function
@pytest.fixture
def temp_database():
# SETUP — runs before the test
db = Database("/tmp/test.db")
db.migrate()
print("DB created")
yield db # hand the db to the test
# TEARDOWN — runs after the test (even if test fails)
db.close()
Path("/tmp/test.db").unlink(missing_ok=True)
print("DB cleaned up")
Fixture Scope — How Long Does It Live?
# function (default) — new fixture for each test
@pytest.fixture(scope="function")
def fresh_db():
...
# module — shared by all tests in the same file
@pytest.fixture(scope="module")
def module_db():
...
# session — shared across the entire test run
@pytest.fixture(scope="session")
def api_client():
client = create_client()
yield client
client.close()
Use wider scope for expensive setup (database connections, API clients).
Use function scope when tests need isolated state.
Built-In: tmp_path and tmp_path_factory
pytest provides tmp_path — a temporary directory that's automatically cleaned up:
def test_write_and_read_file(tmp_path):
# tmp_path is a pathlib.Path to a unique temp directory
file = tmp_path / "output.txt"
file.write_text("Hello, World!", encoding="utf-8")
content = file.read_text(encoding="utf-8")
assert content == "Hello, World!"
def test_process_directory(tmp_path):
# Create test files
(tmp_path / "data").mkdir()
(tmp_path / "data" / "record1.json").write_text('{"id": 1}', encoding="utf-8")
(tmp_path / "data" / "record2.json").write_text('{"id": 2}', encoding="utf-8")
results = process_directory(tmp_path / "data")
assert len(results) == 2
conftest.py — Shared Fixtures
Fixtures defined in conftest.py are automatically available to all tests in the same directory and subdirectories:
# tests/conftest.py
import pytest
from pathlib import Path
from myapp.database import Database
@pytest.fixture(scope="session")
def test_data_dir() -> Path:
return Path(__file__).parent / "data"
@pytest.fixture
def sample_users() -> list[dict]:
return [
{"id": 1, "name": "Alice", "email": "alice@example.com"},
{"id": 2, "name": "Bob", "email": "bob@example.com"},
]
@pytest.fixture
def empty_db():
db = Database(":memory:")
db.migrate()
yield db
db.close()
@pytest.fixture
def seeded_db(empty_db, sample_users):
for user in sample_users:
empty_db.insert("users", user)
return empty_db
Fixtures Calling Other Fixtures
@pytest.fixture
def config():
return {"host": "localhost", "port": 5432}
@pytest.fixture
def db(config): # config injected into db fixture
return Database(config["host"], config["port"])
def test_something(db): # db already has config injected
...
Parametrize with Fixtures
@pytest.fixture(params=["sqlite", "postgres"])
def database(request):
if request.param == "sqlite":
return Database(":memory:")
else:
return Database("postgresql://localhost/testdb")
def test_query(database):
# This test runs twice — once with sqlite, once with postgres
result = database.query("SELECT 1")
assert result is not None
Common Fixture Patterns
JSON test data from files
@pytest.fixture
def sample_config(tmp_path):
import json
config_data = {"host": "localhost", "port": 5432, "debug": True}
config_file = tmp_path / "config.json"
config_file.write_text(json.dumps(config_data), encoding="utf-8")
return config_file
Environment variable fixture
@pytest.fixture
def set_env(monkeypatch):
monkeypatch.setenv("API_KEY", "test-key-123")
monkeypatch.setenv("DEBUG", "true")
Reset global state
@pytest.fixture(autouse=True)
def reset_cache():
cache.clear()
yield
cache.clear()
autouse=True means the fixture runs for every test automatically.
Common Mistakes
| Mistake | Fix |
|---|---|
No teardown after yield | Add cleanup after yield |
scope="session" with mutable state | Tests interfere — use scope="function" |
Not using tmp_path for file tests | Hardcoded /tmp paths conflict between runs |
| Fixtures doing too much | Split large fixtures into smaller ones |
Ignoring conftest.py | Put shared fixtures there, not in test files |
Quick Reference
# Basic fixture
@pytest.fixture
def my_fixture():
resource = setup()
yield resource
teardown(resource)
# Scoped fixture
@pytest.fixture(scope="session")
def shared_client():
...
# Using tmp_path
def test_fn(tmp_path):
file = tmp_path / "test.txt"
file.write_text("content")
# conftest.py
# (fixtures here are auto-available to all tests)
# autouse — run for every test automatically
@pytest.fixture(autouse=True)
def clean_state():
setup()
yield
teardown()
# monkeypatch env vars
@pytest.fixture
def env(monkeypatch):
monkeypatch.setenv("KEY", "value")