Most tests need something set up before they run ā a database connection, a temp file, a configured object. Fixtures are pytest's answer to sharing that setup cleanly across many tests, without copy-pasting the same setup code (and forgetting the same cleanup) in every single test function.
1Fixtures as Dependency Injection for Tests
Without fixtures, any setup a test needs ā constructing a sample object, establishing a test database connection ā either gets copy-pasted at the top of every test function that needs it (real duplication, with all the maintenance risk that implies: change the setup logic, and now ten separate copies need updating consistently), or gets extracted into a plain helper function that every test explicitly calls, which works but doesn't provide any additional structure beyond ordinary function calls.
@pytest.fixture turns a function into something closer to dependency injection: sample_user, decorated as a fixture, becomes available to any test simply by naming a parameter with that exact same name ā def test_user_has_valid_email(sample_user): ā and pytest automatically calls the fixture function and passes its return value in, with no explicit import or manual invocation needed inside the test body itself. Multiple tests requesting the same fixture name all share the identical setup logic, defined exactly once.
This is a genuinely different mechanism than a plain helper function, even though the code inside a simple fixture might look similar ā fixtures participate in pytest's dependency graph (fixtures can themselves depend on other fixtures, simply by naming them as parameters), support the scoping and yield-based teardown covered next, and are automatically discoverable from a shared conftest.py file across an entire test directory without needing to be explicitly imported into every test file that uses them.
import pytest
@pytest.fixture
def sample_user():
return {"id": 1, "name": "Ada", "email": "ada@example.com"}
def test_user_has_valid_email(sample_user):
assert "@" in sample_user["email"]
def test_user_has_name(sample_user):
assert sample_user["name"] == "Ada"
# Both tests share the SAME fixture definition -- change it once, both updatepytest matches the parameter name, calls the fixture, injects the result
2yield: Splitting Setup From Guaranteed Teardown
A fixture using return (like sample_user) provides setup only ā the value is constructed and handed to the test, with nothing further to clean up afterward, appropriate for simple, stateless data. Many real fixtures need genuine teardown ā closing a database connection, deleting a temporary file, releasing a lock ā that must run after the test completes, *regardless* of whether that test passed or failed.
yield inside a fixture function splits it into exactly these two phases: everything before yield is setup, executed before the test runs; the value passed to yield is what the test receives as its fixture parameter; and everything *after* yield is teardown, executed once the test has finished ā critically, pytest guarantees this teardown code runs whether the test passed, failed with an assertion error, or raised an unexpected exception, mirroring exactly the guarantee a context manager's __exit__ or a try/finally block provides, covered in depth in the Context Managers lesson.
This guarantee matters concretely: without it, a failing test could leave a test database connection open, a temporary file undeleted, or a lock unreleased ā resource leaks that can cause subsequent, unrelated tests to fail mysteriously (a classic, confusing source of flaky test suites) simply because an earlier test's cleanup never ran. yield-based fixtures make that class of bug structurally impossible for any fixture written this way.
import pytest
@pytest.fixture
def temp_database():
db = create_test_database() # SETUP -- runs before the test
yield db # the test receives 'db' here
db.close() # TEARDOWN -- runs after the test, even if it FAILEDCode after yield (db.close()) runs regardless of test outcome
3Scope: Trading Isolation for Speed, Deliberately
By default, a fixture's scope is "function" ā its setup code runs fresh, once, for *every single test* that requests it, guaranteeing each test gets a completely clean, isolated instance with no state leaking between tests. This is the safest default and the right choice for anything where cross-test contamination would be a real correctness concern ā a mutable data structure, a database transaction that should roll back between tests.
Some setup is genuinely expensive and doesn't need to be repeated per-test to maintain correctness ā loading a large machine learning model, spinning up a genuinely heavyweight test container. scope="session" tells pytest to run that fixture's setup exactly *once*, for the entire test run, sharing the identical instance across every test that requests it ā a substantial speed improvement for expensive setup, at the deliberate cost of tests now sharing state (which is safe specifically when that state is read-only or otherwise cannot be mutated by any test in a way that would affect others).
Other scope values ("class", "module") sit between these two extremes, sharing setup across a narrower group of tests than the whole session but a broader group than a single test function. Choosing scope deliberately ā matching it to how expensive the setup genuinely is and how safe sharing state actually is for that specific fixture ā is a real engineering trade-off, not a default to set-and-forget; the wrong scope choice can either waste significant time re-running expensive setup unnecessarily, or introduce subtle, hard-to-diagnose test interdependencies from inappropriately shared state.
@pytest.fixture(scope="function") # default: fresh instance per test
def fresh_counter():
return {"count": 0}
@pytest.fixture(scope="session") # created ONCE, shared across every test
def expensive_ml_model():
return load_large_model() # loaded ONCE, not once per test -- much fasterExpensive setup runs ONCE, shared across every test ā a deliberate trade-off
4Step-by-Step Breakdown
Copy-pasting the same setup code into ten test functions means ten places to update when that setup logic needs to change. Fixtures fix that with something close to dependency injection for tests.
A fixture is a function decorated with @pytest.fixture -- a test that names it as a PARAMETER automatically receives its return value.
Checkpoint: How does test_user_has_valid_email(sample_user) receive the fixture's value?
- āpytest matches the parameter name "sample_user" to the fixture function of the same name and calls it automatically
- āThe test must explicitly import and call sample_user() itself
yield (instead of return) in a fixture separates SETUP from TEARDOWN -- code after yield runs automatically after the test finishes.
Checkpoint: Does the code after yield in a fixture (db.close()) run if the test itself FAILS with an assertion error?
- āYes ā teardown code after yield runs regardless of whether the test passed or failed
- āNo ā it only runs if the test passes successfully
Fixture SCOPE controls how often setup runs -- 'function' (default, every test) vs 'session' (once for the ENTIRE test run) trades isolation for speed.
Fixtures handle real setup and teardown; Mocking is the next tool, for isolating a test from its slow or unpredictable real dependencies entirely.
Guarantee Real Fixture Teardown. Finish with_fixture(): a fixture's teardown always runs, even if the test fails.
Level Up š
Advanced cheat sheets, SEO tricks, and interview prep for this topic.
Browser Support
Fully supported (via server-side Python execution).
Fully supported (via server-side Python execution).
Fully supported (via server-side Python execution).
Fully supported (via server-side Python execution).
Best Practices
Use yield-based fixtures whenever genuine teardown (closing, deleting, releasing) is needed
This guarantees cleanup runs regardless of whether the test passes or fails, preventing resource leaks that cause confusing, flaky failures in unrelated later tests.
Default to function scope; widen to session/module scope deliberately, only for genuinely expensive, safely-shareable setup
Function scope guarantees test isolation by default. Widening scope is a real trade-off worth making consciously for expensive setup, not a default optimization to apply everywhere.
Frequent Bugs
Using return instead of yield in a fixture that needs teardown (like closing a database connection), causing that cleanup code to never run at all, since there's no mechanism for code after a return statement to execute.
Use yield instead of return whenever a fixture needs teardown logic, placing the cleanup code after the yield statement so pytest runs it automatically once the test completes.
Real-World Examples
A Database Fixture With Guaranteed Rollback
A test suite needs each test to run against a real test database, but every test should see a clean, isolated database state, with any changes rolled back afterward regardless of whether the test passes.
import pytest
@pytest.fixture
def db_session():
connection = test_engine.connect()
transaction = connection.begin()
session = Session(bind=connection)
yield session
session.close()
transaction.rollback() # always runs -- undoes any changes the test made
connection.close()
def test_create_user(db_session):
user = User(name="Ada")
db_session.add(user)
db_session.commit()
assert db_session.query(User).count() == 1