6 Selenium Python Libraries Every QA Engineer Should Know

test-automation

@pytest.mark.parametrize lets you run a single test function across multiple input patterns โ€” a critical tool in real-world projects. Switching authentication patterns in API tests, validating role-based access in UI tests, and more all become cleaner with parametrize. Once you understand pytest.param, indirect, and when to use fixture params instead, your data-driven testing becomes significantly more powerful.

๐Ÿ“Œ Who This Article Is For

โœ… Developers who know the basics of @pytest.mark.parametrize but want to use it more effectively
โœ… Those who want to learn how to use id and marks with pytest.param
โœ… Anyone looking to connect parametrize with fixtures using indirect
โœ… Those who want a clear decision guide between parametrize and fixture params

๐Ÿ“– What You’ll Learn in This Article

โœ” How to combine multiple arguments and stacked decorators
โœ” Adding id and marks (including skip and xfail) with pytest.param
โœ” Using indirect=True to pass parameters into fixtures
โœ” When to use parametrize vs fixture params (comparison table with real-world frequency)
โœ” How to deal with combinatorial explosion (pairwise, boundary values, risk-based)
โœ” Combining parametrize with pytest-xdist for parallel test execution

I have worked as a QA engineer for over 15 years, focusing on API and functional test automation.
From cycling through multiple authentication token patterns in API test suites to validating role-based access across dozens of endpoints, parametrized tests have been central to my work.
This article shares real failure patterns and practical improvements from that experience.

โœ… Key Takeaways

  • Use pytest.param(id=..., marks=...) to name and tag each pattern for better maintainability
  • Use indirect=True to pass parameters into fixtures so setup logic also switches per pattern
  • Use parametrize for input variations on a single test; use fixture params when sharing patterns across multiple tests

@pytest.mark.parametrize โ€” Quick Recap

Before diving into advanced patterns, let’s confirm the minimal setup.

import pytest

# Specify argument names and a list of value sets
@pytest.mark.parametrize("value, expected", [
    (1,  True),
    (0,  False),
    (-1, False),
])
def test_is_positive(value: int, expected: bool) -> None:
    assert (value > 0) == expected

This runs three test cases automatically. Output names are auto-generated: test_is_positive[1-True], and so on.

5 Advanced Patterns

1. Use pytest.param to Add id and marks

As the number of patterns grows, it becomes hard to tell which case failed.
Adding id (test name) and marks (attributes) with pytest.param makes test output immediately readable.

import pytest
import requests

@pytest.mark.parametrize("payload, expected_status", [
    pytest.param(
        {"username": "admin", "password": "correct"},
        200,
        id="valid_credentials",
    ),
    pytest.param(
        {"username": "admin", "password": "wrong"},
        401,
        id="wrong_password",
    ),
    # Run only smoke tests in CI with: pytest -m smoke
    pytest.param(
        {"username": "", "password": ""},
        400,
        id="empty_credentials",
        marks=pytest.mark.smoke,
    ),
    # Temporarily skipped due to known bug
    pytest.param(
        {"username": "admin", "password": None},
        400,
        id="null_password",
        marks=pytest.mark.skip(reason="Pending fix for Issue #123"),
    ),
    # Known failing case โ€” expected to fail until the bug is fixed
    pytest.param(
        {"username": " ", "password": "pass"},
        400,
        id="whitespace_username",
        marks=pytest.mark.xfail(reason="Whitespace-only username currently passes validation"),
    ),
])
def test_login(payload: dict, expected_status: int) -> None:
    response = requests.post(
        "https://example.com/api/auth/login",  # sample URL
        json=payload,
        timeout=(3, 10),
    )
    assert response.status_code == expected_status

With id, the output becomes test_login[valid_credentials] โ€” clear and searchable.
Without it, you get auto-generated names like test_login[payload0-200], which get confusing fast.

MarkPurposeReal-world use case
pytest.mark.smokeCustom categoryRun only critical checks first in CI
pytest.mark.skipTemporary skipExclude known bugs or environment-specific patterns
pytest.mark.xfailExpected failureKeep a failing test in the suite while waiting for a fix
โš ๏ธ Custom marks must be registered in pytest.ini
Custom marks like pytest.mark.smoke require registration in pytest.ini (or pyproject.toml), otherwise pytest raises PytestUnknownMarkWarning. Built-in marks like skip and xfail do not need registration.

# pytest.ini
[pytest]
markers =
    smoke: Smoke tests (basic sanity checks)
    slow: Tests with long execution time

2. Stack Decorators to Generate Combinations

