Good test suites check the same behaviour with many inputs: several users, several prices, several pages. Copying a test for each input creates duplicate code that is painful to maintain. pytest’s parametrize marker runs one test function once per set of values, each reported as its own test. In this lesson you will parametrize plain functions and Playwright login tests, give every case a readable name, mark individual cases, combine parameters, and parametrize fixtures.
๐ What You’ll Learn: @pytest.mark.parametrize with one and several arguments, how pytest builds test IDs, custom ids, pytest.param with id and marks, stacking decorators for combinations, a data-driven Playwright login test, parametrized fixtures with params and request.param, and how parametrize combines with pytest-playwright’s browser parameter.
๐ One Test, Many Inputs
@pytest.mark.parametrize(names, values) takes:
- a string with the argument names, separated by commas;
- a list of values: single values for one argument, or tuples for several.
pytest then generates one test per item. Create tests/test_basic_params.py:
๐ tests/test_basic_params.py
import pytest
def to_price(text: str) -> float:
return float(text.replace("$", "").replace(",", ""))
@pytest.mark.parametrize("text", ["$29.99", "$9.99", "$7.99"])
def test_price_is_positive(text):
assert to_price(text) > 0
@pytest.mark.parametrize(
"text, expected",
[
("$29.99", 29.99),
("$1,299.00", 1299.0),
("15", 15.0),
],
)
def test_price_parsing(text, expected):
assert to_price(text) == expected
- The first test has one argument, so the list holds plain strings.
- The second has two arguments (
"text, expected"), so each item is a tuple in the same order. - The test function’s parameters must match the names exactly.
โจ๏ธ Terminal
pytest -v tests/test_basic_params.py
๐ฅ๏ธ Output
tests/test_basic_params.py::test_price_is_positive[$29.99] PASSED [ 16%]
tests/test_basic_params.py::test_price_is_positive[$9.99] PASSED [ 33%]
tests/test_basic_params.py::test_price_is_positive[$7.99] PASSED [ 50%]
tests/test_basic_params.py::test_price_parsing[$29.99-29.99] PASSED [ 66%]
tests/test_basic_params.py::test_price_parsing[$1,299.00-1299.0] PASSED [ 83%]
tests/test_basic_params.py::test_price_parsing[15-15.0] PASSED [100%]
Each case is a separate test with an ID in square brackets. pytest builds the ID from the values, joined with -. If one case fails, the others still run and you see exactly which input broke.
๐ท๏ธ Readable IDs and Per-Case Marks
Auto-generated IDs work for simple values but become unreadable for dictionaries or long strings. Use ids= for a list of names, or pytest.param(..., id=...) to name one case and attach markers to it (tests/test_param_marks.py):
๐ tests/test_param_marks.py
import pytest
@pytest.mark.parametrize(
"a, b, expected",
[(2, 3, 5), (10, -4, 6), (0, 0, 0)],
ids=["small", "negative", "zeros"],
)
def test_add(a, b, expected):
assert a + b == expected
@pytest.mark.parametrize(
"username, can_log_in",
[
pytest.param("standard_user", True, id="standard"),
pytest.param("locked_out_user", False, id="locked"),
pytest.param("glitch_user", True, id="glitch",
marks=pytest.mark.xfail(reason="no such user on the demo site")),
],
)
def test_known_users(username, can_log_in):
known = {"standard_user": True, "locked_out_user": False}
assert known[username] == can_log_in
ids=[...]gives the cases names in order:test_add[small],test_add[negative],test_add[zeros].pytest.param(values..., id="...")names a single case right next to its data, which is easier to keep in sync than a separate list.marks=pytest.mark.xfail(...)applies a marker to that case only. You can also usepytest.mark.skipthis way.
โจ๏ธ Terminal
pytest -q -rx tests/test_param_marks.py
๐ฅ๏ธ Output
.....x [100%]
=========================== short test summary info ===========================
XFAIL tests/test_param_marks.py::test_known_users[glitch] - no such user on the demo site
5 passed, 1 xfailed in 0.02s
-rx adds the summary line for xfailed tests, showing the case’s ID and reason.
โ๏ธ Stacking: Every Combination
Stacking two parametrize decorators runs every combination of their values (a Cartesian product). Use --collect-only to see the generated tests without running them (tests/test_stacked.py):
๐ tests/test_stacked.py
import pytest
@pytest.mark.parametrize("width", [375, 768, 1280])
@pytest.mark.parametrize("theme", ["light", "dark"])
def test_layout(width, theme):
assert width > 0 and theme in ("light", "dark")
โจ๏ธ Terminal
pytest --collect-only -q tests/test_stacked.py
๐ฅ๏ธ Output
tests/test_stacked.py::test_layout[light-375]
tests/test_stacked.py::test_layout[light-768]
tests/test_stacked.py::test_layout[light-1280]
tests/test_stacked.py::test_layout[dark-375]
tests/test_stacked.py::test_layout[dark-768]
tests/test_stacked.py::test_layout[dark-1280]
6 tests collected in 0.01s
2 themes ร 3 widths = 6 tests. Combinations grow fast, so stack only when every combination is worth testing.
๐ญ Data-Driven Playwright Login Tests
Now the real use case: one login test, many users, each with its expected result (tests/test_login_data.py):
๐ tests/test_login_data.py
import pytest
from playwright.sync_api import Page, expect
LOGIN_CASES = [
pytest.param("standard_user", "secret_sauce", None, id="valid-user"),
pytest.param("locked_out_user", "secret_sauce", "Sorry, this user has been locked out.", id="locked-user"),
pytest.param("standard_user", "wrong", "Username and password do not match", id="wrong-password"),
pytest.param("", "secret_sauce", "Username is required", id="empty-username"),
]
@pytest.mark.parametrize("username, password, error", LOGIN_CASES)
def test_login(page: Page, username: str, password: str, error: str | None):
page.goto("https://www.saucedemo.com/")
page.get_by_placeholder("Username").fill(username)
page.get_by_placeholder("Password").fill(password)
page.get_by_role("button", name="Login").click()
if error is None:
expect(page.locator(".title")).to_have_text("Products")
else:
expect(page.locator("[data-test='error']")).to_contain_text(error)
- The test data lives in one list at the top of the file, separate from the test logic. In Module 7 you will load it from JSON and CSV files instead.
error=Nonemeans “login should succeed”; otherwise the test checks the error message.- Parametrize arguments and fixtures mix freely:
pagecomes from pytest-playwright, the other three from the parameters.
โจ๏ธ Terminal
pytest -v tests/test_login_data.py
๐ฅ๏ธ Output
tests/test_login_data.py::test_login[chromium-valid-user] PASSED [ 25%]
tests/test_login_data.py::test_login[chromium-locked-user] PASSED [ 50%]
tests/test_login_data.py::test_login[chromium-wrong-password] PASSED [ 75%]
tests/test_login_data.py::test_login[chromium-empty-username] PASSED [100%]
Notice the ID: chromium-valid-user. pytest-playwright parametrizes every test that uses page by browser, and pytest combines that with your parameters. Run in two browsers and the cases double:
โจ๏ธ Terminal
pytest -q --collect-only --browser chromium --browser firefox tests/test_login_data.py
๐ฅ๏ธ Output
tests/test_login_data.py::test_login[chromium-valid-user]
tests/test_login_data.py::test_login[chromium-locked-user]
tests/test_login_data.py::test_login[chromium-wrong-password]
tests/test_login_data.py::test_login[chromium-empty-username]
tests/test_login_data.py::test_login[firefox-valid-user]
tests/test_login_data.py::test_login[firefox-locked-user]
tests/test_login_data.py::test_login[firefox-wrong-password]
tests/test_login_data.py::test_login[firefox-empty-username]
8 tests collected in 0.02s
๐ก Tip: Select single cases by ID with -k: pytest -k "locked-user" runs only that case, in every browser.
๐งช Parametrized Fixtures
Sometimes it is the fixture that should vary, for example a viewport size used by many tests. Give the fixture params=[...]; every test that uses it runs once per value, and the fixture reads the current value from request.param (tests/test_fixture_params.py):
๐ tests/test_fixture_params.py
import pytest
from playwright.sync_api import Page
@pytest.fixture(params=[(375, 812), (1280, 720)], ids=["mobile", "desktop"])
def sized_page(page: Page, request) -> Page:
width, height = request.param
page.set_viewport_size({"width": width, "height": height})
return page
def test_practice_page_fits(sized_page: Page):
sized_page.goto("https://sqatools.in/automation-practice-page/")
width = sized_page.viewport_size["width"]
assert sized_page.evaluate("document.documentElement.clientWidth") <= width
paramslists the values;idsnames them in reports.request.paramholds the value for the current run.page.set_viewport_size()resizes the page’s viewport, and the test checks that the page is not wider than the screen.
๐ฅ๏ธ Output
tests/test_fixture_params.py::test_practice_page_fits[mobile-chromium] PASSED [ 50%]
tests/test_fixture_params.py::test_practice_page_fits[desktop-chromium] PASSED [100%]
Here the fixture’s ID comes first and the browser second (mobile-chromium), while in the login example the browser came first (chromium-valid-user). The order follows how pytest resolves the parameters, so do not rely on it when filtering; -k mobile matches either way.
Choose parametrize for test data that belongs to one test, and fixture params when a whole group of tests should run in several configurations.
โ Frequently Asked Questions
๐ค Why do my IDs show names like user0 and user1?
pytest builds IDs only from simple values (numbers, strings, booleans, None); for other objects such as dictionaries it uses the argument name plus an index (user0, user1).
๐ค Can I parametrize a test class?
Yes. Put @pytest.mark.parametrize on the class and every test method receives the parameters.
๐ค How do I load parameters from a JSON or CSV file?
Read the file into a list at module level (or in a helper function) and pass that list to parametrize. Module 7 shows this step by step.
๐ค Is it a problem if one parametrized case fails?
No. Each case is an independent test, so the others still run, and the report shows exactly which input failed.
๐ Summary
@pytest.mark.parametrize("a, b", [(1, 2), ...])runs a test once per value set, each with its own ID.- Use
ids=orpytest.param(..., id=..., marks=...)for readable names and per-case markers. - Stacked decorators produce every combination; pytest-playwright adds the browser to every ID.
- Fixture
paramswithrequest.paramvary a fixture for all tests that use it.
Next, you will use markers to skip, expect failures, and tag tests so you can run exactly the group you need.
๐ฌ Watch the Playwright tutorial series on YouTube!
Subscribe to SqaToolsByDY • Practice on the SQA Tools Automation Practice Page