pytest parametrize: Run One Test with Many Inputs (with Playwright Examples)

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.

Advertisement

๐Ÿ“– 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:

  1. a string with the argument names, separated by commas;
  2. 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 use pytest.mark.skip this 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=None means “login should succeed”; otherwise the test checks the error message.
  • Parametrize arguments and fixtures mix freely: page comes 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
  • params lists the values; ids names them in reports.
  • request.param holds 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= or pytest.param(..., id=..., marks=...) for readable names and per-case markers.
  • Stacked decorators produce every combination; pytest-playwright adds the browser to every ID.
  • Fixture params with request.param vary 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

Leave a Comment