Stacking multiple @pytest.mark.parametrize decorators generates the Cartesian product of all patterns automatically.

import pytest

# 2 roles ร— 3 languages = 6 patterns auto-generated
@pytest.mark.parametrize("lang", ["ja", "en", "ko"])
@pytest.mark.parametrize("role", ["admin", "user"])
def test_dashboard_access(lang: str, role: str) -> None:
    # Real-world use: validate role ร— language access control
    assert lang in ("ja", "en", "ko")
    assert role in ("admin", "user")
โš ๏ธ Strategies for dealing with combinatorial explosion
Stacking multiplies the pattern count (3 ร— 3 ร— 3 = 27; 4 ร— 4 ร— 4 = 64). Execution time can grow quickly. Use these strategies to keep things manageable:

  • Boundary value analysis: Test only the minimum, maximum, and boundary ยฑ1 values
  • Pairwise testing: Ensure every pair of factor values appears at least once โ€” tools like allpairspy can generate these sets automatically
  • Risk-based selection: Prioritize combinations with the highest failure impact; thin out low-risk ones
  • pytest.param + skip: Use marks=pytest.mark.skip to exclude specific unwanted combinations

3. Use indirect=True to Pass Parameters into Fixtures

With indirect=True, the parameter value is not passed directly to the test function.
Instead, it is forwarded to a fixture of the same name, which then returns the processed result.
This is ideal when you want to switch setup logic based on each parameter.

import pytest
import requests

# Fixture receives the parameter and returns an authenticated session
@pytest.fixture
def authed_session(request: pytest.FixtureRequest) -> requests.Session:
    role: str = request.param  # โ† parametrize value arrives here
    credentials = {
        "admin": {"username": "admin_user", "password": "admin_pass"},
        "user":  {"username": "normal_user", "password": "user_pass"},
    }
    cred = credentials[role]
    session = requests.Session()
    res = session.post(
        "https://example.com/api/auth/login",  # sample URL
        json=cred,
        timeout=(3, 10),
    )
    res.raise_for_status()
    session.headers.update({"Authorization": f"Bearer {res.json()['token']}"})
    yield session
    session.close()


# indirect=True forwards the value to the authed_session fixture
@pytest.mark.parametrize("authed_session", ["admin", "user"], indirect=True)
def test_profile_access(authed_session: requests.Session) -> None:
    res = authed_session.get(
        "https://example.com/api/profile",  # sample URL
        timeout=(3, 10),
    )
    assert res.status_code == 200

Without indirect=True, the string "admin" would be passed directly to the test.
Going through the fixture means login logic and teardown are also automated per parameter.

4. Apply indirect to Only Some Arguments (Partial indirect)

You can make only specific arguments go through a fixture while others are passed directly.

import pytest
import requests

@pytest.fixture
def authed_session(request: pytest.FixtureRequest) -> requests.Session:
    role: str = request.param
    session = requests.Session()
    # ... login logic ...
    yield session
    session.close()


# authed_session goes through the fixture; endpoint is passed directly
@pytest.mark.parametrize(
    "authed_session, endpoint",
    [
        pytest.param("admin", "/api/admin/users",   id="admin_users"),
        pytest.param("admin", "/api/admin/reports", id="admin_reports"),
        pytest.param("user",  "/api/profile",       id="user_profile"),
    ],
    indirect=["authed_session"],  # โ† specify target argument(s) as a list
)
def test_endpoint_access(authed_session: requests.Session, endpoint: str) -> None:
    res = authed_session.get(
        f"https://example.com{endpoint}",  # sample URL
        timeout=(3, 10),
    )
    assert res.status_code == 200

5. Combine with pytest-xdist for Parallel Execution

pytest-xdist lets you run parametrized test patterns in parallel, which can significantly cut execution time when you have many patterns.

pip install pytest-xdist
# Auto-detect CPU count and parallelize
pytest -n auto

# Or specify the number of workers
pytest -n 4
โš ๏ธ Watch out for test interference in parallel runs
When running tests in parallel with pytest-xdist, fixtures that hold mutable state can interfere across test workers.
Fixtures that write to a database or access shared resources require particular care.
Fixtures that only read data or work with isolated resources are generally safe to use with session or module scope in parallel runs.

parametrize vs fixture params โ€” When to Use Which

Both achieve “running tests across multiple patterns”, but they excel in different situations.
Let’s first look at what fixture params code actually looks like.

fixture params โ€” Code Example

import pytest
import requests

# Define roles as fixture params
@pytest.fixture(params=[
    pytest.param("admin",  id="admin_role"),
    pytest.param("editor", id="editor_role"),
    pytest.param("viewer", id="viewer_role"),
])
def role(request: pytest.FixtureRequest) -> str:
    return request.param


