pytest Fixtures and conftest.py for Session Management

7 min read

pytest fixtures are the dependency injection system that powers clean, maintainable mobile test suites. Beyond the basic driver fixture from Chapter 1, this lesson covers fixture scoping strategies, fixture composition, and teardown patterns specific to Appium suites.

Fixture scope recap

@pytest.fixture(scope="function")   # default — new driver per test
@pytest.fixture(scope="class")      # one driver for all methods in a class
@pytest.fixture(scope="module")     # one driver for all tests in a file
@pytest.fixture(scope="session")    # one driver for the entire test run

For mobile, the trade-offs:

ScopeSessionsSpeedRisk
function1 per testSlowestNo state leakage
class1 per classFastClass tests must be order-independent
module1 per fileFasterFile tests must leave app in stable state
session1 totalFastestOne bad test can break all subsequent

Start with function scope. Move to module scope once your tests are stable and you've verified they're independent.

The driver fixture with screenshot on failure

# conftest.py
import pytest
import os
from appium import webdriver
from appium.options import UiAutomator2Options
 
 
@pytest.fixture(scope="function")
def driver(request):
    options = UiAutomator2Options()
    options.device_name = os.getenv("ANDROID_DEVICE", "emulator-5554")
    options.app = os.path.abspath("apps/app-debug.apk")
    options.auto_grant_permissions = True
    options.no_reset = True
 
    d = webdriver.Remote("http://127.0.0.1:4723", options=options)
    yield d
 
    # Teardown — runs even if test failed
    if hasattr(request.node, "rep_call") and request.node.rep_call.failed:
        os.makedirs("screenshots", exist_ok=True)
        d.get_screenshot_as_file(f"screenshots/{request.node.name}.png")
 
    try:
        d.quit()
    except Exception:
        pass  # Session may already be dead

This requires the pytest_runtest_makereport hook (see below).

pytest_runtest_makereport hook

This hook stores the test result on the test item so fixtures can check it:

# conftest.py
@pytest.hookimpl(tryfirst=True, hookwrapper=True)
def pytest_runtest_makereport(item, call):
    outcome = yield
    rep = outcome.get_result()
    setattr(item, f"rep_{rep.when}", rep)

After this, request.node.rep_call.failed is available in fixture teardown.

Composing fixtures

Fixtures can depend on other fixtures:

@pytest.fixture
def driver():
    # ... create driver ...
    yield d
    d.quit()
 
@pytest.fixture
def login_page(driver):
    from pages.login_page import LoginPage
    return LoginPage(driver)
 
@pytest.fixture
def home_page(driver):
    from pages.login_page import LoginPage
    from pages.home_page import HomePage
    login = LoginPage(driver)
    home = login.login("standard_user", "secret_sauce")
    return home
 
@pytest.fixture
def cart_with_item(home_page):
    return (
        home_page
        .tap_product("Sauce Labs Backpack")
        .add_to_cart()
        .go_to_cart()
    )

Tests declare only the fixture they need:

def test_product_detail(home_page):
    # Starts at home, driver already created and logged in
    detail = home_page.tap_product("Sauce Labs Backpack")
    assert detail.get_product_name() == "Sauce Labs Backpack"
 
 
def test_remove_from_cart(cart_with_item):
    # Starts in cart with one item already added
    cart_with_item.remove_item("Sauce Labs Backpack")
    assert cart_with_item.get_item_count() == 0

pytest handles fixture dependencies automatically — cart_with_item depends on home_page, which depends on driver. One driver is created per test.

Platform-parametrised fixture

Run the same test on Android and iOS using fixture parametrisation:

@pytest.fixture(params=["Android", "iOS"])
def cross_platform_driver(request):
    platform = request.param
    if platform == "Android":
        options = UiAutomator2Options()
        options.device_name = "emulator-5554"
        options.app = "apps/app.apk"
        options.auto_grant_permissions = True
    else:
        options = XCUITestOptions()
        options.device_name = "iPhone 15"
        options.app = "apps/MyApp.app"
 
    d = webdriver.Remote("http://127.0.0.1:4723", options=options)
    yield d
    d.quit()
 
 
def test_login_works_on_both_platforms(cross_platform_driver):
    from pages.login_page import LoginPage
    home = LoginPage(cross_platform_driver).login("standard_user", "secret_sauce")
    assert home.get_product_count() > 0

pytest runs this test twice — once with Android, once with iOS. The test IDs become test_login_works_on_both_platforms[Android] and test_login_works_on_both_platforms[iOS].

Fixture with app reset

When tests modify app state (add to cart, change settings), reset the app at fixture level:

@pytest.fixture
def fresh_app(driver):
    """Yields driver with app reset to initial state."""
    driver.terminate_app("com.example.myapp")
    driver.activate_app("com.example.myapp")
    yield driver
    # No special cleanup — next test gets fresh_app again

terminate_app + activate_app is faster than quitting and creating a new session, while still clearing in-memory state.

Autouse fixtures for global setup

An autouse=True fixture runs for every test in scope without being explicitly requested:

@pytest.fixture(autouse=True, scope="session")
def start_appium_server():
    """Start Appium programmatically for the entire session."""
    import subprocess
    proc = subprocess.Popen(
        ["appium", "--base-path", "/"],
        stdout=subprocess.DEVNULL,
        stderr=subprocess.DEVNULL
    )
    import time
    time.sleep(3)  # Wait for server to start
    yield
    proc.terminate()

Use autouse sparingly — it makes the fixture dependency implicit. Reserve it for infrastructure concerns (server startup, log configuration) that every test needs.

// tip to track lessons you complete and pick up where you left off across devices.