Python Test Fixtures Best Practices — Core Concepts
What fixtures do
A test fixture provides the preconditions a test needs to run. In pytest, fixtures are functions decorated with @pytest.fixture that set up and optionally tear down test dependencies — database connections, test users, temporary files, API clients, or any other state a test requires.
Scope matters
Pytest fixtures support four scopes that control how often the fixture runs:
- function (default): Runs before each test function. Maximum isolation but slowest.
- class: Runs once per test class. Tests in the class share the fixture’s result.
- module: Runs once per test file. All tests in the file share it.
- session: Runs once for the entire test suite. Every test shares the same instance.
The rule of thumb: use the narrowest scope that keeps tests fast enough. A database connection might be session-scoped (expensive to create), while test data should be function-scoped (must be fresh for each test).
Isolation is non-negotiable
The most common fixture mistake is sharing mutable state. When one test modifies a shared fixture and another test depends on the original state, you get tests that pass individually but fail when run together — or worse, only fail in certain orderings.
Fix this by either making fixtures function-scoped or ensuring shared fixtures return immutable data. If a session-scoped fixture provides a database connection, each test should still set up and tear down its own data within that connection.
Composition over complexity
Instead of building one massive fixture that sets up everything, compose small fixtures together. A test that needs a user with an order doesn’t need a user_with_order fixture. It needs a user fixture and an order fixture that depends on user.
This keeps fixtures reusable. The user fixture works for authentication tests, profile tests, and order tests. The order fixture can be used anywhere an order is needed, not just with users.
Cleanup patterns
Fixtures that create state must clean it up. Pytest supports two patterns:
yield fixtures run setup code before yield and cleanup code after:
Setup happens → yield value to test → test runs → cleanup happens
finalizers are registered during setup and called after the test, even if the test fails. Less common but useful when cleanup is complex.
The critical principle: cleanup must happen regardless of test outcome. A test that fails shouldn’t leave stale data that contaminates subsequent tests.
Factory fixtures
When tests need similar but slightly different data, use factory fixtures — fixtures that return a function rather than a value. The test calls the factory with its specific parameters.
This avoids the “fixture explosion” problem where you end up with user_active, user_inactive, user_admin, user_premium — each nearly identical.
conftest.py organization
Pytest automatically discovers fixtures in conftest.py files. Place fixtures at the right directory level:
- Root
conftest.py: Shared across all tests (database connections, app factories) - Directory
conftest.py: Specific to a test subdirectory (API test helpers intests/api/conftest.py) - Test file: Fixtures used by only one file belong in that file
This hierarchy keeps fixtures discoverable without polluting the global namespace. When a developer reads a test, they can find its fixtures by checking the local file, then the directory conftest, then the root conftest.
Performance traps
Two patterns silently destroy test suite performance:
Over-isolation: Making every fixture function-scoped when some could safely be module or session-scoped. Creating a fresh database for every test when a transaction rollback would achieve the same isolation in milliseconds.
Hidden I/O: Fixtures that make network calls, read large files, or start services on every invocation. Cache these at higher scopes when the data doesn’t change between tests.
One thing to remember: Great fixtures are invisible — tests read like plain English descriptions of behavior, with all the setup and cleanup machinery hidden behind well-named fixture functions.
See Also
- Python Acceptance Testing Patterns How Python teams verify software does what real users actually asked for.
- Python Approval Testing How approval testing lets you verify complex Python output by comparing it to a saved 'golden' copy you already checked.
- Python Behavior Driven Development Get an intuitive feel for Behavior Driven Development so Python behavior stops feeling unpredictable.
- Python Browser Automation Testing How Python can control a web browser like a robot to test websites automatically.
- Python Chaos Testing Applications Why breaking your own Python systems on purpose makes them stronger.