# Every test that uses this fixture runs automatically for all 3 roles
def test_can_access_dashboard(role: str) -> None:
    res = requests.get(
        "https://example.com/api/dashboard",  # sample URL
        headers={"X-Role": role},
        timeout=(3, 10),
    )
    assert res.status_code == 200


def test_can_view_profile(role: str) -> None:
    res = requests.get(
        "https://example.com/api/profile",  # sample URL
        headers={"X-Role": role},
        timeout=(3, 10),
    )
    assert res.status_code == 200

Unlike parametrize, every test function that declares role as an argument automatically runs all 3 patterns โ€” no decorator needed on each test.

Comparison Table

Criterion@pytest.mark.parametrizefixture paramsReal-world frequency
Where you write itTest function decoratorInside the fixture definitionโ€”
Input variations for a single test functionโ—Žโ–ณโ˜…โ˜…โ˜…
Apply same patterns across multiple test functionsโ–ณ (repeat per function)โ—Žโ˜…โ˜…โ˜†
Parameterize setup logic alongside dataโ–ณ (requires indirect)โ—Ž (natural)โ˜…โ˜…โ˜†
Combinatorial testing (Cartesian product)โ—Ž (stacked decorators)โ–ณโ˜…โ˜…โ˜†
Pattern management with id and marksโ—Ž (pytest.param)โ—Ž (pytest.param)โ˜…โ˜…โ˜…
Readability for beginnersโ—Žโ–ณโ€”

Decision flow

I want to pass multiple input patterns to a single test functionโ†’ @pytest.mark.parametrize
I want to reuse the same patterns across multiple test functionsโ†’ fixture params
I want setup logic to change along with the parameterโ†’ fixture params or indirect
I want to auto-generate all combinations (Cartesian product)โ†’ stacked @pytest.mark.parametrize

Managing Shared Test Data in a Separate File

When multiple test files share the same parameter lists, collecting them in a dedicated data file (e.g. tests/test_data.py) keeps things organized.

Importing directly from conftest.py can technically work, but it creates path-dependency issues and is a common source of ModuleNotFoundError. Separating parameter data into its own file is the safer approach.

# Directory layout
tests/
โ”œโ”€โ”€ conftest.py      โ† fixtures only (no parameter data here)
โ”œโ”€โ”€ test_data.py     โ† centralized parameter data
โ”œโ”€โ”€ test_login.py
โ””โ”€โ”€ test_users.py
# tests/test_data.py
import pytest

VALID_USER_PAYLOADS = [
    pytest.param({"username": "admin",  "password": "admin123"}, id="admin"),
    pytest.param({"username": "editor", "password": "edit456"},  id="editor"),
    pytest.param({"username": "viewer", "password": "view789"},  id="viewer"),
]

INVALID_USER_PAYLOADS = [
    pytest.param({"username": "",      "password": "pass"},  id="empty_username"),
    pytest.param({"username": "admin", "password": ""},      id="empty_password"),
    pytest.param({},                                          id="empty_body"),
]
# tests/test_login.py
import pytest
import requests
from tests.test_data import VALID_USER_PAYLOADS, INVALID_USER_PAYLOADS


@pytest.mark.parametrize("payload", VALID_USER_PAYLOADS)
def test_login_success(payload: dict) -> None:
    res = requests.post(
        "https://example.com/api/auth/login",  # sample URL
        json=payload,
        timeout=(3, 10),
    )
    assert res.status_code == 200


@pytest.mark.parametrize("payload", INVALID_USER_PAYLOADS)
def test_login_failure(payload: dict) -> None:
    res = requests.post(
        "https://example.com/api/auth/login",  # sample URL
        json=payload,
        timeout=(3, 10),
    )
    assert res.status_code in (400, 422)

Separating parameter lists into variables keeps test functions clean and means adding a new pattern only requires editing one place.

Parametrize with Playwright

The same patterns work seamlessly with Playwright. Here’s an example of parametrized login validation in a browser test.

import pytest
from playwright.sync_api import Page

@pytest.mark.parametrize("username, password, expected_url", [
    pytest.param("admin",  "correct", "/dashboard", id="admin_login"),
    pytest.param("user",   "correct", "/home",      id="user_login"),
    pytest.param("admin",  "wrong",   "/login",     id="invalid_password"),
    pytest.param("",       "",        "/login",     id="empty_credentials"),
])
def test_login_redirect(
    page: Page,
    username: str,
    password: str,
    expected_url: str,
) -> None:
    page.goto("https://example.com/login")  # sample URL
    page.fill("[data-testid='username']", username)
    page.fill("[data-testid='password']", password)
    page.click("[data-testid='submit']")
    page.wait_for_url(f"**{expected_url}")
    assert expected_url in page.url

