Table of Contents
- What Are PyTest Fixtures?
- Basic Fixture Usage
- Fixture Scopes: Controlling Execution Frequency
- Sharing Fixtures Across Files with
conftest.py - Parameterized Fixtures: Testing Multiple Scenarios
- Teardown: Cleaning Up After Tests
- Advanced Fixture Features
- Best Practices for Fixtures
- Conclusion
- References
What Are PyTest Fixtures?
PyTest fixtures are reusable components that define the setup and teardown logic for tests. Unlike traditional setUp()/tearDown() methods (e.g., in Python’s unittest framework), fixtures are modular, composable, and scoped. They allow you to:
- Reuse setup/teardown logic across multiple tests.
- Control how often a fixture is executed (e.g., once per test, once per module).
- Pass fixture results directly to tests as arguments.
- Dependencies between fixtures (e.g., a database fixture that depends on a config fixture).
In short, fixtures eliminate boilerplate, making tests more readable and maintainable.
Basic Fixture Usage
To create a fixture, use the @pytest.fixture decorator. Fixtures are functions that return (or yield) a value, which can then be injected into tests by passing the fixture’s name as a test argument.
Example: A Simple Fixture
Let’s start with a basic fixture that returns a list of sample users:
# test_users.py
import pytest
@pytest.fixture
def sample_users():
# Setup: Create a list of users
return ["alice", "bob", "charlie"]
def test_user_count(sample_users):
# Test uses the `sample_users` fixture
assert len(sample_users) == 3
def test_first_user(sample_users):
assert sample_users[0] == "alice"
How it works:
- The
sample_usersfixture is defined with@pytest.fixture. - Tests
test_user_countandtest_first_userdeclaresample_usersas an argument. PyTest automatically runs the fixture and passes its return value to the test.
Fixture Scopes: Controlling Execution Frequency
By default, fixtures run once per test function (scope: function). But you can customize this with the scope parameter to optimize performance (e.g., avoid reinitializing expensive resources like database connections for every test).
PyTest supports five scopes (in order of increasing breadth):
| Scope | Description |
|---|---|
function | Runs once per test function (default). |
class | Runs once per test class (all methods in the class share the fixture). |
module | Runs once per module (all tests in the .py file share the fixture). |
package | Runs once per package (all tests in the package share the fixture). |
session | Runs once per test session (all tests in the entire run share the fixture). |
Example: Module-Scoped Fixture
Suppose you have a fixture that initializes a database connection—an expensive operation. Use scope="module" to run it once per module instead of per test:
# test_database.py
import pytest
import sqlite3
@pytest.fixture(scope="module")
def db_connection():
# Setup: Create a database connection (runs once per module)
conn = sqlite3.connect(":memory:")
yield conn # Pass the connection to tests
# Teardown: Close the connection after all tests in the module
conn.close()
def test_create_table(db_connection):
cursor = db_connection.cursor()
cursor.execute("CREATE TABLE users (id INT, name TEXT)")
db_connection.commit()
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='users'")
assert cursor.fetchone() is not None
def test_insert_user(db_connection):
cursor = db_connection.cursor()
cursor.execute("INSERT INTO users VALUES (1, 'alice')")
db_connection.commit()
cursor.execute("SELECT name FROM users WHERE id=1")
assert cursor.fetchone()[0] == "alice"
Here, db_connection runs once when the module starts, and all tests in test_database.py reuse the same connection.
Sharing Fixtures Across Files with conftest.py
To share fixtures across multiple test files, place them in a conftest.py file. PyTest automatically discovers fixtures in conftest.py without requiring explicit imports.
Project Structure Example
my_project/
├── tests/
│ ├── conftest.py # Shared fixtures here
│ ├── test_auth.py # Tests using shared fixtures
│ └── test_database.py # Tests using shared fixtures
Example: conftest.py for Shared Config
Define a config fixture in conftest.py to share app configuration across tests:
# tests/conftest.py
import pytest
@pytest.fixture(scope="session")
def config():
return {
"database_url": "sqlite:///:memory:",
"debug_mode": False,
"max_users": 100
}
Now, any test in the tests/ directory can use the config fixture directly:
# tests/test_auth.py
def test_debug_mode(config):
assert config["debug_mode"] is False
def test_max_users(config):
assert config["max_users"] == 100
Parameterized Fixtures: Testing Multiple Scenarios
Fixtures can be parameterized to return multiple values, allowing tests to run with different inputs. Use the params argument in @pytest.fixture to define parameters, and request.param to access the current parameter in the fixture.
Example: Parameterized User Roles
Suppose you want to test an API with different user roles (admin, user, guest). A parameterized fixture can supply each role to your tests:
# tests/conftest.py
import pytest
@pytest.fixture(params=["admin", "user", "guest"])
def user_role(request):
# `request` is a special fixture that provides test context
return request.param # Returns "admin", then "user", then "guest"
Now, tests using user_role will run once for each parameter:
# tests/test_api.py
def test_access_level(user_role):
if user_role == "admin":
assert has_access(user_role, "delete") is True
elif user_role == "user":
assert has_access(user_role, "delete") is False
else: # guest
assert has_access(user_role, "delete") is False
# Helper function (could be in a separate module)
def has_access(role, action):
permissions = {
"admin": ["read", "write", "delete"],
"user": ["read", "write"],
"guest": ["read"]
}
return action in permissions.get(role, [])
When run, test_access_level executes 3 times (once per role), ensuring coverage for all scenarios.
Teardown: Cleaning Up After Tests
Fixtures handle teardown (cleanup) using yield instead of return. The code before yield is setup; the code after is teardown, which runs after the test (or scope) completes.
Example: Temporary File Fixture
Create a fixture that generates a temporary file, passes its path to the test, then deletes it afterward:
# tests/conftest.py
import pytest
import tempfile
import os
@pytest.fixture(scope="function")
def temp_file():
# Setup: Create a temporary file
with tempfile.NamedTemporaryFile(mode="w", delete=False) as f:
f.write("Hello, PyTest!")
temp_path = f.name # Save the file path
yield temp_path # Pass the path to the test
# Teardown: Delete the file after the test
os.unlink(temp_path)
# tests/test_files.py
def test_temp_file_content(temp_file):
with open(temp_file, "r") as f:
content = f.read()
assert content == "Hello, PyTest!"
def test_temp_file_deleted(temp_file):
assert os.path.exists(temp_file) is True # File exists during test
# After test: teardown runs, and the file is deleted
Advanced Fixture Features
Autouse Fixtures
Autouse fixtures run automatically for all tests in their scope, without needing to be explicitly passed as test arguments. Use autouse=True to enable this.
Example: Logging Test Durations
An autouse fixture to log how long each test takes:
# tests/conftest.py
import pytest
import time
@pytest.fixture(scope="function", autouse=True)
def log_test_duration(request):
start_time = time.time()
yield # Test runs here
end_time = time.time()
duration = end_time - start_time
print(f"\nTest {request.node.name} took {duration:.2f} seconds")
Now, every test in the session will automatically log its duration.
Fixture Dependencies
Fixtures can depend on other fixtures, creating a dependency chain. PyTest resolves dependencies automatically.
Example: Database Fixture with Config Dependency
A database fixture that depends on the config fixture to get the database URL:
# tests/conftest.py
import pytest
import sqlite3
@pytest.fixture(scope="session")
def config():
return {"database_url": "sqlite:///:memory:"}
@pytest.fixture(scope="module")
def database(config): # Depends on the `config` fixture
conn = sqlite3.connect(config["database_url"])
yield conn
conn.close()
Here, database requires config to run. PyTest ensures config executes first, then passes its result to database.
Best Practices for Fixtures
- Keep Fixtures Focused: Each fixture should do one thing (e.g.,
configfor settings,temp_filefor temporary files). - Use Appropriate Scopes: Use
session/modulescopes for expensive setup (e.g., DB connections) to speed up tests. - Leverage
conftest.py: Share fixtures across tests withconftest.pyinstead of duplicating code. - Document Fixtures: Add docstrings to explain what a fixture does, its scope, and dependencies.
- Avoid Overusing Autouse: Autouse fixtures can slow down tests if misused. Use them only for cross-cutting concerns (e.g., logging).
Conclusion
PyTest fixtures transform test setup from a repetitive chore into a modular, maintainable system. By leveraging scopes, conftest.py, parameterization, and teardown with yield, you can write cleaner, faster, and more scalable tests.
Whether you’re testing a small script or a large application, fixtures help you focus on writing meaningful test logic instead of boilerplate. Start using them today to level up your testing workflow!