Pytest & CI

Generating Readable pytest Test IDs

A parametrized test that reports test_charge[case3] has lost the only piece of information that mattered: which input failed. The default id generator renders ints, strings, booleans and enums well, but anything structured — a dict, a dataclass, a list of rows — becomes a positional placeholder, and the failure output stops being self-explanatory exactly when the input is complex enough that you need it to be.

Prerequisites

  • pytest 7.0+; pytest.param and the ids callable form have been stable since 3.0, and pytest_make_parametrize_id since 2.9.
  • The direct and indirect forms from advanced parametrization techniques.

Solution

There are three levels of control, and the right one depends on how many tests need it.

Per case, with pytest.param. The most explicit form, and the right choice when cases have names a reader would recognise. It also carries marks, so a single slow or expected-failure case can be tagged without splitting the list.

Python
import pytest

@pytest.mark.parametrize(
    "payload,expected_status",
    [
        pytest.param({"amount": 100, "currency": "EUR"}, 201, id="valid-eur"),
        pytest.param({"amount": 0, "currency": "EUR"}, 422, id="zero-amount"),
        pytest.param({"amount": 100, "currency": "XXX"}, 422, id="unknown-currency"),
        pytest.param({"amount": 10 ** 12, "currency": "EUR"}, 422,
                     id="over-limit", marks=pytest.mark.slow),
    ],
)
def test_charge_validation(payload, expected_status):
    assert post("/charges", json=payload).status_code == expected_status

The generated node ids are now test_charge_validation[valid-eur] and so on: greppable in a log, selectable on the command line, and meaningful in a coverage report.

Per parametrize call, with a callable. When the name can be derived from the value, a callable avoids repeating yourself. pytest calls it once per value (not per case), and returning None falls back to the default rendering for that value.

Python
import pytest

def render(value):
    if isinstance(value, dict):
        return f"{value['currency']}-{value['amount']}"   # dicts get a real name
    return None                                            # ints render themselves

@pytest.mark.parametrize(
    "payload,expected_status",
    [({"amount": 100, "currency": "EUR"}, 201),
     ({"amount": 0, "currency": "EUR"}, 422)],
    ids=render,
)
def test_charge(payload, expected_status):
    assert post("/charges", json=payload).status_code == expected_status

Project-wide, with a hook. pytest_make_parametrize_id is consulted for every value pytest cannot name by itself, across the whole suite. One implementation in the root conftest.py gives every test the same convention, which matters more than any individual name.

Python
# conftest.py
from dataclasses import is_dataclass

def pytest_make_parametrize_id(config, val, argname):
    if is_dataclass(val):
        return f"{type(val).__name__}"
    if isinstance(val, dict):
        return "-".join(f"{k}={v}" for k, v in sorted(val.items()))[:40]
    if isinstance(val, (list, tuple)):
        return f"{argname}x{len(val)}"          # rowsx3 rather than rows0
    return None                                  # let pytest handle scalars
Which naming rule wins A left-to-right precedence chain for test ids: an explicit id on pytest.param wins, then the ids argument on the parametrize call, then the pytest_make_parametrize_id hook, and finally pytest built-in rendering which falls back to a positional name. Which naming rule wins pytest.param(id=) explicit, per case ids= argument per parametrize call the hook project-wide default built-in scalars, else argname0 Returning None from the callable or the hook falls through to the next stage.
The first rule that returns a string wins, so a project-wide hook can set the convention while individual cases keep the right to override it.

Why this works

pytest builds each test id during collection, before any fixture runs. For every parametrized value it asks, in order: was an explicit id supplied, did the ids callable return a string, did pytest_make_parametrize_id return one, and finally can the value be rendered as a short ASCII literal? Only the last stage can fail, and its fallback is the positional argname0 form. Ids are also normalised — non-ASCII characters are escaped and duplicates get a numeric suffix — because the id has to be usable as part of a node id on the command line.

Selecting and deselecting individual cases

Readable ids pay for themselves the moment you need to run one case. The full node id works, with the parameter in brackets:

Bash
$ pytest "tests/test_charge.py::test_charge_validation[unknown-currency]"
$ pytest -k "unknown-currency"                 # substring match, no quoting pain
$ pytest -k "not over-limit"                   # skip the slow one locally

The -k form is worth preferring in shell scripts, because bracketed ids collide with glob expansion in some shells and require quoting that is easy to get wrong in CI configuration.

Ids also drive rerun and reporting tooling. --last-failed stores node ids, so stable, meaningful ids survive between runs while positional ones shift the moment a case is inserted in the middle of the list — inserting case1 renames every case after it, and --last-failed then re-runs the wrong tests. That fragility is the strongest practical argument for explicit ids on any list that changes over time.

How pytest renders each value type by default A table of value types and their default rendering as test ids: strings and numbers render literally, booleans and None render as words, enums use their name, and lists, dicts and objects fall back to a positional argname plus index. How pytest renders each value type by default Criterion Default id Needs help? int, str, bool, None the literal value no enum member the member name no dataclass / object argname0, argname1 yes list / dict / tuple argname0, argname1 yes
Only the last two rows produce unreadable output, which is exactly where an explicit id or the hook should apply.