The page fixture is provided by pytest-playwright.
Combining it with @pytest.mark.parametrize lets you validate multiple login scenarios efficiently without relaunching the browser between each case.

7 Common Pitfalls in Real Projects

โš ๏ธ pytest parametrize pitfalls to watch out for

  1. Missing test_ prefix on the function name
    pytest only collects functions starting with test_ by default. Parametrizing a function named check_login() means it will never run.
  2. conftest.py is in the wrong directory
    Shared fixtures in conftest.py must be in the same directory as the test files, or a parent directory. Placing it elsewhere means fixtures won’t be discovered.
  3. Parametrizing a fixture that holds mutable state
    Fixtures that write to a database or access shared resources can interfere across workers when run in parallel with pytest-xdist. Fixtures that only read data or use isolated resources are generally safe with session or module scope in parallel runs.
  4. Custom marks not registered in pytest.ini
    Using pytest.param(..., marks=pytest.mark.smoke) without registering the mark in pytest.ini (or pyproject.toml) produces PytestUnknownMarkWarning. Built-in marks skip and xfail need no registration.
  5. print() output is invisible
    pytest captures stdout by default. Add pytest -s to see per-pattern output, or use the logging module instead.
  6. An exception during setup shows ERROR, not FAILED
    If an exception occurs inside a fixture or indirect processing, the result shows as ERROR rather than FAILED. Check the data types and values being passed as parameters.
  7. Running from a subdirectory causes ModuleNotFoundError
    Package-style imports like from tests.test_data import ... require running pytest from the project root. Always run from the directory containing pytest.ini.

Frequently Asked Questions (FAQ)

Q. Is id required?

Not required, but recommended once you have 5 or more patterns.
Without it, pytest auto-generates names like test_login[payload0-200], which makes identifying failures in CI logs difficult.
Building the habit of always adding id saves headaches later.

Q. Should I use fixture params or indirect?

Use fixture params when you want the same patterns applied automatically to multiple test functions.
Use indirect when you need to switch setup logic for a specific test function based on the parameter.
Both pass values through a fixture, but the scope and location differ. Refer to the decision flow table above when in doubt.

Q. What is the difference between indirect=True and indirect=["arg_name"]?

indirect=True routes all arguments through their matching fixtures.
Using a list like indirect=["authed_session"] sends only that specific argument through a fixture, passing the rest directly to the test.
Use the list form when you have multiple arguments and only some need fixture processing.

Q. How do I skip only a specific parameter pattern?

Use pytest.param(..., marks=pytest.mark.skip(reason="...")) to skip that specific pattern.
To mark a pattern as an expected failure instead, use pytest.mark.xfail.
Both let you keep the pattern in your test suite without blocking the rest of the run.

Q. What should I do when the number of parameters becomes too large?

You can load parameters from CSV, JSON, or YAML files.
For more dynamic control, the pytest_generate_tests hook lets you filter or generate parameters programmatically at collection time (an intermediate-level technique).
As a first step though, it’s worth reviewing which patterns are actually necessary โ€” focusing on boundary values, representative equivalence classes, and error cases is usually more maintainable than exhaustive coverage.

Q. Does @pytest.mark.parametrize work with class-based tests?

Yes. You can apply it to individual methods within a class, or to the entire class.
When applied to the class, every test method inside runs against the same parameter set.

Summary

Key PointDescription
pytest.param with id and marksName and tag each pattern for better readability. Use skip and xfail where appropriate
Stacked decorators for combinationsAuto-generate all combinations. Control explosion with pairwise, boundary values, or risk-based selection
indirect=TruePass parameters into fixtures to switch setup logic per pattern
Partial indirectRoute only specific arguments through fixtures using a list
pytest-xdist integrationRun patterns in parallel for faster suites. Watch for interference in fixtures with mutable state
Shared data in test_data.pySeparate parameter data from conftest.py for safe and reliable imports
parametrize vs fixture paramsInput variations for one test โ†’ parametrize; shared patterns across many tests โ†’ fixture params

Mastering @pytest.mark.parametrize‘s advanced patterns lets you grow your test coverage without growing your codebase.
The easiest starting point: add id to your existing pytest.param calls.

Once your patterns are well-organized, adding or removing cases becomes straightforward โ€” and your CI failure messages become actually useful.

Copied title and URL