[{"data":1,"prerenderedAt":1022},["ShallowReactive",2],{"page-\u002Fadvanced-pytest-architecture-configuration\u002Fassertion-introspection-and-reporting\u002Fcustomizing-failure-output-with-assertrepr-compare\u002F":3},{"id":4,"title":5,"body":6,"description":985,"extension":986,"meta":987,"navigation":97,"path":1018,"seo":1019,"stem":1020,"__hash__":1021},"content\u002Fadvanced-pytest-architecture-configuration\u002Fassertion-introspection-and-reporting\u002Fcustomizing-failure-output-with-assertrepr-compare\u002Findex.md","Customizing Failure Output with assertrepr_compare",{"type":7,"value":8,"toc":973},"minimark",[9,34,39,65,69,204,212,327,331,348,355,359,406,410,413,524,530,537,648,652,655,658,664,678,685,786,790,793,804,808,814,850,865,884,888,900,909,932,936,964,969],[10,11,12,13,17,18,21,22,25,26,29,30,33],"p",{},"A failing comparison between two domain objects prints their ",[14,15,16],"code",{},"repr()"," on either side of ",[14,19,20],{},"==",". For a small dataclass that is fine. For an ",[14,23,24],{},"Invoice"," with fifteen fields, three nested line items and a ",[14,27,28],{},"Money"," value object, it is two walls of text in which the one differing field has to be found by eye. ",[14,31,32],{},"pytest_assertrepr_compare"," lets the suite replace that with a list of exactly what differed.",[35,36,38],"h2",{"id":37},"prerequisites","Prerequisites",[40,41,42,49,56],"ul",{},[43,44,45,48],"li",{},[14,46,47],{},"pytest >= 8.0","; the hook has existed for a long time and its signature is stable.",[43,50,51,52,55],{},"A root ",[14,53,54],{},"conftest.py",", or a plugin shipped with the package, to host it.",[43,57,58,59,64],{},"Familiarity with how assertion rewriting produces explanations, from ",[60,61,63],"a",{"href":62},"\u002Fadvanced-pytest-architecture-configuration\u002Fassertion-introspection-and-reporting\u002F","assertion introspection and test reporting",".",[35,66,68],{"id":67},"solution","Solution",[70,71,76],"pre",{"className":72,"code":73,"language":74,"meta":75,"style":75},"language-python shiki shiki-themes github-light github-dark","# conftest.py\nfrom dataclasses import fields, is_dataclass\n\nfrom myapp.billing import Invoice\n\n\ndef pytest_assertrepr_compare(config, op, left, right):\n    # Narrow: only == between two Invoices. Everything else keeps pytest's\n    # own explanation, including its dict\u002Flist\u002Fstr diffs.\n    if op != \"==\" or not (isinstance(left, Invoice) and isinstance(right, Invoice)):\n        return None\n\n    verbose = config.getoption(\"verbose\") > 0\n    lines = [f\"Invoice {left.id!r} differs from {right.id!r}:\"]\n    for field in fields(Invoice):\n        a, b = getattr(left, field.name), getattr(right, field.name)\n        if a != b:\n            lines.append(f\"  {field.name}: {a!r} != {b!r}\")\n        elif verbose:\n            lines.append(f\"  {field.name}: {a!r} (same)\")\n    return lines\n","python","",[14,77,78,86,92,99,105,110,115,121,127,133,139,145,150,156,162,168,174,180,186,192,198],{"__ignoreMap":75},[79,80,83],"span",{"class":81,"line":82},"line",1,[79,84,85],{},"# conftest.py\n",[79,87,89],{"class":81,"line":88},2,[79,90,91],{},"from dataclasses import fields, is_dataclass\n",[79,93,95],{"class":81,"line":94},3,[79,96,98],{"emptyLinePlaceholder":97},true,"\n",[79,100,102],{"class":81,"line":101},4,[79,103,104],{},"from myapp.billing import Invoice\n",[79,106,108],{"class":81,"line":107},5,[79,109,98],{"emptyLinePlaceholder":97},[79,111,113],{"class":81,"line":112},6,[79,114,98],{"emptyLinePlaceholder":97},[79,116,118],{"class":81,"line":117},7,[79,119,120],{},"def pytest_assertrepr_compare(config, op, left, right):\n",[79,122,124],{"class":81,"line":123},8,[79,125,126],{},"    # Narrow: only == between two Invoices. Everything else keeps pytest's\n",[79,128,130],{"class":81,"line":129},9,[79,131,132],{},"    # own explanation, including its dict\u002Flist\u002Fstr diffs.\n",[79,134,136],{"class":81,"line":135},10,[79,137,138],{},"    if op != \"==\" or not (isinstance(left, Invoice) and isinstance(right, Invoice)):\n",[79,140,142],{"class":81,"line":141},11,[79,143,144],{},"        return None\n",[79,146,148],{"class":81,"line":147},12,[79,149,98],{"emptyLinePlaceholder":97},[79,151,153],{"class":81,"line":152},13,[79,154,155],{},"    verbose = config.getoption(\"verbose\") > 0\n",[79,157,159],{"class":81,"line":158},14,[79,160,161],{},"    lines = [f\"Invoice {left.id!r} differs from {right.id!r}:\"]\n",[79,163,165],{"class":81,"line":164},15,[79,166,167],{},"    for field in fields(Invoice):\n",[79,169,171],{"class":81,"line":170},16,[79,172,173],{},"        a, b = getattr(left, field.name), getattr(right, field.name)\n",[79,175,177],{"class":81,"line":176},17,[79,178,179],{},"        if a != b:\n",[79,181,183],{"class":81,"line":182},18,[79,184,185],{},"            lines.append(f\"  {field.name}: {a!r} != {b!r}\")\n",[79,187,189],{"class":81,"line":188},19,[79,190,191],{},"        elif verbose:\n",[79,193,195],{"class":81,"line":194},20,[79,196,197],{},"            lines.append(f\"  {field.name}: {a!r} (same)\")\n",[79,199,201],{"class":81,"line":200},21,[79,202,203],{},"    return lines\n",[70,205,210],{"className":206,"code":208,"language":209,"meta":75},[207],"language-text","E   AssertionError: assert Invoice(id='inv_1', …) == Invoice(id='inv_1', …)\nE     Invoice 'inv_1' differs from 'inv_1':\nE       total_minor: 1230 != 1234\nE       status: 'open' != 'paid'\n","text",[14,211,208],{"__ignoreMap":75},[213,214,217,323],"figure",{"className":215},[216],"diagram",[218,219,226,227,226,231,226,235,226,243,226,252,226,262,226,268,226,275,226,279,226,283,226,287,226,291,226,295,226,301,226,306,226,310,226,316,226,319],"svg",{"viewBox":220,"role":221,"ariaLabelledBy":222,"xmlns":225},"0 0 820 262","img",[223,224],"arc-t","arc-d","http:\u002F\u002Fwww.w3.org\u002F2000\u002Fsvg","\n  ",[228,229,230],"title",{"id":223},"Default repr output versus a field-level diff",[232,233,234],"desc",{"id":224},"Two failure reports for the same comparison. The default prints both objects' full representations, fifteen fields each, with the two differing fields buried among thirteen identical ones. The custom hook prints only the two differing fields with both values, so the cause is visible immediately.",[236,237],"rect",{"x":238,"y":238,"width":239,"height":240,"rx":241,"fill":242},"0","820","262","14","#fffdf8",[209,244,251],{"x":245,"y":246,"textAnchor":247,"fontSize":248,"fontWeight":249,"fill":250},"410","28","middle","16","700","#3d405b","Same failure, very different time to diagnosis",[236,253],{"x":254,"y":255,"width":256,"height":257,"rx":258,"fill":259,"stroke":260,"strokeWidth":261},"26","52","368","186","12","#fbe9e3","#e07a5f","2",[209,263,267],{"x":264,"y":265,"textAnchor":247,"fontSize":266,"fontWeight":249,"fill":250},"210","78","12.5","default: two reprs",[236,269],{"x":270,"y":271,"width":272,"height":258,"rx":273,"fill":274},"46","92","328","3","rgba(61,64,91,0.14)",[236,276],{"x":270,"y":277,"width":278,"height":258,"rx":273,"fill":274},"110","310",[236,280],{"x":270,"y":281,"width":282,"height":258,"rx":273,"fill":260},"128","200",[236,284],{"x":270,"y":285,"width":286,"height":258,"rx":273,"fill":274},"146","320",[236,288],{"x":270,"y":289,"width":290,"height":258,"rx":273,"fill":274},"164","290",[236,292],{"x":270,"y":293,"width":294,"height":258,"rx":273,"fill":260},"182","170",[209,296,300],{"x":264,"y":297,"textAnchor":247,"fontSize":298,"fill":299},"222","11","#8f3d22","2 differences among 30 printed fields",[236,302],{"x":303,"y":255,"width":256,"height":257,"rx":258,"fill":304,"stroke":305,"strokeWidth":261},"426","#e6f0ea","#81b29a",[209,307,309],{"x":308,"y":265,"textAnchor":247,"fontSize":266,"fontWeight":249,"fill":250},"610","hook: only what differs",[209,311,315],{"x":312,"y":313,"fontSize":314,"fill":250},"446","118","11.5","total_minor: 1230 != 1234",[209,317,318],{"x":312,"y":285,"fontSize":314,"fill":250},"status: 'open' != 'paid'",[209,320,322],{"x":308,"y":297,"textAnchor":247,"fontSize":298,"fill":321},"#2a5f49","2 lines, both relevant",[324,325,326],"figcaption",{},"The hook changes nothing about whether the test fails. It changes how long it takes a person to see why.",[35,328,330],{"id":329},"why-this-works","Why this works",[10,332,333,334,337,338,340,341,344,345,347],{},"When a rewritten ",[14,335,336],{},"assert left == right"," fails, pytest calls every registered ",[14,339,32],{}," implementation with the operator and both operands, and uses the first non-",[14,342,343],{},"None"," result as the explanation. Returning ",[14,346,343],{}," is therefore not just polite — it is the mechanism by which your hook coexists with pytest's own implementation, which handles dicts, sequences, sets, strings and dataclasses with a built-in diff.",[10,349,350,351,354],{},"The hook runs only on failure, so it adds no cost to passing tests, and it runs inside pytest's reporting, so its output appears in the terminal, in ",[14,352,353],{},"--tb"," styles, and in JUnit XML without further wiring.",[35,356,358],{"id":357},"edge-cases-and-failure-modes","Edge cases and failure modes",[40,360,361,368,374,388,398],{},[43,362,363,367],{},[364,365,366],"strong",{},"Returning a list unconditionally."," Every assertion in the suite loses pytest's default explanation. Guard on the operator and the types.",[43,369,370,373],{},[364,371,372],{},"An exception inside the hook."," pytest reports it as an internal error during failure reporting, which obscures the original failure. Keep the hook simple and defensive.",[43,375,376,379,380,383,384,387],{},[364,377,378],{},"Subclasses."," ",[14,381,382],{},"isinstance"," matches subclasses, which is usually right; if a subclass has extra fields, iterate over ",[14,385,386],{},"fields(type(left))"," rather than the base class.",[43,389,390,397],{},[364,391,392,393,396],{},"Expensive ",[14,394,395],{},"repr"," calls."," Formatting a field that is itself a large object reintroduces the wall of text. Truncate or summarise nested collections.",[43,399,400,405],{},[364,401,402,403,64],{},"The hook in a subdirectory ",[14,404,54],{}," It applies only beneath that directory, so the same comparison elsewhere falls back to the default. Place it at the root or in a plugin.",[35,407,409],{"id":408},"handling-nested-values-without-flooding-the-output","Handling nested values without flooding the output",[10,411,412],{},"A field-level diff is only readable while each field's value is short. The moment a differing field is itself a list of line items or a nested object, printing both values reintroduces the wall of text one level down. Recursing selectively keeps the output proportionate to the difference.",[70,414,416],{"className":72,"code":415,"language":74,"meta":75,"style":75},"from dataclasses import fields, is_dataclass\n\n\ndef _diff(path: str, a, b, out: list[str], depth: int = 0) -> None:\n    if a == b:\n        return\n    if is_dataclass(a) and type(a) is type(b) and depth \u003C 3:\n        for field in fields(a):\n            _diff(f\"{path}.{field.name}\", getattr(a, field.name),\n                  getattr(b, field.name), out, depth + 1)\n    elif isinstance(a, (list, tuple)) and isinstance(b, (list, tuple)) and depth \u003C 3:\n        if len(a) != len(b):\n            out.append(f\"  {path}: length {len(a)} != {len(b)}\")\n        for i, (x, y) in enumerate(zip(a, b)):\n            _diff(f\"{path}[{i}]\", x, y, out, depth + 1)\n    else:\n        out.append(f\"  {path}: {_short(a)} != {_short(b)}\")\n\n\ndef _short(value, limit: int = 80) -> str:\n    text = repr(value)\n    return text if len(text) \u003C= limit else text[: limit - 1] + \"…\"\n",[14,417,418,422,426,430,435,440,445,450,455,460,465,470,475,480,485,490,495,500,504,508,513,518],{"__ignoreMap":75},[79,419,420],{"class":81,"line":82},[79,421,91],{},[79,423,424],{"class":81,"line":88},[79,425,98],{"emptyLinePlaceholder":97},[79,427,428],{"class":81,"line":94},[79,429,98],{"emptyLinePlaceholder":97},[79,431,432],{"class":81,"line":101},[79,433,434],{},"def _diff(path: str, a, b, out: list[str], depth: int = 0) -> None:\n",[79,436,437],{"class":81,"line":107},[79,438,439],{},"    if a == b:\n",[79,441,442],{"class":81,"line":112},[79,443,444],{},"        return\n",[79,446,447],{"class":81,"line":117},[79,448,449],{},"    if is_dataclass(a) and type(a) is type(b) and depth \u003C 3:\n",[79,451,452],{"class":81,"line":123},[79,453,454],{},"        for field in fields(a):\n",[79,456,457],{"class":81,"line":129},[79,458,459],{},"            _diff(f\"{path}.{field.name}\", getattr(a, field.name),\n",[79,461,462],{"class":81,"line":135},[79,463,464],{},"                  getattr(b, field.name), out, depth + 1)\n",[79,466,467],{"class":81,"line":141},[79,468,469],{},"    elif isinstance(a, (list, tuple)) and isinstance(b, (list, tuple)) and depth \u003C 3:\n",[79,471,472],{"class":81,"line":147},[79,473,474],{},"        if len(a) != len(b):\n",[79,476,477],{"class":81,"line":152},[79,478,479],{},"            out.append(f\"  {path}: length {len(a)} != {len(b)}\")\n",[79,481,482],{"class":81,"line":158},[79,483,484],{},"        for i, (x, y) in enumerate(zip(a, b)):\n",[79,486,487],{"class":81,"line":164},[79,488,489],{},"            _diff(f\"{path}[{i}]\", x, y, out, depth + 1)\n",[79,491,492],{"class":81,"line":170},[79,493,494],{},"    else:\n",[79,496,497],{"class":81,"line":176},[79,498,499],{},"        out.append(f\"  {path}: {_short(a)} != {_short(b)}\")\n",[79,501,502],{"class":81,"line":182},[79,503,98],{"emptyLinePlaceholder":97},[79,505,506],{"class":81,"line":188},[79,507,98],{"emptyLinePlaceholder":97},[79,509,510],{"class":81,"line":194},[79,511,512],{},"def _short(value, limit: int = 80) -> str:\n",[79,514,515],{"class":81,"line":200},[79,516,517],{},"    text = repr(value)\n",[79,519,521],{"class":81,"line":520},22,[79,522,523],{},"    return text if len(text) \u003C= limit else text[: limit - 1] + \"…\"\n",[70,525,528],{"className":526,"code":527,"language":209,"meta":75},[207],"E     Invoice 'inv_1' differs from 'inv_1':\nE       .lines[2].unit_minor: 250 != 275\nE       .lines: length 3 != 4\n",[14,529,527],{"__ignoreMap":75},[10,531,532,533,536],{},"Paths such as ",[14,534,535],{},".lines[2].unit_minor"," locate the difference precisely, and the depth limit plus truncation guarantee the output stays short even for pathological objects.",[213,538,540,645],{"className":539},[216],[218,541,226,546,226,549,226,552,226,569,226,573,226,578,226,587,226,591,226,598,226,601,226,605,226,609,226,614,226,618,226,622,226,625,226,630,226,634,226,640],{"viewBox":542,"role":221,"ariaLabelledBy":543,"xmlns":225},"0 0 800 234",[544,545],"nest2-t","nest2-d",[228,547,548],{"id":544},"Recursing into nested values to locate the difference",[232,550,551],{"id":545},"An invoice contains a list of three line items. The recursive diff walks from the invoice into the lines list and into the third item, reporting a single path, lines index two unit_minor, with both values. A depth limit and truncation keep the output short for deeply nested or very large values.",[553,554,555,556,226],"defs",{},"\n    ",[557,558,565],"marker",{"id":559,"viewBox":560,"refX":561,"refY":562,"markerWidth":563,"markerHeight":563,"orient":564},"nest2-a","0 0 10 10","9","5","7","auto-start-reverse",[566,567],"path",{"d":568,"fill":305},"M0 0 L10 5 L0 10 z",[236,570],{"x":238,"y":238,"width":571,"height":572,"rx":241,"fill":242},"800","234",[209,574,577],{"x":575,"y":246,"textAnchor":247,"fontSize":576,"fontWeight":249,"fill":250},"400","15.5","Report a path, not two trees",[236,579],{"x":580,"y":581,"width":582,"height":583,"rx":584,"fill":585,"stroke":250,"strokeWidth":586},"34","84","150","56","10","#f4f1de","1.6",[209,588,24],{"x":589,"y":590,"textAnchor":247,"fontSize":258,"fontWeight":249,"fill":250},"109","117",[81,592],{"x1":593,"y1":594,"x2":595,"y2":594,"stroke":305,"strokeWidth":596,"markerEnd":597},"188","112","226","1.8","url(#nest2-a)",[236,599],{"x":600,"y":581,"width":582,"height":583,"rx":584,"fill":585,"stroke":250,"strokeWidth":586},"232",[209,602,604],{"x":603,"y":590,"textAnchor":247,"fontSize":258,"fontWeight":249,"fill":250},"307",".lines",[81,606],{"x1":607,"y1":594,"x2":608,"y2":594,"stroke":305,"strokeWidth":596,"markerEnd":597},"386","424",[236,610],{"x":611,"y":581,"width":582,"height":583,"rx":584,"fill":612,"stroke":613,"strokeWidth":261},"430","#f7f0da","#f2cc8f",[209,615,617],{"x":616,"y":590,"textAnchor":247,"fontSize":258,"fontWeight":249,"fill":250},"505","[2]",[81,619],{"x1":620,"y1":594,"x2":621,"y2":594,"stroke":305,"strokeWidth":596,"markerEnd":597},"584","622",[236,623],{"x":624,"y":581,"width":285,"height":583,"rx":584,"fill":259,"stroke":260,"strokeWidth":261},"628",[209,626,629],{"x":627,"y":628,"textAnchor":247,"fontSize":314,"fontWeight":249,"fill":250},"701","108",".unit_minor",[209,631,633],{"x":627,"y":632,"textAnchor":247,"fontSize":298,"fill":299},"126","250 != 275",[236,635],{"x":580,"y":294,"width":636,"height":637,"rx":584,"fill":242,"stroke":638,"strokeWidth":639},"740","44","rgba(61,64,91,0.35)","1.5",[209,641,644],{"x":642,"y":643,"textAnchor":247,"fontSize":314,"fill":250},"404","197","Depth limit 3, values truncated at 80 characters — short output whatever the object size.",[324,646,647],{},"The path doubles as a navigation aid: it tells the reader which part of the fixture or factory call to look at.",[35,649,651],{"id":650},"deciding-which-types-deserve-a-hook","Deciding which types deserve a hook",[10,653,654],{},"Not every class needs custom comparison output, and adding hooks indiscriminately creates a maintenance burden of its own. Three questions identify the types where the effort pays back.",[10,656,657],{},"The first is how often the type appears in assertions. A value object compared in two hundred tests — money, an address, a date range — repays a hook many times over, because every one of those failures becomes readable. A class compared in three tests does not justify the code.",[10,659,660,661,663],{},"The second is how large its default representation is. Dataclasses with a handful of short fields already produce readable failures, and pytest's built-in dataclass diff handles them well since version 5. The case for a hook grows with the number of fields, the depth of nesting, and the presence of fields whose ",[14,662,395],{}," is long — timestamps with microseconds, UUIDs, nested collections.",[10,665,666,667,669,670,673,674,677],{},"The third is whether the type has a notion of equality that differs from field-by-field comparison. A ",[14,668,28],{}," type that considers ",[14,671,672],{},"Money(100, \"GBP\")"," equal to ",[14,675,676],{},"Money(1.00, \"GBP\", unit=\"major\")"," needs a hook that explains the comparison in its own terms, because a field-level diff would show differences where the type sees none.",[10,679,680,681,684],{},"When the answer to any of the three is strongly yes, the hook is worth writing. When all three are weak, a good ",[14,682,683],{},"__repr__"," on the class delivers most of the benefit with none of the machinery — and improves every log line, debugger session and traceback that mentions the object, not just test failures.",[213,686,688,780],{"className":687},[216],[218,689,226,694,226,697,226,700,226,703,226,706,226,712,226,715,226,720,226,725,226,729,226,732,226,736,226,739,226,741,226,744,226,748,226,751,226,755,226,758,226,761,226,763,226,767,226,771,226,774,226,777],{"viewBox":690,"role":221,"ariaLabelledBy":691,"xmlns":225},"0 0 800 236",[692,693],"wh-t","wh-d",[228,695,696],{"id":692},"Three signals that a type deserves a comparison hook",[232,698,699],{"id":693},"Three cards. A type compared in many tests repays a hook many times over. A type with a large or deeply nested representation produces unreadable default failures. A type with its own notion of equality needs a hook that explains comparison in its own terms. When none apply, a good repr is the cheaper improvement.",[236,701],{"x":238,"y":238,"width":571,"height":702,"rx":241,"fill":242},"236",[209,704,705],{"x":575,"y":246,"textAnchor":247,"fontSize":576,"fontWeight":249,"fill":250},"Write the hook where one of these is strongly true",[236,707],{"x":708,"y":709,"width":710,"height":711,"rx":258,"fill":242,"stroke":305,"strokeWidth":261},"24","50","240","160",[236,713],{"x":708,"y":709,"width":710,"height":714,"rx":258,"fill":250},"30",[209,716,719],{"x":717,"y":718,"textAnchor":247,"fontSize":258,"fontWeight":249,"fill":242},"144","70","compared often",[209,721,724],{"x":722,"y":723,"fontSize":298,"fill":250},"40","104","money, addresses,",[209,726,728],{"x":722,"y":727,"fontSize":298,"fill":250},"124","date ranges",[209,730,731],{"x":722,"y":711,"fontSize":298,"fontWeight":249,"fill":321},"hundreds of failures",[209,733,735],{"x":722,"y":734,"fontSize":298,"fill":250},"180","made readable at once",[236,737],{"x":738,"y":709,"width":710,"height":711,"rx":258,"fill":242,"stroke":613,"strokeWidth":261},"280",[236,740],{"x":738,"y":709,"width":710,"height":714,"rx":258,"fill":250},[209,742,743],{"x":575,"y":718,"textAnchor":247,"fontSize":258,"fontWeight":249,"fill":242},"large repr",[209,745,747],{"x":746,"y":723,"fontSize":298,"fill":250},"296","many fields, nesting,",[209,749,750],{"x":746,"y":727,"fontSize":298,"fill":250},"long timestamps",[209,752,754],{"x":746,"y":711,"fontSize":298,"fontWeight":249,"fill":753},"#8a5a00","the diff is the only",[209,756,757],{"x":746,"y":734,"fontSize":298,"fill":250},"way to find the field",[236,759],{"x":760,"y":709,"width":710,"height":711,"rx":258,"fill":242,"stroke":260,"strokeWidth":261},"536",[236,762],{"x":760,"y":709,"width":710,"height":714,"rx":258,"fill":250},[209,764,766],{"x":765,"y":718,"textAnchor":247,"fontSize":258,"fontWeight":249,"fill":242},"656","custom equality",[209,768,770],{"x":769,"y":723,"fontSize":298,"fill":250},"552","equal despite differing",[209,772,773],{"x":769,"y":727,"fontSize":298,"fill":250},"fields, or the reverse",[209,775,776],{"x":769,"y":711,"fontSize":298,"fontWeight":249,"fill":299},"explain in the type's",[209,778,779],{"x":769,"y":734,"fontSize":298,"fill":250},"own terms",[324,781,782,783,785],{},"Where none of the three applies, invest in ",[14,784,683],{}," instead; it improves logs and tracebacks as well as test failures.",[35,787,789],{"id":788},"testing-the-hook-itself","Testing the hook itself",[10,791,792],{},"A comparison hook is code that runs only when something else has already failed, which makes it easy to break without noticing. A broken hook is worse than none: pytest reports an internal error during failure reporting, and the original assertion's explanation is lost at exactly the moment someone needed it.",[10,794,795,796,799,800,64],{},"The ",[14,797,798],{},"pytester"," fixture makes the hook testable in the ordinary way. Write a small test file that performs a failing comparison, run it in a subprocess-like sandbox, and assert on the lines the hook should have produced. Two cases are worth covering: a comparison the hook handles, whose output should contain the field names and values, and a comparison it does not handle — two plain dictionaries, say — whose output should still contain pytest's own diff. The second case is the one that catches a hook accidentally returning a list for everything, and it is the regression most likely to slip through review because nothing about it looks wrong in the hook's own code. Both checks together take a few seconds to run and are covered in detail in ",[60,801,803],{"href":802},"\u002Fadvanced-pytest-architecture-configuration\u002Fbuilding-custom-pytest-plugins\u002Ftesting-a-pytest-plugin-with-the-pytester-fixture\u002F","testing a pytest plugin with the pytester fixture",[35,805,807],{"id":806},"shipping-comparison-output-with-a-library","Shipping comparison output with a library",[10,809,810,811,813],{},"When the domain types live in a package other teams depend on, their tests benefit from the same output — and they should not have to copy the hook into their own ",[14,812,54],{},". A pytest plugin registered through an entry point delivers it automatically to anyone who installs the package with its testing extra.",[70,815,819],{"className":816,"code":817,"language":818,"meta":75,"style":75},"language-toml shiki shiki-themes github-light github-dark","# pyproject.toml of the library\n[project.optional-dependencies]\ntesting = [\"pytest>=8\"]\n\n[project.entry-points.pytest11]\nmyapp_compare = \"myapp.testing.pytest_plugin\"\n","toml",[14,820,821,826,831,836,840,845],{"__ignoreMap":75},[79,822,823],{"class":81,"line":82},[79,824,825],{},"# pyproject.toml of the library\n",[79,827,828],{"class":81,"line":88},[79,829,830],{},"[project.optional-dependencies]\n",[79,832,833],{"class":81,"line":94},[79,834,835],{},"testing = [\"pytest>=8\"]\n",[79,837,838],{"class":81,"line":101},[79,839,98],{"emptyLinePlaceholder":97},[79,841,842],{"class":81,"line":107},[79,843,844],{},"[project.entry-points.pytest11]\n",[79,846,847],{"class":81,"line":112},[79,848,849],{},"myapp_compare = \"myapp.testing.pytest_plugin\"\n",[70,851,853],{"className":72,"code":852,"language":74,"meta":75,"style":75},"# myapp\u002Ftesting\u002Fpytest_plugin.py\nfrom myapp.testing.compare import pytest_assertrepr_compare  # noqa: F401  (re-export)\n",[14,854,855,860],{"__ignoreMap":75},[79,856,857],{"class":81,"line":82},[79,858,859],{},"# myapp\u002Ftesting\u002Fpytest_plugin.py\n",[79,861,862],{"class":81,"line":88},[79,863,864],{},"from myapp.testing.compare import pytest_assertrepr_compare  # noqa: F401  (re-export)\n",[10,866,867,868,871,872,874,875,878,879,883],{},"Every consumer who installs ",[14,869,870],{},"myapp[testing]"," now gets field-level diffs for ",[14,873,24],{}," comparisons with no configuration, and improvements to the diff ship with the library's normal releases. The mechanics of entry-point registration, including how to disable a plugin with ",[14,876,877],{},"-p no:myapp_compare",", are covered in ",[60,880,882],{"href":881},"\u002Fadvanced-pytest-architecture-configuration\u002Fbuilding-custom-pytest-plugins\u002Fpackaging-a-pytest-plugin-with-entry-points\u002F","packaging a pytest plugin with entry points",". It is a small addition with an outsized effect on how pleasant a domain library is to test against, and one of the clearest signals that its authors expect it to be used in tested code.",[35,885,887],{"id":886},"frequently-asked-questions","Frequently Asked Questions",[10,889,890,893,894,896,897,899],{},[364,891,892],{},"Where does pytest_assertrepr_compare have to live?","\nIn a ",[14,895,54],{}," or a registered plugin, because it is a hook. A ",[14,898,54],{}," in a subdirectory applies only to tests collected beneath it, so domain-wide comparison output belongs in the root conftest or in a plugin the package ships.",[10,901,902,905,906,908],{},[364,903,904],{},"What happens if the hook returns a list for every comparison?","\nIt replaces pytest's own explanation for every assertion, including the excellent built-in diffs for dicts, lists, sets and strings. Always return ",[14,907,343],{}," for comparisons you do not specifically handle.",[10,910,911,914,915,918,919,918,922,925,926,928,929,931],{},[364,912,913],{},"Can I use it for operators other than ==?","\nYes. The hook receives the operator as a string, so it can explain ",[14,916,917],{},"!=",", ",[14,920,921],{},"\u003C",[14,923,924],{},"in"," and others. In practice ",[14,927,20],{}," covers nearly all the value, and a comparison hook for ",[14,930,921],{}," on a domain type is usually a sign the type should define its own ordering explanation.",[35,933,935],{"id":934},"related","Related",[40,937,938,944,951,958],{},[43,939,940,943],{},[60,941,942],{"href":62},"Assertion Introspection & Test Reporting"," — how rewriting produces the explanation this hook customises.",[43,945,946,950],{},[60,947,949],{"href":948},"\u002Fadvanced-pytest-architecture-configuration\u002Fassertion-introspection-and-reporting\u002Fproducing-junit-xml-reports-for-ci-dashboards\u002F","Producing JUnit XML Reports for CI Dashboards"," — where the custom output ends up in CI.",[43,952,953,957],{},[60,954,956],{"href":955},"\u002Fadvanced-pytest-architecture-configuration\u002Fbuilding-custom-pytest-plugins\u002Fwriting-a-hookwrapper-for-test-reports\u002F","Writing a Hookwrapper for Test Reports"," — the other reporting hook worth knowing.",[43,959,960,963],{},[60,961,962],{"href":881},"Packaging a pytest Plugin with Entry Points"," — shipping this hook with a library.",[10,965,966,967],{},"← Back to ",[60,968,942],{"href":62},[970,971,972],"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":75,"searchDepth":88,"depth":88,"links":974},[975,976,977,978,979,980,981,982,983,984],{"id":37,"depth":88,"text":38},{"id":67,"depth":88,"text":68},{"id":329,"depth":88,"text":330},{"id":357,"depth":88,"text":358},{"id":408,"depth":88,"text":409},{"id":650,"depth":88,"text":651},{"id":788,"depth":88,"text":789},{"id":806,"depth":88,"text":807},{"id":886,"depth":88,"text":887},{"id":934,"depth":88,"text":935},"Implement pytest_assertrepr_compare so domain objects fail with a readable field-level diff instead of two opaque reprs, while leaving pytest's built-in diffs intact.","md",{"slug":988,"type":989,"breadcrumb":990,"datePublished":991,"dateModified":991,"faq":992,"howto":999},"customizing-failure-output-with-assertrepr-compare","article","assertrepr_compare","2026-09-18",[993,995,997],{"q":892,"a":994},"In a conftest.py or a registered plugin, because it is a hook. A conftest.py in a subdirectory applies only to tests collected beneath it, so domain-wide comparison output belongs in the root conftest or in a plugin the package ships.",{"q":904,"a":996},"It replaces pytest's own explanation for every assertion, including the excellent built-in diffs for dicts, lists, sets and strings. Always return None for comparisons you do not specifically handle.",{"q":913,"a":998},"Yes. The hook receives the operator as a string, so it can explain !=, \u003C, in and others. In practice == covers nearly all the value, and a comparison hook for \u003C on a domain type is usually a sign the type should define its own ordering explanation.",{"name":1000,"description":1001,"steps":1002},"How to customise pytest's failure output for a domain type","Implement the comparison hook narrowly, report only the differing fields, and fall back to pytest's defaults for everything else.",[1003,1006,1009,1012,1015],{"name":1004,"text":1005},"Implement the hook in the root conftest","Define pytest_assertrepr_compare(config, op, left, right) where it applies to every test that compares the type.",{"name":1007,"text":1008},"Handle one operator and one type","Return None unless op is == and both operands are instances of the domain type.",{"name":1010,"text":1011},"Report only the differences","Build lines naming each field that differs, with both values, and skip the fields that match.",{"name":1013,"text":1014},"Respect verbosity","Read config.getoption('verbose') and include matching fields only when the user asked for more detail.",{"name":1016,"text":1017},"Check the output once","Write a deliberately failing comparison and read the result before relying on it.","\u002Fadvanced-pytest-architecture-configuration\u002Fassertion-introspection-and-reporting\u002Fcustomizing-failure-output-with-assertrepr-compare",{"title":5,"description":985},"advanced-pytest-architecture-configuration\u002Fassertion-introspection-and-reporting\u002Fcustomizing-failure-output-with-assertrepr-compare\u002Findex","Q01MCpwlB7xVW41ag0VC7pR76nTyHpVVUWeUC0uhwfM",1789718768798]