Naming conventions that survive a growing suite

A naming rule is only useful if two engineers writing tests six months apart produce ids that read the same way. Three conventions do most of that work.

Name the case, not the data. id="zero-amount" describes the scenario; id="amount-0-currency-EUR" describes the payload. The first survives a change to the payload shape, the second has to be rewritten whenever a field is added — and it is that rewrite which breaks --last-failed and every dashboard keyed on node id.

Keep ids short and lowercase with hyphens. They appear inside brackets in node ids, in JUnit XML attributes, and in shell commands. Spaces and colons are legal but need quoting; capitals make -k matching case-sensitive in a way that surprises people. A ten-to-twenty-character kebab-case name is the sweet spot.

Encode the axis, not the index, in multi-parameter tests. With two parametrize decorators the ids compose as [outer-inner], so naming each axis separately gives readable combinations for free:

Python
import pytest

@pytest.mark.parametrize("browser", ["chromium", "firefox"], ids=["chrome", "ff"])
@pytest.mark.parametrize("locale", ["en_GB", "de_DE"], ids=["gb", "de"])
def test_checkout_renders(browser, locale):
    # ids: test_checkout_renders[gb-chrome], [gb-ff], [de-chrome], [de-ff]
    assert render(browser, locale).status_code == 200

Four cases, each identifiable at a glance, and -k "ff and de" selects exactly one of them. Note the ordering: the last decorator applied is the outermost, so it appears first in the id — a detail worth checking with --collect-only rather than reasoning about, because it is the opposite of what most people expect.

For generated case lists — rows read from a CSV, scenarios discovered from a directory — build the ids in the same comprehension that builds the cases, so the two cannot drift apart:

Python
import pathlib
import pytest

FIXTURES = sorted(pathlib.Path("tests/data").glob("*.json"))

@pytest.mark.parametrize(
    "fixture_path",
    FIXTURES,
    ids=[p.stem for p in FIXTURES],       # one id per case, derived from the file
)
def test_fixture_parses(fixture_path):
    assert parse(fixture_path.read_text()) is not None

That pattern also makes a missing data file visible: the case disappears from the collected list rather than failing with a path error, and a --collect-only -q count assertion in CI catches the silent shrinkage before anyone trusts the green run.

Three id conventions and what each protects Three panels describing id conventions: naming the scenario rather than the data, keeping ids short and kebab-case, and naming each parametrize axis separately, with the failure each convention prevents. Three id conventions and what each protects Name the scenario zero-amount, not amount-0 survives payload changes keeps --last-failed valid reads as documentation Short kebab-case no quoting problems greppable in CI logs safe in JUnit XML ten to twenty chars Name each axis ids compose as outer-inner combinations stay readable -k selects one exactly last decorator is first
Each convention prevents a specific failure: a rewrite, a quoting bug, or an unreadable combinatorial explosion.

Edge cases and failure modes

  • Duplicate ids are silently suffixed. Two cases named valid become valid0 and valid1, which reintroduces the problem you were solving. Keep ids unique within a parametrize call.
  • Non-ASCII characters are escaped. A unicode id renders as an escape sequence in the node id, making it hard to type on the command line. Transliterate names in the hook rather than shipping them raw.
  • The ids list form must match the case count exactly. A list of three names for four cases raises at collection — the callable form avoids this failure mode entirely.
  • Ids appear in JUnit XML and coverage reports. Renaming them changes historical test identity in dashboards that key on node id, so treat a mass rename as a deliberate migration rather than a cleanup.
  • Indirect parameters are rendered from the raw value. With indirect=True the test never sees the value, but the id still comes from it — see indirect parametrization with fixtures for the interaction.

One reporting consequence is worth planning for before a rename: ids are part of the node id, and node ids are the primary key of almost every downstream system. JUnit XML consumers, flaky-test dashboards, --last-failed state, and coverage contexts all key on them. Renaming ids across a large suite therefore resets history in those systems — dashboards show every test as new, and the flake record for a test that has been quarantined for a month disappears. Do the rename in one commit, note it in the changelog, and expect a week of blank trend lines rather than discovering it after the fact.

Frequently Asked Questions

How do I stop pytest generating ids like case0, case1? Pass ids= with a list or a callable, or wrap each case in pytest.param(..., id="name"). pytest falls back to positional names only when it cannot render the value as a short ASCII string, which is why lists, dicts and objects get numbered.

Can I select a single parametrized case from the command line? Yes. Quote the full node id including the bracketed parameter, for example pytest 'tests/test_api.py::test_get[staging-200]'. With -k you can match on the id substring instead, which avoids shell quoting problems.

How do I set ids for every test in the project at once? Implement pytest_make_parametrize_id in a conftest.py. It is called for every parametrized value pytest cannot name itself, so returning a string from it gives the whole suite a consistent naming rule.

← Back to Advanced Parametrization Techniques