Fading Coder

One Final Commit for the Last Sprint

Home > Tech > Content

Designing Robust and Safe Fixtures in Pytest

Tech Oct 26 2

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:

  1. Create a user via an admin API.
  2. Launch a browser with Selenium.
  3. Navigate to the login page.
  4. Log in as the created user.
  5. 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")

Related Articles

Understanding Strong and Weak References in Java

Strong References Strong reference are the most prevalent type of object referencing in Java. When an object has a strong reference pointing to it, the garbage collector will not reclaim its memory. F...

Comprehensive Guide to SSTI Explained with Payload Bypass Techniques

Introduction Server-Side Template Injection (SSTI) is a vulnerability in web applications where user input is improper handled within the template engine and executed on the server. This exploit can r...

Implement Image Upload Functionality for Django Integrated TinyMCE Editor

Django’s Admin panel is highly user-friendly, and pairing it with TinyMCE, an effective rich text editor, simplifies content management significantly. Combining the two is particular useful for bloggi...

Leave a Comment

Anonymous

◎Feel free to join the discussion and share your thoughts.