Parametrizing tests in pytest: marks, ids, scope, and hooks
Parametrization enables a single test definition to run against many inputs by turning scenario-specific bits into data. pytest exposes parametrization in several layers: fixtures can be parametrized, tests can be expanded with @pytest.mark.parametrize, and fully custom strategies can be built with the pytest_generate_tests hook.
Parametrizing with @pytest.mark.parametrize
Each parametrize mark expands the target test (function, class, or module via pytestmark) into multiple test instances by assigning concrete values to declared argument names.
Signature (simplified):
- parametrize(argnames, argvalues, indirect=False, ids=None, scope=None)
argnames rules
- argnames must be a subset of the target’s function parameters:
import pytest
@pytest.mark.parametrize("x, y", [(1, 2)])
def test_only_x(x):
assert x + 1 == 2
# Collection error: function uses no argument 'y'
- argnames cannot include a parameter that already has a default:
import pytest
@pytest.mark.parametrize("x, y", [(1, 2)])
def test_defaults(x, y=0):
assert x + y == 3
# Collection error: function already takes argument 'y' with a default value
- A parametrized name overrides a fixture with the same name:
import pytest
@pytest.fixture()
def threshold():
return 10
@pytest.mark.parametrize("value, threshold", [(5, 6)])
def test_override(value, threshold):
# Uses parametrized threshold=6, not the fixture’s 10
assert value + 1 == threshold
argvalues forms
- When argnames has multiple names, each element of argvalues must be an indexable of matching length (tuple/list, etc.):
import pytest
@pytest.mark.parametrize("lhs, rhs", [(1, 2), [2, 3], tuple([3, 4])])
def test_pair(lhs, rhs):
assert lhs + 1 == rhs
# Using a set is not recommended due to undefined ordering
- With a single name, argvalues can be a simple sequence of scalars:
import pytest
@pytest.mark.parametrize("n", [0, 1, 2, 3])
def test_single(n):
assert n >= 0
- argvalues can be any iterable/generator, enabling lazy or external data sources:
import pytest
def iter_devices():
for dev in ("alpha", "beta", "gamma"):
yield dev
@pytest.mark.parametrize("device", iter_devices())
def test_devices(device):
assert isinstance(device, str)
- Using pytest.param for per-case marks and ids:
import pytest
@pytest.mark.parametrize(
("divisor", "expected"),
[
(1, 2),
pytest.param(0, None, marks=pytest.mark.xfail(reason="division by zero", strict=False), id="zero-div")
],
)
def test_div(divisor, expected):
assert (2 / divisor) == expected
Internally each element becomes a ParameterSet(values, marks, id). Passing a ParameterSet directly is accepted:
import pytest
from _pytest.mark.structures import ParameterSet
cases = [
(10, 11),
ParameterSet(values=(20, 21), marks=[], id="twenty"),
]
@pytest.mark.parametrize("a, b", cases)
def test_ps(a, b):
assert a + 1 == b
indirect
indirect controls whether provided values are fed into fixtures (instead of direct arguments). Values become available to the fixture via request.param.
import pytest
@pytest.fixture()
def high(request):
# Transform incoming parameter
return request.param + 5
@pytest.fixture()
def low(request):
return request.param - 5
# No indirection: direct values used
@pytest.mark.parametrize("low, high", [(10, 20), (30, 40)])
def test_no_indirect(low, high):
assert low < high
# Indirect both: values go into fixtures of same names
@pytest.mark.parametrize("low, high", [(10, 20), (30, 40)], indirect=True)
def test_indirect_both(low, high):
assert low < high # now low=5, high=25 for first case
# Indirect subset
@pytest.mark.parametrize("low, high", [(10, 20), (30, 40)], indirect=["high"])
def test_indirect_partial(low, high):
assert (low + 5) < high
ids
ids can be a sequence of strings or a callable. It labels each expanded test, shows up in node ids, and can drive -k filtering.
- Explicit list/tuple of ids (must match length of argvalues):
import pytest
@pytest.mark.parametrize("i, j", [(1, 2), (3, 4)], ids=["first", "second"])
def test_ids_explicit(i, j):
pass
- Duplicate ids are deduplicated by suffixing indices:
import pytest
@pytest.mark.parametrize("p, q", [(1, 2), (3, 4)], ids=["dup", "dup"])
def test_ids_dupe(p, q):
pass
- Non-ASCII ids are escaped unless configured otherwise. To show them raw, set in pytest.ini:
[pytest]
disable_test_id_escaping_and_forfeit_all_rights_to_community_support = True
import pytest
@pytest.mark.parametrize("x", [1, 2], ids=["英文", "中文"])
def test_ids_unicode(x):
pass
- ids as a calable; the return is used per argument and joined with "-" across parameters:
import pytest
def make_id(val):
return f"v{val+1}" if isinstance(val, int) else str(val)
@pytest.mark.parametrize("m, n", [(1, 2), (3, 4)], ids=make_id)
def test_ids_callable(m, n):
pass
- ids provided via pytest.param override the ids list for that case:
import pytest
@pytest.mark.parametrize(
"a, b",
[
(1, 2),
pytest.param(3, 4, id="override-id"),
],
ids=["default-0", "default-1"],
)
def test_ids_override(a, b):
pass
Targeted execution example using ids in -k:
import pytest
@pytest.mark.parametrize(
"os_name, version",
[
pytest.param("Windows", 11, id="Windows"),
pytest.param("Windows", 10, id="Windows"),
pytest.param("Linux", 6, id="Non-Windows"),
],
)
def test_platforms(os_name, version):
pass
# Run only Windows scenarios: pytest -k "Windows and not Non"
scope
scope controls how parametrized instances are grouped during collection (and interacts with indirect+fixture scopes).
- Explicit scope example:
import pytest
@pytest.mark.parametrize("x, y", [(1, 2), (3, 4)], scope="module")
def test_scope_a(x, y):
pass
@pytest.mark.parametrize("x, y", [(1, 2), (3, 4)], scope="module")
def test_scope_b(x, y):
pass
With module scope, colllection groups by parameter set across funcsions (e.g., test_scope_a[1-2], test_scope_b[1-2], then test_scope_a[3-4], test_scope_b[3-4]). Without scope, default ordering is function-level grouping.
- Derived scope with indirect: if scope is None and indirect applies to all argnames, the effective parameter scope becomes the smallest of involved fixture scopes; otherwise it’s function.
import pytest
@pytest.fixture(scope="module")
def x(request):
return request.param
@pytest.fixture(scope="module")
def y(request):
return request.param
@pytest.mark.parametrize("x, y", [(1, 2), (3, 4)], indirect=True)
def test_scope_indirect(x, y):
pass
# Effective parameter scope is module due to fixtures
Empty parameter set behavior
When a parametrize mark receives an empty argument list, pytest marks the test according to the empty-parameter policy (default: skipped).
import sys
import pytest
def load_numbers():
return [1, 2, 3] if sys.version_info >= (3, 8) else []
@pytest.mark.parametrize("num", load_numbers())
def test_empty_param_set(num):
assert isinstance(num, int)
Configure behavior in pytest.ini:
- empty_parameter_set_mark = skip (default)
- empty_parameter_set_mark = xfail (equivalent to xfail(run=False))
- empty_parameter_set_mark = fail_at_collect (raise a collection error)
Example:
[pytest]
empty_parameter_set_mark = xfail
Stacking multiple parametrize marks
Multiple parametrize marks on the same test produce the Cartesian product of the provided values.
import pytest
@pytest.mark.parametrize("rows", [1, 2, 3])
@pytest.mark.parametrize("cols, cells", [(1, 2), (3, 4)])
def test_product(rows, cols, cells):
pass
# Produces 6 test cases (all combinations)
Module-level parametrization
Use pytestmark at module scope to apply parametrization to all tests that accept the given arguments.
import pytest
pytestmark = pytest.mark.parametrize("arg, out", [(1, 2), (3, 4)])
def test_mod(arg, out):
assert arg + 1 == out
Custom parametrization via pytest_generate_tests
pytest_generate_tests runs during collection and receives a Metafunc, enabling dynamic parametrization and integration with command-line options or external data.
Example: treat function argument string_data as a parameter filled from a CLI option.
conftest.py:
# conftest.py
def pytest_addoption(parser):
parser.addoption(
"--string-data",
action="append",
default=[],
help="values for 'string_data' parameter",
)
def pytest_generate_tests(metafunc):
if "string_data" in metafunc.fixturenames:
values = metafunc.config.getoption("string-data")
metafunc.parametrize("string_data", values)
test file:
# test_strings.py
def test_alpha(string_data):
assert string_data.isalpha()
Run with values:
pytest -q --string-data=hello --string-data=world
If no values are provided, this becomes an empty parameter set and follows the configured empty_parameter_set_mark behavior.
Note: Whether using metafunc.parametrize or @pytest.mark.parametrize, argument names must not repeat across stacked parametrizations; pytest raises ValueError on duplicates.