pytest Fixtures: Complete Guide with Examples
pytest fixtures are the backbone of maintainable Python test suites. They handle setup, teardown, and shared state so your test functions can focus on what actually matters — asserting behavior. This guide covers everything from basic usage through advanced patterns, with working code examples throughout.
What Are pytest Fixtures and Why They Matter
A fixture is a function that prepares something your tests need — a database connection, a configured client, sample data, a temporary file. Instead of repeating setup code in every test, you define a fixture once and declare it as a parameter.
Without fixtures:
def test_user_creation():
db = Database(":memory:")
db.migrate()
user = db.create_user("alice@example.com")
assert user.id is not None
db.close()
def test_user_lookup():
db = Database(":memory:")
db.migrate()
db.create_user("alice@example.com")
user = db.find_user("alice@example.com")
assert user.email == "alice@example.com"
db.close()With fixtures:
@pytest.fixture
def db():
database = Database(":memory:")
database.migrate()
yield database
database.close()
def test_user_creation(db):
user = db.create_user("alice@example.com")
assert user.id is not None
def test_user_lookup(db):
db.create_user("alice@example.com")
user = db.find_user("alice@example.com")
assert user.email == "alice@example.com"Cleaner, DRY, and the teardown is guaranteed to run even if the test fails.
Basic Fixture with @pytest.fixture
Decorate any function with @pytest.fixture and use its name as a test parameter:
import pytest
@pytest.fixture
def sample_user():
return {"id": 1, "name": "Alice", "email": "alice@example.com"}
def test_user_name(sample_user):
assert sample_user["name"] == "Alice"
def test_user_email(sample_user):
assert "@" in sample_user["email"]pytest discovers the fixture by matching the parameter name. No imports needed — pytest handles the injection automatically.
Fixture Scope: function, class, module, session
By default, a fixture runs once per test function. The scope parameter controls how often the fixture is created and destroyed.
@pytest.fixture(scope="function") # default — new instance per test
def fn_scope():
return []
@pytest.fixture(scope="class") # shared across tests in a class
def class_scope():
return {"calls": 0}
@pytest.fixture(scope="module") # shared across all tests in a file
def module_scope():
print("Setting up module-level resource")
return DatabasePool(size=5)
@pytest.fixture(scope="session") # shared across the entire test run
def session_scope():
client = requests.Session()
client.headers["Authorization"] = "Bearer test-token"
return clientChoose scope based on the cost and safety of sharing:
- function — safe default; use for mutable state
- class — useful when grouping related tests logically
- module — good for expensive setup that's safe to share within a file (read-only data, schema migrations)
- session — reserved for truly expensive one-time setup (started servers, OAuth tokens)
A fixture can only use other fixtures of equal or broader scope. A session-scoped fixture cannot request a function-scoped fixture.
Fixture Dependencies (Fixtures Using Other Fixtures)
Fixtures can depend on other fixtures, just like tests can:
@pytest.fixture(scope="session")
def db_engine():
engine = create_engine("sqlite:///:memory:")
Base.metadata.create_all(engine)
return engine
@pytest.fixture
def db_session(db_engine):
Session = sessionmaker(bind=db_engine)
session = Session()
yield session
session.rollback()
session.close()
@pytest.fixture
def user(db_session):
u = User(name="Alice", email="alice@example.com")
db_session.add(u)
db_session.commit()
return u
def test_user_exists(user, db_session):
found = db_session.query(User).filter_by(email="alice@example.com").first()
assert found is not None
assert found.name == "Alice"pytest builds the dependency graph and resolves everything in the right order. Each layer handles one concern: db_engine creates the schema once, db_session manages transactions, user creates test data.
conftest.py — Shared Fixtures Across Tests
Fixtures defined in conftest.py are automatically available to all tests in the same directory and subdirectories — no import required.
project/
├── conftest.py # available everywhere
├── tests/
│ ├── conftest.py # available to tests/ and subdirs
│ ├── test_users.py
│ └── api/
│ ├── conftest.py # available only to tests/api/
│ └── test_endpoints.pyA typical conftest.py for a Flask app:
# conftest.py
import pytest
from myapp import create_app, db as _db
@pytest.fixture(scope="session")
def app():
app = create_app({"TESTING": True, "SQLALCHEMY_DATABASE_URI": "sqlite:///:memory:"})
return app
@pytest.fixture(scope="session")
def db(app):
with app.app_context():
_db.create_all()
yield _db
_db.drop_all()
@pytest.fixture
def client(app):
return app.test_client()Every test file under the project root can now use client, db, and app without any import.
Parametrized Fixtures
A single fixture can supply multiple values, causing each dependent test to run once per value:
@pytest.fixture(params=["sqlite", "postgresql", "mysql"])
def database_url(request):
urls = {
"sqlite": "sqlite:///:memory:",
"postgresql": "postgresql://localhost/testdb",
"mysql": "mysql://localhost/testdb",
}
return urls[request.param]
def test_connection(database_url):
engine = create_engine(database_url)
assert engine.connect()This runs test_connection three times — once per database. The test IDs become test_connection[sqlite], test_connection[postgresql], test_connection[mysql], making failures easy to identify.
You can also parametrize with complex objects:
@pytest.fixture(params=[
pytest.param({"role": "admin"}, id="admin"),
pytest.param({"role": "viewer"}, id="viewer"),
])
def user_role(request):
return request.paramYield Fixtures for Setup and Teardown
Use yield to split a fixture into setup (before yield) and teardown (after yield). Teardown runs even if the test fails:
@pytest.fixture
def temp_config_file(tmp_path):
config = tmp_path / "config.json"
config.write_text('{"debug": true, "timeout": 30}')
yield config
# cleanup runs here — even on test failure
if config.exists():
config.unlink()
@pytest.fixture
def mock_server():
server = MockHTTPServer(port=8888)
server.start()
yield server
server.stop() # always called
def test_config_loading(temp_config_file):
cfg = load_config(temp_config_file)
assert cfg["timeout"] == 30For teardown that must run even on setup failure, use request.addfinalizer instead:
@pytest.fixture
def resource(request):
r = allocate_resource()
request.addfinalizer(r.release)
return rBuilt-in Fixtures: tmp_path, capsys, monkeypatch, caplog
pytest ships with several powerful fixtures that cover common testing needs.
tmp_path
Provides a temporary directory unique to each test, cleaned up automatically:
def test_file_writing(tmp_path):
output = tmp_path / "results.txt"
write_report(output)
assert output.read_text().startswith("Report generated")Use tmp_path_factory (session-scoped) when you need temporary files shared across tests.
capsys
Captures stdout and stderr output:
def test_cli_output(capsys):
print_summary({"passed": 10, "failed": 2})
captured = capsys.readouterr()
assert "10 passed" in captured.out
assert "2 failed" in captured.outmonkeypatch
Patches attributes, environment variables, and dictionary values — all reversals are automatic after the test:
def test_env_variable(monkeypatch):
monkeypatch.setenv("DATABASE_URL", "sqlite:///:memory:")
assert get_db_url() == "sqlite:///:memory:"
def test_patched_method(monkeypatch):
monkeypatch.setattr("mymodule.requests.get", lambda url: MockResponse(200))
result = fetch_data("https://api.example.com/data")
assert result.status_code == 200
def test_modified_dict(monkeypatch):
monkeypatch.setitem(config, "retry_count", 5)
assert should_retry(config) is Truecaplog
Captures log records emitted during a test:
import logging
def test_warning_on_empty(caplog):
with caplog.at_level(logging.WARNING):
process_items([])
assert "No items to process" in caplog.text
assert any(r.levelno == logging.WARNING for r in caplog.records)Best Practices
Keep fixtures focused. A fixture should do one thing. If your db fixture also creates five test users, split that into a separate seeded_db fixture that depends on db.
Name fixtures as nouns. user, client, db_session — not setup_user or create_client. The name describes what the fixture provides, not what it does.
Prefer the narrowest scope. Start with function scope (the default). Only widen to module or session when you've measured that the setup cost actually matters.
Put shared fixtures in conftest.py immediately. If two test files use the same fixture, it belongs in conftest.py. Don't wait until the duplication is painful.
Use yield for any resource that needs cleanup. Even if your cleanup is just obj.close(), the yield pattern guarantees it runs. Never rely on test-end GC for external resources.
Avoid stateful session-scoped fixtures for mutable resources. A session-scoped database that tests write to will cause order-dependent test failures. Use transactions + rollback at function scope instead.
Document non-obvious fixtures. A one-line docstring explaining what the fixture provides and any side effects saves the next developer (often you, six months later) real time:
@pytest.fixture(scope="module")
def auth_token(app):
"""JWT token for a pre-created admin user. Valid for the test module duration."""
with app.app_context():
return generate_token(role="admin", expiry=3600)pytest fixtures are one of the best-designed features in any testing framework — composable, scoped, and automatic. Getting comfortable with them is the single biggest leverage point for writing clean, maintainable Python test suites.
Test More Than Functions
pytest fixtures power your unit and integration tests. For end-to-end browser testing with AI-generated tests and 24/7 monitoring, HelpMeTest handles the browser layer — starting free.