pytest is the test runner for this course. It's simpler than unittest for mobile tests: fixtures replace setUp/tearDown classes, parametrize handles data-driven tests, and the plugin ecosystem covers reporting, parallelism, and retry. This lesson covers the pytest features you'll use most.
Test functions and test classes
pytest discovers tests by file name (test_*.py), function name (test_*), and optionally class name (Test*):
# test_login.py
def test_login_with_valid_credentials(driver):
# driver is a fixture injected by pytest
from pages.login_page import LoginPage
page = LoginPage(driver)
home = page.login("standard_user", "secret_sauce")
assert home.is_displayed()
class TestLoginValidation:
def test_empty_username_shows_error(self, driver):
from pages.login_page import LoginPage
page = LoginPage(driver)
page.login("", "secret_sauce")
assert "Username is required" in page.get_error_message()Both forms work. Plain functions are simpler; classes let you group related tests and share class-level state.
Fixtures
Fixtures are pytest's dependency injection system. A fixture is a function decorated with @pytest.fixture:
# conftest.py
import pytest
from appium import webdriver
from appium.options import UiAutomator2Options
@pytest.fixture
def driver():
options = UiAutomator2Options()
options.device_name = "emulator-5554"
options.app = "apps/app-debug.apk"
options.auto_grant_permissions = True
d = webdriver.Remote("http://127.0.0.1:4723", options=options)
yield d # test runs here
d.quit() # teardown: always runs, even if test failsAny test function that declares driver as a parameter receives a fresh driver for each test. The yield separates setup (above) from teardown (below). Code below yield runs after the test, regardless of pass/fail.
Fixture scope
@pytest.fixture(scope="function") # default — new fixture for every test
@pytest.fixture(scope="class") # shared within a test class
@pytest.fixture(scope="module") # shared within a test file
@pytest.fixture(scope="session") # shared for the entire test runFor mobile, function scope means a new Appium session per test — slow but clean. module scope means one session per test file — faster, but tests must not leave the app in a broken state. Choose based on how independent your tests need to be.
Assertions
pytest uses Python's built-in assert with introspection — you get helpful failure messages without needing assertion libraries:
assert home.is_displayed(), "Home page should be visible after login"
# Comparison
product_count = home.get_product_count()
assert product_count >= 6, f"Expected at least 6 products, got {product_count}"
# In list
assert "Backpack" in home.get_product_names()
# Exception
import pytest
with pytest.raises(ValueError, match="Invalid input"):
parse_price("not-a-price")Running tests
# Run all tests
pytest
# Run a specific file
pytest tests/test_login.py
# Run a specific test
pytest tests/test_login.py::test_login_with_valid_credentials
# Run a specific class
pytest tests/test_login.py::TestLoginValidation
# Run tests matching a keyword
pytest -k "login"
# Run with verbose output
pytest -v
# Show print output
pytest -s
# Run and stop on first failure
pytest -xMarkers
Mark tests to control which run:
import pytest
@pytest.mark.smoke
def test_standard_user_login(driver):
...
@pytest.mark.regression
def test_locked_out_user(driver):
...Register markers in pytest.ini to avoid warnings:
[pytest]
markers =
smoke: core functionality tests
regression: full test suite
slow: tests that take more than 30 secondsRun by marker:
pytest -m smoke
pytest -m "regression and not slow"conftest.py — shared fixtures and hooks
conftest.py in the project root is automatically loaded by pytest. Fixtures defined there are available to all test files. Multiple conftest.py files can exist — pytest loads them hierarchically (root → subdirectory):
conftest.py ← available to all tests
tests/
conftest.py ← available to tests in tests/ only
test_login.py
Use conftest.py for:
- Shared fixtures (driver, pages)
- pytest hooks (e.g., screenshot on failure)
- Common test data
The yield fixture pattern for screenshots
Capture a screenshot when a test fails using a fixture:
@pytest.fixture
def driver(request):
options = UiAutomator2Options()
options.device_name = "emulator-5554"
options.app = "apps/app-debug.apk"
d = webdriver.Remote("http://127.0.0.1:4723", options=options)
yield d
# Teardown: check if the test failed
if request.node.rep_call.failed:
screenshot_path = f"screenshots/{request.node.name}.png"
d.get_screenshot_as_file(screenshot_path)
d.quit()The request.node.rep_call.failed check requires the pytest_runtest_makereport hook (covered in Chapter 5's reporting lesson).