Designing Robust and Safe Fixtures in Pytest
Safe Teardown
Pytest's fixture system is robust, yet it operates within computational constraints and cannot automatically determine how to safely dismantle all states. Careless design can lead to leftover state from one test causing cascading failures in others.
Consider this email system test:
# test_emaillib.py
from emaillib import Email, MailAdminClient
import pytest
@pytest.fixture
def mail_setup():
admin = MailAdminClient()
sender = admin.create_user()
receiver = admin.create_user()
msg = Email(subject="Hello", body="Test message")
sender.send_email(msg, receiver)
yield receiver, msg
receiver.clear_mailbox()
admin.delete_user(sender)
admin.delete_user(receiver)
def test_inbox_receives_email(mail_setup):
recipient, message = mail_setup
assert message in recipient.inbox
While concise, this fixture is difficult to read, lacks a descriptive name, and is not reusable. More critically, if any step in the setup phase raises an exception, the teardown code will not execute.
Structuring Fixtures Safely
The safest approach restricts each fixture to a single state-changing operation, bundling it with its corresponding cleanup. Since most test-level state changes are transactional, the risk of partial failure is minimal. Isolating each operation into its own fixture ensures successful changes are always reverted.
For a website login test scenario:
- Create a user via an admin API.
- Launch a browser with Selenium.
- Navigate to the login page.
- Log in as the created user.
- Verify the user's name appears on the post-login page.
We must ansure the user is deleted and the browser closed after the test.
from uuid import uuid4
from urllib.parse import urljoin
from selenium.webdriver import Firefox
import pytest
from app.pages import LoginPage, DashboardPage
from app.clients import AdminClient
from app.models import User
@pytest.fixture
def admin_api(base_url, admin_auth):
return AdminClient(base_url, **admin_auth)
@pytest.fixture
def test_user(admin_api):
user = User(name="Alex", username=f"user-{uuid4()}", password="S3cr3t!")
admin_api.add_user(user)
yield user
admin_api.remove_user(user)
@pytest.fixture
def web_browser():
browser = Firefox()
yield browser
browser.quit()
@pytest.fixture
def user_login(web_browser, base_url, test_user):
web_browser.get(urljoin(base_url, "/login"))
login_screen = LoginPage(web_browser)
login_screen.authenticate(test_user)
@pytest.fixture
def dashboard(web_browser, user_login):
return DashboardPage(web_browser)
def test_dashboard_welcome_message(dashboard, test_user):
assert dashboard.title == f"Welcome, {test_user.name}!"
The order of fixture execution (test_user vs. web_browser) is irrelevant because each is atomic. If web_browser executes first and test_user fails, the browser still quits and no user is created. Conversely, if web_browser fails, it never starts and no user is created.
Safely Executing Multiple Assertions
After performing setup, you may need multiple assertions. Pytest facilitates this by using a broader fixture scope (like class) and defining setup steps as autouse fixtures.
Extending the login example to also check for a logout button and a profile link:
# tests/test_dashboard.py
from uuid import uuid4
from urllib.parse import urljoin
from selenium.webdriver import Firefox
import pytest
from app.pages import LoginPage, DashboardPage
from app.clients import AdminClient
from app.models import User
@pytest.fixture(scope="class")
def admin_api(base_url, admin_auth):
return AdminClient(base_url, **admin_auth)
@pytest.fixture(scope="class")
def test_user(admin_api):
user = User(name="Alex", username=f"user-{uuid4()}", password="S3cr3t!")
admin_api.add_user(user)
yield user
admin_api.remove_user(user)
@pytest.fixture(scope="class")
def web_browser():
browser = Firefox()
yield browser
browser.quit()
@pytest.fixture(scope="class")
def dashboard(web_browser, login_setup):
return DashboardPage(web_browser)
class TestDashboardFeatures:
@pytest.fixture(scope="class", autouse=True)
def login_setup(self, web_browser, base_url, test_user):
web_browser.get(urljoin(base_url, "/login"))
LoginPage(web_browser).authenticate(test_user)
def test_welcome_header(self, dashboard, test_user):
assert dashboard.title == f"Welcome, {test_user.name}!"
def test_logout_button_present(self, dashboard):
assert dashboard.logout_button.is_displayed()
def test_profile_link_target(self, dashboard, test_user):
expected_url = urljoin(base_url, f"/account?id={test_user.account_id}")
assert dashboard.profile_link.get_attribute("href") == expected_url
The login_setup autouse fixture ensures all dependent fixtures run before any test method. Each test requests only the fixtures it needs. Since no further state changes occur, tests can make multiple read-only assertions without side effects.
Other test classes can define different behaviors, like testing failed login attempts:
from copy import deepcopy
class TestLoginFailure:
@pytest.fixture(scope="class")
def bad_user(self, test_user):
invalid_user = deepcopy(test_user)
invalid_user.password = "wrong"
return invalid_user
def test_invalid_password_exception(self, login_page, bad_user):
with pytest.raises(AuthenticationError):
login_page.authenticate(bad_user)
Fixture Introspection of Test Context
A fixture can inspect the context of the requesting test using the request object.
# conftest.py
import smtplib
import pytest
@pytest.fixture(scope="module")
def mail_server(request):
# Read 'mailhost' from the test module's attributes, defaulting to 'smtp.gmail.com'
host = getattr(request.module, "mailhost", "smtp.gmail.com")
connection = smtplib.SMTP(host, 587, timeout=5)
yield connection
print(f"Closing {connection} ({host})")
connection.close()
A test module can then define its own server:
# test_mail.py
mailhost = "mail.example.org"
def test_server_greeting(mail_server):
# This will connect to mail.example.org
assert 0, mail_server.helo()
Passing Data to Fixtures Using Marks
Fixtures can access marks applied to test functions, allowing tests to pass data to fixtures.
import pytest
@pytest.fixture
def config_data(request):
marker = request.node.get_closest_marker("test_config")
if marker is None:
data = None
else:
data = marker.args[0]
# Process data...
return data
@pytest.mark.test_config(42)
def test_with_config(config_data):
assert config_data == 42
The "Fixture Factory" Pattern
Instead of returning data directly, a fixture can return a function that generates data. This is useful when a test needs multiple variations.
@pytest.fixture
def customer_factory():
def _create_customer(name):
return {"customer_name": name, "purchases": []}
return _create_customer
def test_multiple_customers(customer_factory):
cust_a = customer_factory("Alice")
cust_b = customer_factory("Bob")
cust_c = customer_factory("Charlie")
If the created data requires cleanup, the fixture can menage it:
@pytest.fixture
def managed_customer_factory():
registry = []
def _create(name):
cust = Customer(name=name, orders=[])
registry.append(cust)
return cust
yield _create
# Teardown: clean up all created customers
for customer in registry:
customer.delete()
def test_customer_lifecycle(managed_customer_factory):
c1 = managed_customer_factory("Diana")
c2 = managed_customer_factory("Eve")