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 runFor mobile, the trade-offs:
| Scope | Sessions | Speed | Risk |
|---|---|---|---|
| function | 1 per test | Slowest | No state leakage |
| class | 1 per class | Fast | Class tests must be order-independent |
| module | 1 per file | Faster | File tests must leave app in stable state |
| session | 1 total | Fastest | One 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 deadThis 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() == 0pytest 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() > 0pytest 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 againterminate_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.