@pytest.mark.parametrize runs the same test function with different inputs. Combined with dataclasses and JSON fixtures, it covers data-driven scenarios cleanly without external test management tools.
Basic parametrize
import pytest
from pages.login_page import LoginPage
@pytest.mark.parametrize("username,password,expected_error", [
("standard_user", "secret_sauce", None),
("locked_out_user","secret_sauce", "Sorry, this user has been locked out"),
("invalid_user", "secret_sauce", "Username and password do not match"),
("standard_user", "wrong_pass", "Username and password do not match"),
("", "", "Username is required"),
])
def test_login_scenarios(driver, username, password, expected_error):
page = LoginPage(driver)
page.login(username, password)
if expected_error is None:
from pages.home_page import HomePage
assert HomePage(driver).is_displayed()
else:
assert expected_error in page.get_error_message()pytest runs this function once per row, reporting each as a separate test:
test_login_scenarios[standard_user-secret_sauce-None] PASSED
test_login_scenarios[locked_out_user-secret_sauce-...] PASSED
test_login_scenarios[invalid_user-secret_sauce-...] PASSED
Named test IDs
Default IDs use parameter values, which can get long. Set explicit IDs with the ids parameter:
@pytest.mark.parametrize("username,password,expect_success", [
("standard_user", "secret_sauce", True),
("locked_out_user", "secret_sauce", False),
("", "", False),
], ids=["valid_login", "locked_user", "empty_credentials"])
def test_login(driver, username, password, expect_success):
...Test names become:
test_login[valid_login]
test_login[locked_user]
test_login[empty_credentials]
Using dataclasses as parameters
from dataclasses import dataclass
from typing import Optional
@dataclass
class LoginCase:
username: str
password: str
expect_success: bool = True
expected_error: Optional[str] = None
def __str__(self):
return f"{'valid' if self.expect_success else 'invalid'}[{self.username}]"
LOGIN_CASES = [
LoginCase("standard_user", "secret_sauce"),
LoginCase("locked_out_user", "secret_sauce", False, "Sorry, this user has been locked out"),
LoginCase("", "", False, "Username is required"),
]
@pytest.mark.parametrize("case", LOGIN_CASES, ids=str)
def test_login(driver, case):
page = LoginPage(driver)
page.login(case.username, case.password)
if case.expect_success:
assert HomePage(driver).is_displayed()
else:
assert case.expected_error in page.get_error_message()ids=str calls str() on each parameter — which calls __str__() on the dataclass — to generate test IDs.
Reading parameters from JSON
For large datasets, read from JSON files and convert to pytest parameters:
import json
from pathlib import Path
def load_test_cases(file_path: str):
"""Load parametrize data from a JSON file."""
data = json.loads(Path(file_path).read_text())
return [pytest.param(*row.values(), id=row.get("id", str(i)))
for i, row in enumerate(data)]
@pytest.mark.parametrize("username,password,expected_error",
load_test_cases("tests/testdata/login_cases.json"))
def test_login_from_json(driver, username, password, expected_error):
...login_cases.json:
[
{"id": "valid", "username": "standard_user", "password": "secret_sauce", "expected_error": null},
{"id": "locked", "username": "locked_out_user", "password": "secret_sauce", "expected_error": "locked out"}
]Multiple parametrize decorators (cross-product)
Stacking @pytest.mark.parametrize generates all combinations:
@pytest.mark.parametrize("platform", ["Android", "iOS"])
@pytest.mark.parametrize("user_type", ["standard_user", "performance_glitch_user"])
def test_login_user_types_on_platforms(platform_driver, user_type, platform):
# Runs 4 times: Android/standard, Android/performance, iOS/standard, iOS/performance
...Skipping specific parameter combinations
@pytest.mark.parametrize("feature,platform", [
("detox_sync", "iOS"),
("detox_sync", "Android"), # Detox doesn't support Android
("espresso", "Android"),
("espresso", "iOS"), # Espresso is Android-only
], ids=lambda x: x)
def test_framework_availability(feature, platform):
if feature == "detox_sync" and platform == "Android":
pytest.skip("Detox not supported on Android")
if feature == "espresso" and platform == "iOS":
pytest.skip("Espresso is Android-only")
...Or use pytest.param with marks:
@pytest.mark.parametrize("feature,platform", [
pytest.param("detox_sync", "Android",
marks=pytest.mark.skip(reason="Detox not supported on Android")),
("detox_sync", "iOS"),
("espresso", "Android"),
pytest.param("espresso", "iOS",
marks=pytest.mark.skip(reason="Espresso is Android-only")),
])
def test_framework_availability(feature, platform):
...Fixture parametrisation vs test parametrisation
@pytest.mark.parametrize: best for data variations within a single test (different credentials, different products)@pytest.fixture(params=[...]): best for infrastructure variations (different devices, different environments) that should affect all tests equally
Use fixture parametrisation when you want all tests in a module to run against multiple platforms automatically, without marking each test individually.