[{"data":1,"prerenderedAt":1093},["ShallowReactive",2],{"page-\u002Fadvanced-pytest-architecture-configuration\u002Fadvanced-parametrization-techniques\u002Fgenerating-readable-test-ids\u002F":3},{"id":4,"title":5,"body":6,"description":1059,"extension":1060,"meta":1061,"navigation":92,"path":1089,"seo":1090,"stem":1091,"__hash__":1092},"content\u002Fadvanced-pytest-architecture-configuration\u002Fadvanced-parametrization-techniques\u002Fgenerating-readable-test-ids\u002Findex.md","Generating Readable pytest Test IDs",{"type":7,"value":8,"toc":1049},"minimark",[9,18,23,55,59,62,70,166,173,188,262,275,334,467,471,484,488,491,513,520,534,635,639,642,658,667,677,715,730,733,793,800,896,900,955,961,965,979,992,1004,1008,1039,1045],[10,11,12,13,17],"p",{},"A parametrized test that reports ",[14,15,16],"code",{},"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.",[19,20,22],"h2",{"id":21},"prerequisites","Prerequisites",[24,25,26,46],"ul",{},[27,28,29,33,34,37,38,41,42,45],"li",{},[30,31,32],"strong",{},"pytest 7.0+","; ",[14,35,36],{},"pytest.param"," and the ",[14,39,40],{},"ids"," callable form have been stable since 3.0, and ",[14,43,44],{},"pytest_make_parametrize_id"," since 2.9.",[27,47,48,49,54],{},"The direct and indirect forms from ",[50,51,53],"a",{"href":52},"\u002Fadvanced-pytest-architecture-configuration\u002Fadvanced-parametrization-techniques\u002F","advanced parametrization techniques",".",[19,56,58],{"id":57},"solution","Solution",[10,60,61],{},"There are three levels of control, and the right one depends on how many tests need it.",[10,63,64,69],{},[30,65,66,67,54],{},"Per case, with ",[14,68,36],{}," 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.",[71,72,77],"pre",{"className":73,"code":74,"language":75,"meta":76,"style":76},"language-python shiki shiki-themes github-light github-dark","import pytest\n\n@pytest.mark.parametrize(\n    \"payload,expected_status\",\n    [\n        pytest.param({\"amount\": 100, \"currency\": \"EUR\"}, 201, id=\"valid-eur\"),\n        pytest.param({\"amount\": 0, \"currency\": \"EUR\"}, 422, id=\"zero-amount\"),\n        pytest.param({\"amount\": 100, \"currency\": \"XXX\"}, 422, id=\"unknown-currency\"),\n        pytest.param({\"amount\": 10 ** 12, \"currency\": \"EUR\"}, 422,\n                     id=\"over-limit\", marks=pytest.mark.slow),\n    ],\n)\ndef test_charge_validation(payload, expected_status):\n    assert post(\"\u002Fcharges\", json=payload).status_code == expected_status\n","python","",[14,78,79,87,94,100,106,112,118,124,130,136,142,148,154,160],{"__ignoreMap":76},[80,81,84],"span",{"class":82,"line":83},"line",1,[80,85,86],{},"import pytest\n",[80,88,90],{"class":82,"line":89},2,[80,91,93],{"emptyLinePlaceholder":92},true,"\n",[80,95,97],{"class":82,"line":96},3,[80,98,99],{},"@pytest.mark.parametrize(\n",[80,101,103],{"class":82,"line":102},4,[80,104,105],{},"    \"payload,expected_status\",\n",[80,107,109],{"class":82,"line":108},5,[80,110,111],{},"    [\n",[80,113,115],{"class":82,"line":114},6,[80,116,117],{},"        pytest.param({\"amount\": 100, \"currency\": \"EUR\"}, 201, id=\"valid-eur\"),\n",[80,119,121],{"class":82,"line":120},7,[80,122,123],{},"        pytest.param({\"amount\": 0, \"currency\": \"EUR\"}, 422, id=\"zero-amount\"),\n",[80,125,127],{"class":82,"line":126},8,[80,128,129],{},"        pytest.param({\"amount\": 100, \"currency\": \"XXX\"}, 422, id=\"unknown-currency\"),\n",[80,131,133],{"class":82,"line":132},9,[80,134,135],{},"        pytest.param({\"amount\": 10 ** 12, \"currency\": \"EUR\"}, 422,\n",[80,137,139],{"class":82,"line":138},10,[80,140,141],{},"                     id=\"over-limit\", marks=pytest.mark.slow),\n",[80,143,145],{"class":82,"line":144},11,[80,146,147],{},"    ],\n",[80,149,151],{"class":82,"line":150},12,[80,152,153],{},")\n",[80,155,157],{"class":82,"line":156},13,[80,158,159],{},"def test_charge_validation(payload, expected_status):\n",[80,161,163],{"class":82,"line":162},14,[80,164,165],{},"    assert post(\"\u002Fcharges\", json=payload).status_code == expected_status\n",[10,167,168,169,172],{},"The generated node ids are now ",[14,170,171],{},"test_charge_validation[valid-eur]"," and so on: greppable in a log, selectable on the command line, and meaningful in a coverage report.",[10,174,175,178,179,183,184,187],{},[30,176,177],{},"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 ",[180,181,182],"em",{},"value"," (not per case), and returning ",[14,185,186],{},"None"," falls back to the default rendering for that value.",[71,189,191],{"className":73,"code":190,"language":75,"meta":76,"style":76},"import pytest\n\ndef render(value):\n    if isinstance(value, dict):\n        return f\"{value['currency']}-{value['amount']}\"   # dicts get a real name\n    return None                                            # ints render themselves\n\n@pytest.mark.parametrize(\n    \"payload,expected_status\",\n    [({\"amount\": 100, \"currency\": \"EUR\"}, 201),\n     ({\"amount\": 0, \"currency\": \"EUR\"}, 422)],\n    ids=render,\n)\ndef test_charge(payload, expected_status):\n    assert post(\"\u002Fcharges\", json=payload).status_code == expected_status\n",[14,192,193,197,201,206,211,216,221,225,229,233,238,243,248,252,257],{"__ignoreMap":76},[80,194,195],{"class":82,"line":83},[80,196,86],{},[80,198,199],{"class":82,"line":89},[80,200,93],{"emptyLinePlaceholder":92},[80,202,203],{"class":82,"line":96},[80,204,205],{},"def render(value):\n",[80,207,208],{"class":82,"line":102},[80,209,210],{},"    if isinstance(value, dict):\n",[80,212,213],{"class":82,"line":108},[80,214,215],{},"        return f\"{value['currency']}-{value['amount']}\"   # dicts get a real name\n",[80,217,218],{"class":82,"line":114},[80,219,220],{},"    return None                                            # ints render themselves\n",[80,222,223],{"class":82,"line":120},[80,224,93],{"emptyLinePlaceholder":92},[80,226,227],{"class":82,"line":126},[80,228,99],{},[80,230,231],{"class":82,"line":132},[80,232,105],{},[80,234,235],{"class":82,"line":138},[80,236,237],{},"    [({\"amount\": 100, \"currency\": \"EUR\"}, 201),\n",[80,239,240],{"class":82,"line":144},[80,241,242],{},"     ({\"amount\": 0, \"currency\": \"EUR\"}, 422)],\n",[80,244,245],{"class":82,"line":150},[80,246,247],{},"    ids=render,\n",[80,249,250],{"class":82,"line":156},[80,251,153],{},[80,253,254],{"class":82,"line":162},[80,255,256],{},"def test_charge(payload, expected_status):\n",[80,258,260],{"class":82,"line":259},15,[80,261,165],{},[10,263,264,267,268,270,271,274],{},[30,265,266],{},"Project-wide, with a hook."," ",[14,269,44],{}," is consulted for every value pytest cannot name by itself, across the whole suite. One implementation in the root ",[14,272,273],{},"conftest.py"," gives every test the same convention, which matters more than any individual name.",[71,276,278],{"className":73,"code":277,"language":75,"meta":76,"style":76},"# conftest.py\nfrom dataclasses import is_dataclass\n\ndef pytest_make_parametrize_id(config, val, argname):\n    if is_dataclass(val):\n        return f\"{type(val).__name__}\"\n    if isinstance(val, dict):\n        return \"-\".join(f\"{k}={v}\" for k, v in sorted(val.items()))[:40]\n    if isinstance(val, (list, tuple)):\n        return f\"{argname}x{len(val)}\"          # rowsx3 rather than rows0\n    return None                                  # let pytest handle scalars\n",[14,279,280,285,290,294,299,304,309,314,319,324,329],{"__ignoreMap":76},[80,281,282],{"class":82,"line":83},[80,283,284],{},"# conftest.py\n",[80,286,287],{"class":82,"line":89},[80,288,289],{},"from dataclasses import is_dataclass\n",[80,291,292],{"class":82,"line":96},[80,293,93],{"emptyLinePlaceholder":92},[80,295,296],{"class":82,"line":102},[80,297,298],{},"def pytest_make_parametrize_id(config, val, argname):\n",[80,300,301],{"class":82,"line":108},[80,302,303],{},"    if is_dataclass(val):\n",[80,305,306],{"class":82,"line":114},[80,307,308],{},"        return f\"{type(val).__name__}\"\n",[80,310,311],{"class":82,"line":120},[80,312,313],{},"    if isinstance(val, dict):\n",[80,315,316],{"class":82,"line":126},[80,317,318],{},"        return \"-\".join(f\"{k}={v}\" for k, v in sorted(val.items()))[:40]\n",[80,320,321],{"class":82,"line":132},[80,322,323],{},"    if isinstance(val, (list, tuple)):\n",[80,325,326],{"class":82,"line":138},[80,327,328],{},"        return f\"{argname}x{len(val)}\"          # rowsx3 rather than rows0\n",[80,330,331],{"class":82,"line":144},[80,332,333],{},"    return None                                  # let pytest handle scalars\n",[335,336,339,463],"figure",{"className":337},[338],"diagram",[340,341,348,349,348,353,348,357,348,375,348,383,348,392,348,401,348,407,348,412,348,418,348,421,348,425,348,428,348,432,348,435,348,439,348,442,348,446,348,449,348,453,348,457],"svg",{"viewBox":342,"role":343,"ariaLabelledBy":344,"xmlns":347},"0 0 760 172","img",[345,346],"idsources-t","idsources-d","http:\u002F\u002Fwww.w3.org\u002F2000\u002Fsvg","\n  ",[350,351,352],"title",{"id":345},"Which naming rule wins",[354,355,356],"desc",{"id":346},"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.",[358,359,360,361,348],"defs",{},"\n    ",[362,363,370],"marker",{"id":364,"viewBox":365,"refX":366,"refY":367,"markerWidth":368,"markerHeight":368,"orient":369},"idsources-a","0 0 10 10","9","5","7","auto-start-reverse",[371,372],"path",{"d":373,"fill":374},"M0 0 L10 5 L0 10 z","#f2cc8f",[376,377],"rect",{"x":378,"y":378,"width":379,"height":380,"rx":381,"fill":382},"0","760","172","14","#fffdf8",[384,385,352],"text",{"x":386,"y":387,"textAnchor":388,"fontSize":389,"fontWeight":390,"fill":391},"380","30","middle","15","700","#3d405b",[376,393],{"x":394,"y":395,"width":396,"height":397,"rx":398,"fill":399,"stroke":374,"strokeWidth":400},"26","62","142","76","12","#f4f1de","1.8",[384,402,406],{"x":403,"y":404,"textAnchor":388,"fontSize":405,"fontWeight":390,"fill":391},"97","96","12.5","pytest.param(id=)",[384,408,411],{"x":403,"y":409,"textAnchor":388,"fontSize":410,"fill":391},"113","11","explicit, per case",[82,413],{"x1":414,"y1":415,"x2":416,"y2":415,"stroke":374,"strokeWidth":400,"markerEnd":417},"176","100","208","url(#idsources-a)",[376,419],{"x":420,"y":395,"width":396,"height":397,"rx":398,"fill":382,"stroke":374,"strokeWidth":400},"214",[384,422,424],{"x":423,"y":404,"textAnchor":388,"fontSize":405,"fontWeight":390,"fill":391},"286","ids= argument",[384,426,427],{"x":423,"y":409,"textAnchor":388,"fontSize":410,"fill":391},"per parametrize call",[82,429],{"x1":430,"y1":415,"x2":431,"y2":415,"stroke":374,"strokeWidth":400,"markerEnd":417},"364","396",[376,433],{"x":434,"y":395,"width":396,"height":397,"rx":398,"fill":399,"stroke":374,"strokeWidth":400},"403",[384,436,438],{"x":437,"y":404,"textAnchor":388,"fontSize":405,"fontWeight":390,"fill":391},"474","the hook",[384,440,441],{"x":437,"y":409,"textAnchor":388,"fontSize":410,"fill":391},"project-wide default",[82,443],{"x1":444,"y1":415,"x2":445,"y2":415,"stroke":374,"strokeWidth":400,"markerEnd":417},"552","584",[376,447],{"x":448,"y":395,"width":396,"height":397,"rx":398,"fill":382,"stroke":374,"strokeWidth":400},"592",[384,450,452],{"x":451,"y":404,"textAnchor":388,"fontSize":405,"fontWeight":390,"fill":391},"663","built-in",[384,454,456],{"x":451,"y":409,"textAnchor":388,"fontSize":455,"fill":391},"10.5","scalars, else argname0",[384,458,462],{"x":386,"y":459,"textAnchor":388,"fontSize":460,"fontStyle":461,"fill":391},"164","11.5","italic","Returning None from the callable or the hook falls through to the next stage.",[464,465,466],"figcaption",{},"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.",[19,468,470],{"id":469},"why-this-works","Why this works",[10,472,473,474,476,477,479,480,483],{},"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 ",[14,475,40],{}," callable return a string, did ",[14,478,44],{}," 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 ",[14,481,482],{},"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.",[19,485,487],{"id":486},"selecting-and-deselecting-individual-cases","Selecting and deselecting individual cases",[10,489,490],{},"Readable ids pay for themselves the moment you need to run one case. The full node id works, with the parameter in brackets:",[71,492,496],{"className":493,"code":494,"language":495,"meta":76,"style":76},"language-console shiki shiki-themes github-light github-dark","$ pytest \"tests\u002Ftest_charge.py::test_charge_validation[unknown-currency]\"\n$ pytest -k \"unknown-currency\"                 # substring match, no quoting pain\n$ pytest -k \"not over-limit\"                   # skip the slow one locally\n","console",[14,497,498,503,508],{"__ignoreMap":76},[80,499,500],{"class":82,"line":83},[80,501,502],{},"$ pytest \"tests\u002Ftest_charge.py::test_charge_validation[unknown-currency]\"\n",[80,504,505],{"class":82,"line":89},[80,506,507],{},"$ pytest -k \"unknown-currency\"                 # substring match, no quoting pain\n",[80,509,510],{"class":82,"line":96},[80,511,512],{},"$ pytest -k \"not over-limit\"                   # skip the slow one locally\n",[10,514,515,516,519],{},"The ",[14,517,518],{},"-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.",[10,521,522,523,526,527,530,531,533],{},"Ids also drive rerun and reporting tooling. ",[14,524,525],{},"--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 ",[14,528,529],{},"case1"," renames every case after it, and ",[14,532,525],{}," then re-runs the wrong tests. That fragility is the strongest practical argument for explicit ids on any list that changes over time.",[335,535,537,632],{"className":536},[338],[340,538,348,543,348,546,348,549,348,552,348,554,348,562,348,566,348,570,348,574,348,578,348,582,348,585,348,589,348,592,348,596,348,599,348,601,348,603,348,607,348,610,348,614,348,617,348,621,348,623,348,625,348,629],{"viewBox":539,"role":343,"ariaLabelledBy":540,"xmlns":347},"0 0 760 270",[541,542],"idrules-t","idrules-d",[350,544,545],{"id":541},"How pytest renders each value type by default",[354,547,548],{"id":542},"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.",[376,550],{"x":378,"y":378,"width":379,"height":551,"rx":381,"fill":382},"270",[384,553,545],{"x":386,"y":387,"textAnchor":388,"fontSize":389,"fontWeight":390,"fill":391},[376,555],{"x":556,"y":557,"width":558,"height":559,"rx":560,"fill":374,"stroke":391,"strokeWidth":561},"24","52","712","40","10","1.5",[384,563,565],{"x":564,"y":397,"fontSize":398,"fontWeight":390,"fill":391},"38","Criterion",[384,567,569],{"x":568,"y":397,"textAnchor":388,"fontSize":398,"fontWeight":390,"fill":391},"390","Default id",[384,571,573],{"x":572,"y":397,"textAnchor":388,"fontSize":398,"fontWeight":390,"fill":391},"620","Needs help?",[376,575],{"x":556,"y":576,"width":558,"height":559,"fill":399,"stroke":391,"strokeWidth":577},"92","1",[384,579,581],{"x":564,"y":580,"fontSize":460,"fill":391},"116","int, str, bool, None",[384,583,584],{"x":568,"y":580,"textAnchor":388,"fontSize":460,"fill":391},"the literal value",[384,586,588],{"x":572,"y":580,"textAnchor":388,"fontSize":460,"fill":587},"#8f3d22","no",[376,590],{"x":556,"y":591,"width":558,"height":559,"fill":382,"stroke":391,"strokeWidth":577},"132",[384,593,595],{"x":564,"y":594,"fontSize":460,"fill":391},"156","enum member",[384,597,598],{"x":568,"y":594,"textAnchor":388,"fontSize":460,"fill":391},"the member name",[384,600,588],{"x":572,"y":594,"textAnchor":388,"fontSize":460,"fill":587},[376,602],{"x":556,"y":380,"width":558,"height":559,"fill":399,"stroke":391,"strokeWidth":577},[384,604,606],{"x":564,"y":605,"fontSize":460,"fill":391},"196","dataclass \u002F object",[384,608,609],{"x":568,"y":605,"textAnchor":388,"fontSize":460,"fill":391},"argname0, argname1",[384,611,613],{"x":572,"y":605,"textAnchor":388,"fontSize":460,"fill":612},"#2a5f49","yes",[376,615],{"x":556,"y":616,"width":558,"height":559,"fill":382,"stroke":391,"strokeWidth":577},"212",[384,618,620],{"x":564,"y":619,"fontSize":460,"fill":391},"236","list \u002F dict \u002F tuple",[384,622,609],{"x":568,"y":619,"textAnchor":388,"fontSize":460,"fill":391},[384,624,613],{"x":572,"y":619,"textAnchor":388,"fontSize":460,"fill":612},[82,626],{"x1":627,"y1":557,"x2":627,"y2":628,"stroke":391,"strokeWidth":577},"274","252",[82,630],{"x1":631,"y1":557,"x2":631,"y2":628,"stroke":391,"strokeWidth":577},"505",[464,633,634],{},"Only the last two rows produce unreadable output, which is exactly where an explicit id or the hook should apply.",[19,636,638],{"id":637},"naming-conventions-that-survive-a-growing-suite","Naming conventions that survive a growing suite",[10,640,641],{},"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.",[10,643,644,267,647,650,651,654,655,657],{},[30,645,646],{},"Name the case, not the data.",[14,648,649],{},"id=\"zero-amount\""," describes the scenario; ",[14,652,653],{},"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 ",[14,656,525],{}," and every dashboard keyed on node id.",[10,659,660,663,664,666],{},[30,661,662],{},"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 ",[14,665,518],{}," matching case-sensitive in a way that surprises people. A ten-to-twenty-character kebab-case name is the sweet spot.",[10,668,669,672,673,676],{},[30,670,671],{},"Encode the axis, not the index, in multi-parameter tests."," With two parametrize decorators the ids compose as ",[14,674,675],{},"[outer-inner]",", so naming each axis separately gives readable combinations for free:",[71,678,680],{"className":73,"code":679,"language":75,"meta":76,"style":76},"import pytest\n\n@pytest.mark.parametrize(\"browser\", [\"chromium\", \"firefox\"], ids=[\"chrome\", \"ff\"])\n@pytest.mark.parametrize(\"locale\", [\"en_GB\", \"de_DE\"], ids=[\"gb\", \"de\"])\ndef test_checkout_renders(browser, locale):\n    # ids: test_checkout_renders[gb-chrome], [gb-ff], [de-chrome], [de-ff]\n    assert render(browser, locale).status_code == 200\n",[14,681,682,686,690,695,700,705,710],{"__ignoreMap":76},[80,683,684],{"class":82,"line":83},[80,685,86],{},[80,687,688],{"class":82,"line":89},[80,689,93],{"emptyLinePlaceholder":92},[80,691,692],{"class":82,"line":96},[80,693,694],{},"@pytest.mark.parametrize(\"browser\", [\"chromium\", \"firefox\"], ids=[\"chrome\", \"ff\"])\n",[80,696,697],{"class":82,"line":102},[80,698,699],{},"@pytest.mark.parametrize(\"locale\", [\"en_GB\", \"de_DE\"], ids=[\"gb\", \"de\"])\n",[80,701,702],{"class":82,"line":108},[80,703,704],{},"def test_checkout_renders(browser, locale):\n",[80,706,707],{"class":82,"line":114},[80,708,709],{},"    # ids: test_checkout_renders[gb-chrome], [gb-ff], [de-chrome], [de-ff]\n",[80,711,712],{"class":82,"line":120},[80,713,714],{},"    assert render(browser, locale).status_code == 200\n",[10,716,717,718,721,722,725,726,729],{},"Four cases, each identifiable at a glance, and ",[14,719,720],{},"-k \"ff and de\""," selects exactly one of them. Note the ordering: the ",[180,723,724],{},"last"," decorator applied is the outermost, so it appears first in the id — a detail worth checking with ",[14,727,728],{},"--collect-only"," rather than reasoning about, because it is the opposite of what most people expect.",[10,731,732],{},"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:",[71,734,736],{"className":73,"code":735,"language":75,"meta":76,"style":76},"import pathlib\nimport pytest\n\nFIXTURES = sorted(pathlib.Path(\"tests\u002Fdata\").glob(\"*.json\"))\n\n@pytest.mark.parametrize(\n    \"fixture_path\",\n    FIXTURES,\n    ids=[p.stem for p in FIXTURES],       # one id per case, derived from the file\n)\ndef test_fixture_parses(fixture_path):\n    assert parse(fixture_path.read_text()) is not None\n",[14,737,738,743,747,751,756,760,764,769,774,779,783,788],{"__ignoreMap":76},[80,739,740],{"class":82,"line":83},[80,741,742],{},"import pathlib\n",[80,744,745],{"class":82,"line":89},[80,746,86],{},[80,748,749],{"class":82,"line":96},[80,750,93],{"emptyLinePlaceholder":92},[80,752,753],{"class":82,"line":102},[80,754,755],{},"FIXTURES = sorted(pathlib.Path(\"tests\u002Fdata\").glob(\"*.json\"))\n",[80,757,758],{"class":82,"line":108},[80,759,93],{"emptyLinePlaceholder":92},[80,761,762],{"class":82,"line":114},[80,763,99],{},[80,765,766],{"class":82,"line":120},[80,767,768],{},"    \"fixture_path\",\n",[80,770,771],{"class":82,"line":126},[80,772,773],{},"    FIXTURES,\n",[80,775,776],{"class":82,"line":132},[80,777,778],{},"    ids=[p.stem for p in FIXTURES],       # one id per case, derived from the file\n",[80,780,781],{"class":82,"line":138},[80,782,153],{},[80,784,785],{"class":82,"line":144},[80,786,787],{},"def test_fixture_parses(fixture_path):\n",[80,789,790],{"class":82,"line":150},[80,791,792],{},"    assert parse(fixture_path.read_text()) is not None\n",[10,794,795,796,799],{},"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 ",[14,797,798],{},"--collect-only -q"," count assertion in CI catches the silent shrinkage before anyone trusts the green run.",[335,801,803,893],{"className":802},[338],[340,804,348,809,348,812,348,815,348,817,348,819,348,826,348,828,348,833,348,837,348,841,348,845,348,849,348,852,348,854,348,857,348,861,348,864,348,867,348,870,348,874,348,876,348,880,348,884,348,887,348,890],{"viewBox":805,"role":343,"ariaLabelledBy":806,"xmlns":347},"0 0 760 196",[807,808],"idconventions-t","idconventions-d",[350,810,811],{"id":807},"Three id conventions and what each protects",[354,813,814],{"id":808},"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.",[376,816],{"x":378,"y":378,"width":379,"height":605,"rx":381,"fill":382},[384,818,811],{"x":386,"y":387,"textAnchor":388,"fontSize":389,"fontWeight":390,"fill":391},[376,820],{"x":394,"y":821,"width":822,"height":823,"rx":398,"fill":382,"stroke":824,"strokeWidth":825},"58","221","124","#81b29a","2",[376,827],{"x":394,"y":821,"width":822,"height":387,"rx":398,"fill":391},[384,829,832],{"x":830,"y":831,"textAnchor":388,"fontSize":398,"fontWeight":390,"fill":382},"137","78","Name the scenario",[384,834,836],{"x":559,"y":835,"fontSize":410,"fill":391},"110","zero-amount, not amount-0",[384,838,840],{"x":559,"y":839,"fontSize":410,"fill":391},"130","survives payload changes",[384,842,844],{"x":559,"y":843,"fontSize":410,"fill":391},"150","keeps --last-failed valid",[384,846,848],{"x":559,"y":847,"fontSize":410,"fill":391},"170","reads as documentation",[376,850],{"x":851,"y":821,"width":822,"height":823,"rx":398,"fill":382,"stroke":374,"strokeWidth":825},"269",[376,853],{"x":851,"y":821,"width":822,"height":387,"rx":398,"fill":374},[384,855,856],{"x":386,"y":831,"textAnchor":388,"fontSize":398,"fontWeight":390,"fill":391},"Short kebab-case",[384,858,860],{"x":859,"y":835,"fontSize":410,"fill":391},"283","no quoting problems",[384,862,863],{"x":859,"y":839,"fontSize":410,"fill":391},"greppable in CI logs",[384,865,866],{"x":859,"y":843,"fontSize":410,"fill":391},"safe in JUnit XML",[384,868,869],{"x":859,"y":847,"fontSize":410,"fill":391},"ten to twenty chars",[376,871],{"x":872,"y":821,"width":822,"height":823,"rx":398,"fill":382,"stroke":873,"strokeWidth":825},"513","#e07a5f",[376,875],{"x":872,"y":821,"width":822,"height":387,"rx":398,"fill":391},[384,877,879],{"x":878,"y":831,"textAnchor":388,"fontSize":398,"fontWeight":390,"fill":382},"623","Name each axis",[384,881,883],{"x":882,"y":835,"fontSize":410,"fill":391},"527","ids compose as outer-inner",[384,885,886],{"x":882,"y":839,"fontSize":410,"fill":391},"combinations stay readable",[384,888,889],{"x":882,"y":843,"fontSize":410,"fill":391},"-k selects one exactly",[384,891,892],{"x":882,"y":847,"fontSize":410,"fill":391},"last decorator is first",[464,894,895],{},"Each convention prevents a specific failure: a rewrite, a quoting bug, or an unreadable combinatorial explosion.",[19,897,899],{"id":898},"edge-cases-and-failure-modes","Edge cases and failure modes",[24,901,902,920,926,934,940],{},[27,903,904,907,908,911,912,915,916,919],{},[30,905,906],{},"Duplicate ids are silently suffixed."," Two cases named ",[14,909,910],{},"valid"," become ",[14,913,914],{},"valid0"," and ",[14,917,918],{},"valid1",", which reintroduces the problem you were solving. Keep ids unique within a parametrize call.",[27,921,922,925],{},[30,923,924],{},"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.",[27,927,928,933],{},[30,929,515,930,932],{},[14,931,40],{}," 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.",[27,935,936,939],{},[30,937,938],{},"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.",[27,941,942,945,946,949,950,954],{},[30,943,944],{},"Indirect parameters are rendered from the raw value."," With ",[14,947,948],{},"indirect=True"," the test never sees the value, but the id still comes from it — see ",[50,951,953],{"href":952},"\u002Fadvanced-pytest-architecture-configuration\u002Fadvanced-parametrization-techniques\u002Findirect-parametrization-with-fixtures\u002F","indirect parametrization with fixtures"," for the interaction.",[10,956,957,958,960],{},"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, ",[14,959,525],{}," 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.",[19,962,964],{"id":963},"frequently-asked-questions","Frequently Asked Questions",[10,966,967,970,971,974,975,978],{},[30,968,969],{},"How do I stop pytest generating ids like case0, case1?","\nPass ",[14,972,973],{},"ids="," with a list or a callable, or wrap each case in ",[14,976,977],{},"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.",[10,980,981,984,985,988,989,991],{},[30,982,983],{},"Can I select a single parametrized case from the command line?","\nYes. Quote the full node id including the bracketed parameter, for example ",[14,986,987],{},"pytest 'tests\u002Ftest_api.py::test_get[staging-200]'",". With ",[14,990,518],{}," you can match on the id substring instead, which avoids shell quoting problems.",[10,993,994,997,998,1000,1001,1003],{},[30,995,996],{},"How do I set ids for every test in the project at once?","\nImplement ",[14,999,44],{}," in a ",[14,1002,273],{},". 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.",[19,1005,1007],{"id":1006},"related","Related",[24,1009,1010,1016,1022,1032],{},[27,1011,1012,1015],{},[50,1013,1014],{"href":52},"Advanced parametrization techniques"," — the generation mechanisms these ids label.",[27,1017,1018,1021],{},[50,1019,1020],{"href":952},"Indirect parametrization with fixtures"," — where ids come from the raw value the test never receives.",[27,1023,1024,1028,1029,1031],{},[50,1025,1027],{"href":1026},"\u002Fadvanced-pytest-architecture-configuration\u002Fpytest-configuration-best-practices\u002Fpytest-markers-for-conditional-test-execution\u002F","Pytest markers for conditional test execution"," — ",[14,1030,36],{}," also carries marks, one case at a time.",[27,1033,1034,1038],{},[50,1035,1037],{"href":1036},"\u002Fadvanced-pytest-architecture-configuration\u002Foptimizing-test-discovery\u002Fdebugging-flaky-tests-with-pytest-rerunfailures\u002F","Debugging flaky tests with pytest-rerunfailures"," — stable ids are what make a flake log attributable across runs.",[10,1040,1041,1042],{},"← Back to ",[50,1043,1044],{"href":52},"Advanced Parametrization Techniques",[1046,1047,1048],"style",{},"html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}",{"title":76,"searchDepth":89,"depth":89,"links":1050},[1051,1052,1053,1054,1055,1056,1057,1058],{"id":21,"depth":89,"text":22},{"id":57,"depth":89,"text":58},{"id":469,"depth":89,"text":470},{"id":486,"depth":89,"text":487},{"id":637,"depth":89,"text":638},{"id":898,"depth":89,"text":899},{"id":963,"depth":89,"text":964},{"id":1006,"depth":89,"text":1007},"Control pytest parametrize ids with ids=, pytest.param and pytest_make_parametrize_id so generated cases are selectable, greppable and readable in CI output.","md",{"slug":1062,"type":1063,"breadcrumb":1064,"datePublished":1065,"dateModified":1065,"faq":1066,"howto":1073},"generating-readable-test-ids","article","Readable Test IDs","2026-08-01",[1067,1069,1071],{"q":969,"a":1068},"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.",{"q":983,"a":1070},"Yes. Quote the full node id including the bracketed parameter, for example pytest 'tests\u002Ftest_api.py::test_get[staging-200]'. With -k you can match on the id substring instead, which avoids shell quoting problems.",{"q":996,"a":1072},"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.",{"name":1074,"description":1075,"steps":1076},"How to generate readable pytest test ids","Replace generated positional ids with names that identify each case in output and on the command line.",[1077,1080,1083,1086],{"name":1078,"text":1079},"Name individual cases with pytest.param","Wrap the values and pass id= so the case is labelled at the point it is defined.",{"name":1081,"text":1082},"Pass a callable to ids for computed names","The callable receives one value at a time and returns a string or None to fall back.",{"name":1084,"text":1085},"Add pytest_make_parametrize_id for project-wide rules","Implement the hook in conftest.py so every unnamed value is rendered the same way.",{"name":1087,"text":1088},"Verify with --collect-only -q","Read the generated node ids and confirm each one identifies its case unambiguously.","\u002Fadvanced-pytest-architecture-configuration\u002Fadvanced-parametrization-techniques\u002Fgenerating-readable-test-ids",{"title":5,"description":1059},"advanced-pytest-architecture-configuration\u002Fadvanced-parametrization-techniques\u002Fgenerating-readable-test-ids\u002Findex","CbvvtgzzWBeQ661y3D9D1630VgGubaJW6RZRGqMfg1o",1785613404346]