Most form, search, and filter tests are the same shape repeated with different inputs: type a value, click a button, assert the result. Copy-pasting the test body for each input pair is the slow road to a stale, hard-to-maintain suite. pytest.mark.parametrize is the fast road — one test function, multiple parameter sets, multiple test runs, each with its own pass/fail line in the report. This is genuinely one of the highest-leverage features Python testing offers, and the Playwright TypeScript world doesn't have a direct equivalent.
The basic shape
import pytest
from playwright.sync_api import Page, expect
@pytest.mark.parametrize("email,password,expected_url", [
("admin@test.com", "AdminPass", "/admin"),
("user@test.com", "UserPass", "/dashboard"),
("viewer@test.com", "ViewerPass", "/readonly"),
])
def test_login_redirects_by_role(page: Page, email: str, password: str, expected_url: str):
page.goto("/login")
page.get_by_label("Email").fill(email)
page.get_by_label("Password").fill(password)
page.get_by_role("button", name="Login").click()
expect(page).to_have_url(expected_url)Three lines of decorator turn one test function into three independent test runs. pytest generates them automatically — they appear in the report as test_login_redirects_by_role[admin@test.com-AdminPass-/admin], etc. Each runs with its own fresh page, its own pass/fail report, and its own retry behaviour if you re-run a single failure.
The signature: parametrize("comma,separated,arg,names", [list_of_tuples]). The arg names appear as parameters on the test function — pytest matches them positionally to the tuple values.
Readable test IDs with pytest.param
Default test IDs are auto-generated from the values, which can be ugly for long emails or complex objects. Use pytest.param(..., id="...") for readable names:
@pytest.mark.parametrize("email,password,expected_url", [
pytest.param("admin@test.com", "AdminPass", "/admin", id="admin-login"),
pytest.param("user@test.com", "UserPass", "/dashboard", id="standard-login"),
pytest.param("wrong@test.com", "WrongPass", "/login", id="invalid-login-stays-on-login"),
])
def test_login_redirects(page: Page, email, password, expected_url):
...The report now shows test_login_redirects[admin-login], test_login_redirects[standard-login], test_login_redirects[invalid-login-stays-on-login] — instantly scannable. When a test fails on CI, the ID tells you which case failed without you having to count tuple positions.
Negative testing — one function, many invalid inputs
The pattern parametrize is best at: one happy-path test verifies the form works at all, one parametrized test sweeps every invalid input you care about.
@pytest.mark.parametrize("invalid_email", [
pytest.param("", id="empty"),
pytest.param("not-an-email", id="no-at-sign"),
pytest.param("a" * 256 + "@test.com", id="too-long"),
pytest.param("<script>alert(1)</script>", id="xss-attempt"),
pytest.param(" spaces@test.com", id="leading-space"),
pytest.param("user@", id="missing-domain"),
pytest.param("@test.com", id="missing-local-part"),
])
def test_rejects_invalid_email(page: Page, invalid_email: str):
page.goto("/register")
page.get_by_label("Email").fill(invalid_email)
page.get_by_role("button", name="Register").click()
expect(page.get_by_text("Invalid email")).to_be_visible()Seven test cases, one test body. Adding an eighth invalid pattern is one line — no copy-paste, no risk of the eighth test diverging from the others over time. When a developer accidentally loosens email validation, you'll know exactly which invalid pattern slipped through, not just "validation broke."
Cross-viewport responsive testing
parametrize works on any kind of input — including viewport sizes:
@pytest.mark.parametrize("viewport", [
pytest.param({"width": 1920, "height": 1080}, id="desktop"),
pytest.param({"width": 768, "height": 1024}, id="tablet"),
pytest.param({"width": 375, "height": 667}, id="mobile"),
])
def test_responsive_navigation(page: Page, viewport):
page.set_viewport_size(viewport)
page.goto("/")
if viewport["width"] < 768:
expect(page.get_by_test_id("mobile-menu-toggle")).to_be_visible()
expect(page.get_by_test_id("desktop-nav")).to_be_hidden()
else:
expect(page.get_by_test_id("desktop-nav")).to_be_visible()
expect(page.get_by_test_id("mobile-menu-toggle")).to_be_hidden()Three viewport sizes, three test runs, one body. For true multi-browser coverage you usually use the --browser CLI flag (chapter 1) — that's faster and runs in parallel. Use parametrized viewports when the test logic itself depends on the size (the if viewport["width"] < 768: branch).
Stacking decorators — the cross product
Multiple parametrize decorators stack — pytest generates the cross product:
@pytest.mark.parametrize("role", ["admin", "user", "viewer"])
@pytest.mark.parametrize("locale", ["en-GB", "en-US", "fr-FR"])
def test_dashboard_per_role_per_locale(page: Page, role: str, locale: str):
# 3 roles × 3 locales = 9 test runs
page.goto("/login")
# ...Nine test runs from one function. Use carefully — cross-product growth is exponential. Three decorators of three values each is 27 tests; four is 81. When the matrix gets dense, that's a sign you should split into focused tests instead.
How parametrize multiplies tests
One test function × N parameter sets = N test runs
The line between useful coverage and combinatorial explosion is around 10-15 cases per test function. Past that, the test starts running long enough that maintainers won't re-run it during local development, which defeats the purpose.
Combining with dataclasses — typed parameter sets
Long parameter tuples get unreadable. Wrap them in a dataclass (covered in the Python for QA course):
from dataclasses import dataclass
import pytest
from playwright.sync_api import Page, expect
@dataclass
class LoginCase:
email: str
password: str
expected_url: str
CASES = [
pytest.param(LoginCase("admin@test.com", "AdminPass", "/admin"), id="admin"),
pytest.param(LoginCase("user@test.com", "UserPass", "/dashboard"), id="standard"),
pytest.param(LoginCase("viewer@test.com", "ViewerPass", "/readonly"), id="viewer"),
]
@pytest.mark.parametrize("case", CASES)
def test_login_redirects(page: Page, case: LoginCase):
page.goto("/login")
page.get_by_label("Email").fill(case.email)
page.get_by_label("Password").fill(case.password)
page.get_by_role("button", name="Login").click()
expect(page).to_have_url(case.expected_url)Now the test reads case.email and case.expected_url instead of decoding tuple positions. Type hints work end-to-end. The IDE autocompletes case. to all dataclass fields. For tests that check more than a handful of values per case, the dataclass version is dramatically easier to maintain.
Coming from Playwright TypeScript?
The TypeScript Playwright runner doesn't have built-in parametrization. The closest equivalents are:
// Manual loop in TypeScript
for (const { email, password, url } of cases) {
test(`login as ${email}`, async ({ page }) => {
await page.goto("/login");
await page.getByLabel("Email").fill(email);
// ...
});
}The for loop generates separate test() calls at file-load time. It works, but it's less ergonomic — no clean test IDs, no cross-product stacking, no pytest.param integration with markers. Python's parametrize is genuinely a better data-driven testing API than the TypeScript Playwright equivalent. If you've found yourself wanting forEach patterns in TS, this is the lesson where the Python advantage shows up.
Indirect parametrize — feeding values into fixtures
Advanced pattern: have parametrize choose which fixture variant to use, via indirect=True:
@pytest.fixture
def role_user(request):
role = request.param
return {
"admin": {"email": "admin@test.com", "password": "AdminPass"},
"user": {"email": "user@test.com", "password": "UserPass"},
}[role]
@pytest.mark.parametrize("role_user", ["admin", "user"], indirect=True)
def test_dashboard_renders_for_role(page: Page, role_user):
page.goto("/login")
page.get_by_label("Email").fill(role_user["email"])
# ...indirect=True tells pytest to pass the parameter value to the fixture, not to the test. The fixture receives request.param and decides what to build. Useful when the parameter selects between expensive fixture configurations and you don't want to recompute them per test.
⚠️ Common mistakes
- Forgetting to spell parameter names exactly the same in the decorator and the function signature.
@pytest.mark.parametrize("email,password,url", [...])followed bydef test_(self, page, email, password, expected_url):will fail becauseexpected_url != url. pytest reports a confusing "fixture not found" error. Match the names letter for letter. - Putting tuples and
pytest.paramin the same list inconsistently. Either every entry is a tuple or every entry is apytest.param. Mixing the two leaves you with mismatched test IDs that are hard to debug. Once you start usingpytest.paramfor IDs, convert all the entries. - Using parametrize to test five totally different scenarios. If the test bodies branch on the parameter (
if role == "admin": assert X else: assert Y), you've collapsed two distinct tests into a single function with embedded if/else logic. Split them — readable test names beat clever generality.
🎯 Practice task
Replace three repetitive tests with one parametrized test. 25-30 minutes.
-
Create
tests/test_login_parametrize.py:import pytest from playwright.sync_api import Page, expect @pytest.mark.parametrize("username,password,expected_outcome", [ pytest.param("standard_user", "secret_sauce", "success", id="standard-user"), pytest.param("locked_out_user", "secret_sauce", "locked", id="locked-out"), pytest.param("problem_user", "secret_sauce", "success", id="problem-user"), pytest.param("performance_glitch_user", "secret_sauce", "success", id="performance-glitch"), pytest.param("standard_user", "wrong_password", "wrong-creds", id="bad-password"), pytest.param("", "secret_sauce", "missing-username", id="empty-username"), pytest.param("standard_user", "", "missing-password", id="empty-password"), ]) def test_login_outcomes(page: Page, username, password, expected_outcome): page.goto("/") page.get_by_placeholder("Username").fill(username) page.get_by_placeholder("Password").fill(password) page.get_by_role("button", name="Login").click() if expected_outcome == "success": expect(page).to_have_url("/inventory.html") elif expected_outcome == "locked": expect(page.get_by_test_id("error")).to_contain_text("locked out") elif expected_outcome == "wrong-creds": expect(page.get_by_test_id("error")).to_contain_text("Username and password do not match") elif expected_outcome == "missing-username": expect(page.get_by_test_id("error")).to_contain_text("Username is required") elif expected_outcome == "missing-password": expect(page.get_by_test_id("error")).to_contain_text("Password is required") -
Run with
pytest tests/test_login_parametrize.py -v. Seven test runs, seven readable IDs in the report. -
Add an eighth case. What happens with username
"<script>alert(1)</script>"? Add apytest.paramfor it and decide the expected outcome by running the test once and observing. -
Refactor with dataclass. Replace the tuple list with a
LoginCasedataclass and a list ofpytest.param(LoginCase(...), id="..."). Confirm the test still runs and reads more cleanly. -
Stack a decorator. Add
@pytest.mark.parametrize("browser", ["chromium", "firefox"])(or use the--browserCLI flag instead — for simplicity, just add it as an extra parameter). Note that with two browsers and seven cases, you have 14 test runs. Watch the cross-product in the report. -
Stretch: look at the hint that the test body now has 5-way
if/elif/elsebranches. Split the test in two: one parametrized test that runs the success cases and asserts URL, another that runs the failure cases and asserts the error message. Same coverage, smaller test bodies, easier to read.
You've got the data-driven testing pattern. The next lesson is the last in this chapter — markers, tagging, folder structure, and the patterns that keep an 80- or 800-test suite organised and runnable in slices.