Fading Coder

One Final Commit for the Last Sprint

Home > Tech > Content

Parametrizing tests in pytest: marks, ids, scope, and hooks

Tech 2

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.

Tags: pytestPython

Related Articles

Understanding Strong and Weak References in Java

Strong References Strong reference are the most prevalent type of object referencing in Java. When an object has a strong reference pointing to it, the garbage collector will not reclaim its memory. F...

Comprehensive Guide to SSTI Explained with Payload Bypass Techniques

Introduction Server-Side Template Injection (SSTI) is a vulnerability in web applications where user input is improper handled within the template engine and executed on the server. This exploit can r...

Implement Image Upload Functionality for Django Integrated TinyMCE Editor

Django’s Admin panel is highly user-friendly, and pairing it with TinyMCE, an effective rich text editor, simplifies content management significantly. Combining the two is particular useful for bloggi...

Leave a Comment

Anonymous

◎Feel free to join the discussion and share your thoughts.