Pytest fixtures can do a lot more than just provide setup and teardown for your tests; they’re actually the primary mechanism for managing any kind of shared state or resource your tests might need.
Let’s watch a fixture in action. Imagine you’re testing a simple database interaction. You need a database connection for each test, but you don’t want to create a new one every single time.
import pytest
import sqlite3
@pytest.fixture
def db_connection():
# Setup: Create a new in-memory SQLite database
conn = sqlite3.connect(":memory:")
print("\n--- Database connection established ---")
yield conn # Provide the connection to the test
# Teardown: Close the connection
conn.close()
print("\n--- Database connection closed ---")
def test_user_creation(db_connection):
cursor = db_connection.cursor()
cursor.execute("CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT)")
cursor.execute("INSERT INTO users (name) VALUES (?)", ("Alice",))
db_connection.commit()
cursor.execute("SELECT name FROM users WHERE id = 1")
result = cursor.fetchone()
assert result[0] == "Alice"
def test_user_lookup(db_connection):
cursor = db_connection.cursor()
cursor.execute("CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT)")
cursor.execute("INSERT INTO users (name) VALUES (?)", ("Bob",))
db_connection.commit()
cursor.execute("SELECT name FROM users WHERE id = 1")
result = cursor.fetchone()
assert result[0] == "Bob"
When you run pytest your_test_file.py, you’ll see the print statements indicating the setup and teardown phases. The db_connection fixture is executed once before test_user_creation starts, and then again before test_user_lookup starts. The yield keyword is where the magic happens: everything before yield is setup, and everything after yield is teardown. The value provided by yield (the conn object) is what gets passed to any test function that requests it by name.
The core problem fixtures solve is avoiding repetitive setup and teardown code. Instead of writing the same database connection logic in every test function, you define it once in a fixture. This makes your tests DRY (Don’t Repeat Yourself) and much easier to maintain. If you need to change how the database is set up, you only modify the fixture, not every single test.
Internally, pytest discovers fixtures by looking for functions decorated with @pytest.fixture. When a test function lists a fixture name as an argument, pytest automatically finds and executes that fixture. The execution scope of a fixture can be controlled. By default, fixtures run once per test function that uses them (function scope). However, you can specify other scopes like class (once per test class), module (once per test module), package (once per test package), or session (once per entire test run). This is crucial for performance; you wouldn’t want to spin up a web server for every single test function if it’s only needed once per test session.
To change the scope, you simply add scope="..." to the decorator:
@pytest.fixture(scope="session")
def expensive_resource():
# Setup for a resource that takes a long time to initialize
print("\n--- Initializing expensive resource ---")
resource = initialize_very_slowly()
yield resource
# Teardown for the expensive resource
print("\n--- Cleaning up expensive resource ---")
resource.cleanup()
This expensive_resource fixture would only run its setup code once at the beginning of the entire test session and its teardown code once at the very end, regardless of how many tests use it.
Fixtures can also depend on other fixtures. If test_user_creation needed a pre-populated table, you could create another fixture that depends on db_connection:
@pytest.fixture
def populated_db_connection(db_connection):
cursor = db_connection.cursor()
cursor.execute("CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT)")
cursor.execute("INSERT INTO users (name) VALUES (?)", ("Alice",))
db_connection.commit()
return db_connection # Or yield if teardown needed here too
Then, test_user_creation could request populated_db_connection instead of db_connection, ensuring the table is already set up.
The request fixture is a special, built-in fixture that provides information about the requesting test function, module, or class. You can use it to dynamically set up resources based on test metadata, or to access parameters passed to fixtures. For instance, you might use request.param to run a fixture with different configurations.
When you define a fixture with params, pytest will run the test function multiple times, once for each parameter. This is incredibly powerful for testing a function with various inputs without writing repetitive test cases.
@pytest.fixture(params=[1, 2, 3])
def number_param(request):
return request.param
def test_with_param(number_param):
assert number_param in [1, 2, 3]
This test_with_param will run three times, with number_param taking on the values 1, 2, and 3 sequentially. The output will clearly indicate which parameter value is being used for each test run.
The most surprising truth about fixtures is that they are not just for setup and teardown; they are the fundamental building blocks for dependency injection in pytest. By requesting a fixture as a function argument, you are telling pytest, "I need this dependency, please provide it." Pytest then resolves this dependency, ensuring the fixture is executed and its result is passed to your test. This system is so robust that it can manage complex dependency graphs between fixtures, executing them in the correct order and reusing them according to their specified scopes, which is how pytest achieves its speed and efficiency.
The next step in mastering pytest’s fixture system is understanding how to use autouse fixtures to automatically apply setup/teardown logic to all tests within a scope without explicit declaration.