Parameterised Tests with @pytest.mark.parametrize

6 min read

@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.

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