[{"data":1,"prerenderedAt":1147},["ShallowReactive",2],{"page-\u002Fadvanced-pytest-architecture-configuration\u002Fbuilding-custom-pytest-plugins\u002Fwriting-a-hookwrapper-for-test-reports\u002F":3},{"id":4,"title":5,"body":6,"description":1113,"extension":1114,"meta":1115,"navigation":90,"path":1143,"seo":1144,"stem":1145,"__hash__":1146},"content\u002Fadvanced-pytest-architecture-configuration\u002Fbuilding-custom-pytest-plugins\u002Fwriting-a-hookwrapper-for-test-reports\u002Findex.md","Writing a pytest Hookwrapper for Test Reports",{"type":7,"value":8,"toc":1103},"minimark",[9,18,23,55,59,62,180,195,198,256,425,429,444,521,525,528,531,612,623,626,714,718,737,782,800,803,810,832,842,939,943,1004,1010,1027,1031,1037,1048,1060,1064,1093,1099],[10,11,12,13,17],"p",{},"Standard pytest reporting tells you a test failed and prints its traceback. It does not tell you which feature flag was set, which database the fixture provisioned, or how much memory the process had grown by — and by the time teardown runs, the report has already been rendered. A hookwrapper around ",[14,15,16],"code",{},"pytest_runtest_makereport"," is the supported way to intercept every report before it is emitted, enrich it, and make the outcome visible to fixtures that need to react to failure.",[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+"," for the ",[14,35,36],{},"hookimpl(hookwrapper=True)"," form used here (pytest 8 also accepts the newer ",[14,39,40],{},"wrapper=True"," style with a ",[14,43,44],{},"return"," instead of a post-yield block).",[27,47,48,49,54],{},"Plugin structure and registration from ",[50,51,53],"a",{"href":52},"\u002Fadvanced-pytest-architecture-configuration\u002Fbuilding-custom-pytest-plugins\u002F","building custom pytest plugins",".",[19,56,58],{"id":57},"solution","Solution",[10,60,61],{},"The wrapper yields exactly once. Everything before the yield runs before the other implementations, and everything after it sees their result.",[63,64,69],"pre",{"className":65,"code":66,"language":67,"meta":68,"style":68},"language-python shiki shiki-themes github-light github-dark","# conftest.py or a plugin module\nimport pytest\n\n@pytest.hookimpl(hookwrapper=True)\ndef pytest_runtest_makereport(item, call):\n    outcome = yield                       # let pytest build the report first\n    report = outcome.get_result()         # TestReport for ONE phase\n\n    # Make the outcome of each phase reachable from the item, so fixtures can see it.\n    setattr(item, f\"rep_{report.when}\", report)\n\n    if report.when == \"call\" and report.failed:\n        # Enrich the report with context nobody would otherwise capture.\n        report.sections.append((\n            \"captured context\",\n            f\"markers={[m.name for m in item.iter_markers()]}\\n\"\n            f\"fixtures={sorted(item.fixturenames)}\",\n        ))\n","python","",[14,70,71,79,85,92,98,104,110,116,121,127,133,138,144,150,156,162,168,174],{"__ignoreMap":68},[72,73,76],"span",{"class":74,"line":75},"line",1,[72,77,78],{},"# conftest.py or a plugin module\n",[72,80,82],{"class":74,"line":81},2,[72,83,84],{},"import pytest\n",[72,86,88],{"class":74,"line":87},3,[72,89,91],{"emptyLinePlaceholder":90},true,"\n",[72,93,95],{"class":74,"line":94},4,[72,96,97],{},"@pytest.hookimpl(hookwrapper=True)\n",[72,99,101],{"class":74,"line":100},5,[72,102,103],{},"def pytest_runtest_makereport(item, call):\n",[72,105,107],{"class":74,"line":106},6,[72,108,109],{},"    outcome = yield                       # let pytest build the report first\n",[72,111,113],{"class":74,"line":112},7,[72,114,115],{},"    report = outcome.get_result()         # TestReport for ONE phase\n",[72,117,119],{"class":74,"line":118},8,[72,120,91],{"emptyLinePlaceholder":90},[72,122,124],{"class":74,"line":123},9,[72,125,126],{},"    # Make the outcome of each phase reachable from the item, so fixtures can see it.\n",[72,128,130],{"class":74,"line":129},10,[72,131,132],{},"    setattr(item, f\"rep_{report.when}\", report)\n",[72,134,136],{"class":74,"line":135},11,[72,137,91],{"emptyLinePlaceholder":90},[72,139,141],{"class":74,"line":140},12,[72,142,143],{},"    if report.when == \"call\" and report.failed:\n",[72,145,147],{"class":74,"line":146},13,[72,148,149],{},"        # Enrich the report with context nobody would otherwise capture.\n",[72,151,153],{"class":74,"line":152},14,[72,154,155],{},"        report.sections.append((\n",[72,157,159],{"class":74,"line":158},15,[72,160,161],{},"            \"captured context\",\n",[72,163,165],{"class":74,"line":164},16,[72,166,167],{},"            f\"markers={[m.name for m in item.iter_markers()]}\\n\"\n",[72,169,171],{"class":74,"line":170},17,[72,172,173],{},"            f\"fixtures={sorted(item.fixturenames)}\",\n",[72,175,177],{"class":74,"line":176},18,[72,178,179],{},"        ))\n",[10,181,182,183,186,187,190,191,194],{},"Two details make this correct rather than merely working. ",[14,184,185],{},"outcome.get_result()"," must be called after the yield — before it, no implementation has run and there is no report. And ",[14,188,189],{},"report.when"," distinguishes the three phases pytest reports per test, so unfiltered logic runs three times and, for a test that fails in setup, runs against a ",[14,192,193],{},"call"," report that does not exist.",[10,196,197],{},"With the report attached to the item, a fixture can branch on the outcome during its own teardown — the standard way to dump diagnostics only for failures:",[63,199,201],{"className":65,"code":200,"language":67,"meta":68,"style":68},"import pytest\n\n@pytest.fixture\ndef browser(request):\n    driver = start_browser()\n    yield driver\n    # request.node is the item the wrapper decorated above.\n    report = getattr(request.node, \"rep_call\", None)\n    if report is not None and report.failed:\n        driver.save_screenshot(f\"\u002Fartifacts\u002F{request.node.name}.png\")\n    driver.quit()\n",[14,202,203,207,211,216,221,226,231,236,241,246,251],{"__ignoreMap":68},[72,204,205],{"class":74,"line":75},[72,206,84],{},[72,208,209],{"class":74,"line":81},[72,210,91],{"emptyLinePlaceholder":90},[72,212,213],{"class":74,"line":87},[72,214,215],{},"@pytest.fixture\n",[72,217,218],{"class":74,"line":94},[72,219,220],{},"def browser(request):\n",[72,222,223],{"class":74,"line":100},[72,224,225],{},"    driver = start_browser()\n",[72,227,228],{"class":74,"line":106},[72,229,230],{},"    yield driver\n",[72,232,233],{"class":74,"line":112},[72,234,235],{},"    # request.node is the item the wrapper decorated above.\n",[72,237,238],{"class":74,"line":118},[72,239,240],{},"    report = getattr(request.node, \"rep_call\", None)\n",[72,242,243],{"class":74,"line":123},[72,244,245],{},"    if report is not None and report.failed:\n",[72,247,248],{"class":74,"line":129},[72,249,250],{},"        driver.save_screenshot(f\"\u002Fartifacts\u002F{request.node.name}.png\")\n",[72,252,253],{"class":74,"line":135},[72,254,255],{},"    driver.quit()\n",[257,258,261,421],"figure",{"className":259},[260],"diagram",[262,263,270,271,270,275,270,279,270,309,270,317,270,325,270,334,270,340,270,346,270,349,270,352,270,355,270,358,270,362,270,365,270,371,270,377,270,382,270,387,270,394,270,398,270,402,270,408,270,412,270,416],"svg",{"viewBox":264,"role":265,"ariaLabelledBy":266,"xmlns":269},"0 0 760 430","img",[267,268],"hookwrapseq-t","hookwrapseq-d","http:\u002F\u002Fwww.w3.org\u002F2000\u002Fsvg","\n  ",[272,273,274],"title",{"id":267},"Where a hookwrapper sits in the call chain",[276,277,278],"desc",{"id":268},"A sequence diagram with three lanes: pytest core, the hookwrapper, and the normal hook implementations. Core calls the wrapper, the wrapper runs its pre-yield code and yields, the normal implementations build the report, control returns to the wrapper which reads and enriches the result, and only then does the report reach the terminal reporter.",[280,281,282,283,282,297,282,303,270],"defs",{},"\n    ",[284,285,292],"marker",{"id":286,"viewBox":287,"refX":288,"refY":289,"markerWidth":290,"markerHeight":290,"orient":291},"hookwrapseq-a","0 0 10 10","9","5","7","auto-start-reverse",[293,294],"path",{"d":295,"fill":296},"M0 0 L10 5 L0 10 z","#3d405b",[284,298,300],{"id":299,"viewBox":287,"refX":288,"refY":289,"markerWidth":290,"markerHeight":290,"orient":291},"hookwrapseq-c",[293,301],{"d":295,"fill":302},"#e07a5f",[284,304,306],{"id":305,"viewBox":287,"refX":288,"refY":289,"markerWidth":290,"markerHeight":290,"orient":291},"hookwrapseq-s",[293,307],{"d":295,"fill":308},"#81b29a",[310,311],"rect",{"x":312,"y":312,"width":313,"height":314,"rx":315,"fill":316},"0","760","430","14","#fffdf8",[318,319,274],"text",{"x":320,"y":321,"textAnchor":322,"fontSize":323,"fontWeight":324,"fill":296},"380","30","middle","15","700",[310,326],{"x":327,"y":328,"width":329,"height":330,"rx":331,"fill":332,"stroke":296,"strokeWidth":333},"34","52","220","40","10","#f4f1de","1.8",[318,335,339],{"x":336,"y":337,"textAnchor":322,"fontSize":338,"fontWeight":324,"fill":296},"144","76","12","pytest core",[74,341],{"x1":336,"y1":342,"x2":336,"y2":343,"stroke":296,"strokeWidth":344,"strokeDashArray":345},"98","414","1.2",[289,289],[310,347],{"x":348,"y":328,"width":329,"height":330,"rx":331,"fill":332,"stroke":296,"strokeWidth":333},"270",[318,350,351],{"x":320,"y":337,"textAnchor":322,"fontSize":338,"fontWeight":324,"fill":296},"hookwrapper",[74,353],{"x1":320,"y1":342,"x2":320,"y2":343,"stroke":296,"strokeWidth":344,"strokeDashArray":354},[289,289],[310,356],{"x":357,"y":328,"width":329,"height":330,"rx":331,"fill":332,"stroke":296,"strokeWidth":333},"506",[318,359,361],{"x":360,"y":337,"textAnchor":322,"fontSize":338,"fontWeight":324,"fill":296},"616","implementations",[74,363],{"x1":360,"y1":342,"x2":360,"y2":343,"stroke":296,"strokeWidth":344,"strokeDashArray":364},[289,289],[74,366],{"x1":367,"y1":368,"x2":369,"y2":368,"stroke":296,"strokeWidth":333,"markerEnd":370},"152","126","372","url(#hookwrapseq-a)",[318,372,376],{"x":373,"y":374,"textAnchor":322,"fontSize":375,"fill":296},"262","117","11","call makereport",[74,378],{"x1":379,"y1":380,"x2":381,"y2":380,"stroke":296,"strokeWidth":333,"markerEnd":370},"388","180","608",[318,383,386],{"x":384,"y":385,"textAnchor":322,"fontSize":375,"fill":296},"498","171","yield: run them",[74,388],{"x1":381,"y1":389,"x2":379,"y2":389,"stroke":308,"strokeWidth":333,"strokeDashArray":390,"markerEnd":393},"234",[391,392],"6","4","url(#hookwrapseq-s)",[318,395,397],{"x":384,"y":396,"textAnchor":322,"fontSize":375,"fill":296},"225","the built report",[293,399],{"d":400,"fill":401,"stroke":296,"strokeWidth":333,"markerEnd":370},"M380 288 h 46 v 22 h -40","none",[318,403,407],{"x":404,"y":405,"textAnchor":406,"fontSize":375,"fill":296},"436","292","start","enrich it",[74,409],{"x1":369,"y1":410,"x2":367,"y2":410,"stroke":308,"strokeWidth":333,"strokeDashArray":411,"markerEnd":393},"342",[391,392],[318,413,415],{"x":373,"y":414,"textAnchor":322,"fontSize":375,"fill":296},"333","report continues",[318,417,420],{"x":320,"y":418,"textAnchor":322,"fontSize":375,"fontStyle":419,"fill":296},"418","italic","Everything after the yield runs with the result in hand — that is the whole point of the form.",[422,423,424],"figcaption",{},"The wrapper does not produce a report; it observes and mutates the one the real implementations produced.",[19,426,428],{"id":427},"why-this-works","Why this works",[10,430,431,432,436,437,440,441,443],{},"pytest's hook system calls every registered implementation for a hook and collects their results. A hookwrapper is not one of those implementations: pluggy runs it ",[433,434,435],"em",{},"around"," the whole set, passing control at the ",[14,438,439],{},"yield"," and returning the collected outcome afterwards. That gives it two capabilities no plain implementation has — it sees the final result rather than contributing to it, and it can run code both before and after the entire chain. Because reports are created per phase, the wrapper is invoked three times per test, and ",[14,442,189],{}," is the only reliable way to tell which invocation you are in.",[257,445,447,518],{"className":446},[260],[262,448,270,453,270,456,270,459,270,466,270,469,270,471,270,476,270,482,270,486,270,491,270,494,270,496,270,499,270,503,270,506,270,510,270,513],{"viewBox":449,"role":265,"ariaLabelledBy":450,"xmlns":269},"0 0 760 172",[451,452],"reportphases-t","reportphases-d",[272,454,455],{"id":451},"The three reports pytest builds per test",[276,457,458],{"id":452},"A left-to-right sequence of the three test phases, each producing its own report: setup, call and teardown, with a note that a setup failure means no call report is ever produced.",[280,460,282,461,270],{},[284,462,464],{"id":463,"viewBox":287,"refX":288,"refY":289,"markerWidth":290,"markerHeight":290,"orient":291},"reportphases-a",[293,465],{"d":295,"fill":302},[310,467],{"x":312,"y":312,"width":313,"height":468,"rx":315,"fill":316},"172",[318,470,455],{"x":320,"y":321,"textAnchor":322,"fontSize":323,"fontWeight":324,"fill":296},[310,472],{"x":473,"y":474,"width":475,"height":337,"rx":338,"fill":332,"stroke":302,"strokeWidth":333},"26","62","205",[318,477,481],{"x":478,"y":479,"textAnchor":322,"fontSize":480,"fontWeight":324,"fill":296},"129","96","12.5","setup",[318,483,485],{"x":478,"y":484,"textAnchor":322,"fontSize":375,"fill":296},"113","fixtures build",[74,487],{"x1":488,"y1":489,"x2":348,"y2":489,"stroke":302,"strokeWidth":333,"markerEnd":490},"238","100","url(#reportphases-a)",[310,492],{"x":493,"y":474,"width":475,"height":337,"rx":338,"fill":316,"stroke":302,"strokeWidth":333},"277",[318,495,193],{"x":320,"y":479,"textAnchor":322,"fontSize":480,"fontWeight":324,"fill":296},[318,497,498],{"x":320,"y":484,"textAnchor":322,"fontSize":375,"fill":296},"the test body runs",[74,500],{"x1":501,"y1":489,"x2":502,"y2":489,"stroke":302,"strokeWidth":333,"markerEnd":490},"490","522",[310,504],{"x":505,"y":474,"width":475,"height":337,"rx":338,"fill":332,"stroke":302,"strokeWidth":333},"529",[318,507,509],{"x":508,"y":479,"textAnchor":322,"fontSize":480,"fontWeight":324,"fill":296},"631","teardown",[318,511,512],{"x":508,"y":484,"textAnchor":322,"fontSize":375,"fill":296},"finalizers unwind",[318,514,517],{"x":320,"y":515,"textAnchor":322,"fontSize":516,"fontStyle":419,"fill":296},"164","11.5","A setup failure skips the call phase entirely — rep_call will not exist.",[422,519,520],{},"Each phase produces its own TestReport, so an unfiltered wrapper body executes three times per test.",[19,522,524],{"id":523},"routing-reports-somewhere-useful","Routing reports somewhere useful",[10,526,527],{},"Enriching the report is half the value; the other half is getting the enriched data out of the process. Three destinations cover most needs, and all three read the same object.",[10,529,530],{},"Writing a machine-readable line per failure is the simplest and survives CI log truncation:",[63,532,534],{"className":65,"code":533,"language":67,"meta":68,"style":68},"import json, pathlib, pytest\n\nLOG = pathlib.Path(\"test-events.jsonl\")\n\n@pytest.hookimpl(hookwrapper=True)\ndef pytest_runtest_makereport(item, call):\n    outcome = yield\n    report = outcome.get_result()\n    if report.when != \"call\":\n        return\n    LOG.open(\"a\").write(json.dumps({\n        \"nodeid\": report.nodeid,\n        \"outcome\": report.outcome,             # passed | failed | skipped\n        \"duration\": round(report.duration, 4), # per-phase seconds\n        \"markers\": [m.name for m in item.iter_markers()],\n    }) + \"\\n\")\n",[14,535,536,541,545,550,554,558,562,567,572,577,582,587,592,597,602,607],{"__ignoreMap":68},[72,537,538],{"class":74,"line":75},[72,539,540],{},"import json, pathlib, pytest\n",[72,542,543],{"class":74,"line":81},[72,544,91],{"emptyLinePlaceholder":90},[72,546,547],{"class":74,"line":87},[72,548,549],{},"LOG = pathlib.Path(\"test-events.jsonl\")\n",[72,551,552],{"class":74,"line":94},[72,553,91],{"emptyLinePlaceholder":90},[72,555,556],{"class":74,"line":100},[72,557,97],{},[72,559,560],{"class":74,"line":106},[72,561,103],{},[72,563,564],{"class":74,"line":112},[72,565,566],{},"    outcome = yield\n",[72,568,569],{"class":74,"line":118},[72,570,571],{},"    report = outcome.get_result()\n",[72,573,574],{"class":74,"line":123},[72,575,576],{},"    if report.when != \"call\":\n",[72,578,579],{"class":74,"line":129},[72,580,581],{},"        return\n",[72,583,584],{"class":74,"line":135},[72,585,586],{},"    LOG.open(\"a\").write(json.dumps({\n",[72,588,589],{"class":74,"line":140},[72,590,591],{},"        \"nodeid\": report.nodeid,\n",[72,593,594],{"class":74,"line":146},[72,595,596],{},"        \"outcome\": report.outcome,             # passed | failed | skipped\n",[72,598,599],{"class":74,"line":152},[72,600,601],{},"        \"duration\": round(report.duration, 4), # per-phase seconds\n",[72,603,604],{"class":74,"line":158},[72,605,606],{},"        \"markers\": [m.name for m in item.iter_markers()],\n",[72,608,609],{"class":74,"line":164},[72,610,611],{},"    }) + \"\\n\")\n",[10,613,614,615,618,619,622],{},"The terminal summary is the second destination: appending to ",[14,616,617],{},"report.sections"," puts your text in the failure block that pytest already prints, which is where a developer will actually look. And ",[14,620,621],{},"report.user_properties"," is the third — a list of key\u002Fvalue pairs that pytest writes into JUnit XML, making custom fields visible to CI dashboards without any parsing of the log.",[10,624,625],{},"Keep the wrapper cheap. It runs three times for every test in the suite, so a filesystem write per phase on a 20,000-test run is 60,000 writes; filter first, buffer if the volume is high, and never do network I\u002FO inside it.",[257,627,629,711],{"className":628},[260],[262,630,270,635,270,638,270,641,270,644,270,646,270,649,270,652,270,655,270,660,270,663,270,665,270,669,270,672,270,676,270,678,270,682,270,685,270,688,270,690,270,694,270,697,270,700,270,702,270,704,270,707],{"viewBox":631,"role":265,"ariaLabelledBy":632,"xmlns":269},"0 0 760 402",[633,634],"reportfields-t","reportfields-d",[272,636,637],{"id":633},"The fields worth reading on a TestReport",[276,639,640],{"id":634},"A stack of five TestReport fields with what each contains: when for the phase, outcome for the result, longrepr for the failure representation, duration for the phase timing, and user_properties for custom key-value pairs that reach JUnit XML.",[310,642],{"x":312,"y":312,"width":313,"height":643,"rx":315,"fill":316},"402",[318,645,637],{"x":320,"y":321,"textAnchor":322,"fontSize":323,"fontWeight":324,"fill":296},[310,647],{"x":321,"y":648,"width":324,"height":328,"rx":331,"fill":332,"stroke":302,"strokeWidth":333},"56",[310,650],{"x":321,"y":648,"width":651,"height":328,"rx":392,"fill":302},"8",[318,653,189],{"x":328,"y":654,"fontSize":480,"fontWeight":324,"fill":296},"86",[318,656,659],{"x":657,"y":654,"textAnchor":658,"fontSize":375,"fill":296},"714","end","setup, call or teardown — filter on this first",[310,661],{"x":321,"y":662,"width":324,"height":328,"rx":331,"fill":316,"stroke":308,"strokeWidth":333},"120",[310,664],{"x":321,"y":662,"width":651,"height":328,"rx":392,"fill":308},[318,666,668],{"x":328,"y":667,"fontSize":480,"fontWeight":324,"fill":296},"150","report.outcome",[318,670,671],{"x":657,"y":667,"textAnchor":658,"fontSize":375,"fill":296},"passed, failed or skipped for this phase",[310,673],{"x":321,"y":674,"width":324,"height":328,"rx":331,"fill":332,"stroke":675,"strokeWidth":333},"184","#f2cc8f",[310,677],{"x":321,"y":674,"width":651,"height":328,"rx":392,"fill":675},[318,679,681],{"x":328,"y":680,"fontSize":480,"fontWeight":324,"fill":296},"214","report.longrepr",[318,683,684],{"x":657,"y":680,"textAnchor":658,"fontSize":375,"fill":296},"the formatted failure; None when the phase passed",[310,686],{"x":321,"y":687,"width":324,"height":328,"rx":331,"fill":316,"stroke":296,"strokeWidth":333},"248",[310,689],{"x":321,"y":687,"width":651,"height":328,"rx":392,"fill":296},[318,691,693],{"x":328,"y":692,"fontSize":480,"fontWeight":324,"fill":296},"278","report.duration",[318,695,696],{"x":657,"y":692,"textAnchor":658,"fontSize":375,"fill":296},"seconds for this phase alone, not the whole test",[310,698],{"x":321,"y":699,"width":324,"height":328,"rx":331,"fill":332,"stroke":308,"strokeWidth":333},"312",[310,701],{"x":321,"y":699,"width":651,"height":328,"rx":392,"fill":308},[318,703,621],{"x":328,"y":410,"fontSize":480,"fontWeight":324,"fill":296},[318,705,706],{"x":657,"y":410,"textAnchor":658,"fontSize":375,"fill":296},"key\u002Fvalue pairs exported to JUnit XML",[318,708,710],{"x":320,"y":709,"textAnchor":322,"fontSize":375,"fontStyle":419,"fill":296},"394","item.stash is the supported place for cross-hook state on modern pytest.",[422,712,713],{},"These five cover almost every reporting plugin; anything richer usually belongs on the item rather than the report.",[19,715,717],{"id":716},"ordering-wrappers-against-other-plugins","Ordering wrappers against other plugins",[10,719,720,721,724,725,728,729,732,733,736],{},"Once more than one plugin wraps the same hook, order matters, and pytest gives two levers for it. ",[14,722,723],{},"tryfirst=True"," and ",[14,726,727],{},"trylast=True"," position an implementation relative to others, and for wrappers the semantics are worth stating precisely: a ",[14,730,731],{},"tryfirst"," wrapper's pre-yield code runs earliest and its post-yield code runs ",[433,734,735],{},"latest",", because wrappers nest rather than queue.",[63,738,740],{"className":65,"code":739,"language":67,"meta":68,"style":68},"import pytest\n\n@pytest.hookimpl(hookwrapper=True, trylast=True)\ndef pytest_runtest_makereport(item, call):\n    # trylast: this wrapper is innermost, so it writes its field before any\n    # outer wrapper reads the report.\n    outcome = yield\n    report = outcome.get_result()\n    report.user_properties.append((\"suite\", item.config.getoption(\"--suite\", \"default\")))\n",[14,741,742,746,750,755,759,764,769,773,777],{"__ignoreMap":68},[72,743,744],{"class":74,"line":75},[72,745,84],{},[72,747,748],{"class":74,"line":81},[72,749,91],{"emptyLinePlaceholder":90},[72,751,752],{"class":74,"line":87},[72,753,754],{},"@pytest.hookimpl(hookwrapper=True, trylast=True)\n",[72,756,757],{"class":74,"line":94},[72,758,103],{},[72,760,761],{"class":74,"line":100},[72,762,763],{},"    # trylast: this wrapper is innermost, so it writes its field before any\n",[72,765,766],{"class":74,"line":106},[72,767,768],{},"    # outer wrapper reads the report.\n",[72,770,771],{"class":74,"line":112},[72,772,566],{},[72,774,775],{"class":74,"line":118},[72,776,571],{},[72,778,779],{"class":74,"line":123},[72,780,781],{},"    report.user_properties.append((\"suite\", item.config.getoption(\"--suite\", \"default\")))\n",[10,783,784,785,788,789,791,792,795,796,799],{},"The practical rule: if your wrapper ",[433,786,787],{},"reads"," a report other plugins enrich, be outermost (",[14,790,731],{},"); if it ",[433,793,794],{},"writes"," a field another plugin should see, be innermost (",[14,797,798],{},"trylast","). Getting this backwards produces a field that is present locally and missing in CI, because the plugin set differs between the two environments.",[10,801,802],{},"The second lever is registration order, which for conftest-based hooks follows the directory hierarchy — a root conftest registers before a nested one, and both register before the test module's own hooks. A project-wide wrapper in the root conftest is therefore naturally outermost relative to a directory-specific one, without any explicit ordering.",[10,804,805,806,809],{},"To see the resolved order rather than infer it, run with ",[14,807,808],{},"--trace-config",", which prints every registered plugin and the hooks it implements at startup. On a suite with a dozen plugins this is the fastest way to answer \"who else is touching this hook\", and it is usually the first command to run when a wrapper works locally and does nothing in CI — where an extra plugin from the CI requirements file has quietly inserted itself into the chain.",[63,811,815],{"className":812,"code":813,"language":814,"meta":68,"style":68},"language-console shiki shiki-themes github-light github-dark","$ pytest --trace-config -q 2>&1 | grep -A1 makereport\n  plugin name: rerunfailures ... pytest_runtest_makereport\n  plugin name: conftest ...     pytest_runtest_makereport (hookwrapper)\n","console",[14,816,817,822,827],{"__ignoreMap":68},[72,818,819],{"class":74,"line":75},[72,820,821],{},"$ pytest --trace-config -q 2>&1 | grep -A1 makereport\n",[72,823,824],{"class":74,"line":81},[72,825,826],{},"  plugin name: rerunfailures ... pytest_runtest_makereport\n",[72,828,829],{"class":74,"line":87},[72,830,831],{},"  plugin name: conftest ...     pytest_runtest_makereport (hookwrapper)\n",[10,833,834,835,837,838,841],{},"A final ordering trap: ",[14,836,16],{}," wrappers see reports for ",[433,839,840],{},"every"," test, including ones another plugin has already marked as expected failures. Guard on the marks and the phase rather than assuming an item reached your code because it was interesting.",[257,843,845,936],{"className":844},[260],[262,846,270,850,270,853,270,856,270,863,270,865,270,867,270,871,270,875,270,878,270,883,270,886,270,890,270,893,270,896,270,899,270,902,270,905,270,909,270,912,270,916,270,919,270,923,270,926,270,930,270,933],{"viewBox":449,"role":265,"ariaLabelledBy":847,"xmlns":269},[848,849],"wrapnesting-t","wrapnesting-d",[272,851,852],{"id":848},"How two wrappers nest around one hook",[276,854,855],{"id":849},"A left-to-right sequence showing nesting: the outer wrapper pre-yield code runs, then the inner wrapper pre-yield code, then the real implementations build the report, then the inner post-yield code, and finally the outer post-yield code.",[280,857,282,858,270],{},[284,859,861],{"id":860,"viewBox":287,"refX":288,"refY":289,"markerWidth":290,"markerHeight":290,"orient":291},"wrapnesting-a",[293,862],{"d":295,"fill":308},[310,864],{"x":312,"y":312,"width":313,"height":468,"rx":315,"fill":316},[318,866,852],{"x":320,"y":321,"textAnchor":322,"fontSize":323,"fontWeight":324,"fill":296},[310,868],{"x":869,"y":474,"width":870,"height":337,"rx":338,"fill":332,"stroke":308,"strokeWidth":333},"16","122",[318,872,874],{"x":873,"y":479,"textAnchor":322,"fontSize":480,"fontWeight":324,"fill":296},"77","outer pre",[318,876,877],{"x":873,"y":484,"textAnchor":322,"fontSize":375,"fill":296},"tryfirst wrapper",[74,879],{"x1":880,"y1":489,"x2":881,"y2":489,"stroke":308,"strokeWidth":333,"markerEnd":882},"145","161","url(#wrapnesting-a)",[310,884],{"x":885,"y":474,"width":870,"height":337,"rx":338,"fill":316,"stroke":308,"strokeWidth":333},"168",[318,887,889],{"x":888,"y":479,"textAnchor":322,"fontSize":480,"fontWeight":324,"fill":296},"228","inner pre",[318,891,892],{"x":888,"y":484,"textAnchor":322,"fontSize":375,"fill":296},"trylast wrapper",[74,894],{"x1":895,"y1":489,"x2":699,"y2":489,"stroke":308,"strokeWidth":333,"markerEnd":882},"296",[310,897],{"x":898,"y":474,"width":870,"height":337,"rx":338,"fill":332,"stroke":308,"strokeWidth":333},"319",[318,900,901],{"x":320,"y":479,"textAnchor":322,"fontSize":480,"fontWeight":324,"fill":296},"hooks run",[318,903,904],{"x":320,"y":484,"textAnchor":322,"fontSize":375,"fill":296},"report is built",[74,906],{"x1":907,"y1":489,"x2":908,"y2":489,"stroke":308,"strokeWidth":333,"markerEnd":882},"448","464",[310,910],{"x":911,"y":474,"width":870,"height":337,"rx":338,"fill":316,"stroke":308,"strokeWidth":333},"471",[318,913,915],{"x":914,"y":479,"textAnchor":322,"fontSize":480,"fontWeight":324,"fill":296},"532","inner post",[318,917,918],{"x":914,"y":484,"textAnchor":322,"fontSize":375,"fill":296},"writes fields",[74,920],{"x1":921,"y1":489,"x2":922,"y2":489,"stroke":308,"strokeWidth":333,"markerEnd":882},"599","615",[310,924],{"x":925,"y":474,"width":870,"height":337,"rx":338,"fill":332,"stroke":308,"strokeWidth":333},"622",[318,927,929],{"x":928,"y":479,"textAnchor":322,"fontSize":480,"fontWeight":324,"fill":296},"683","outer post",[318,931,932],{"x":928,"y":484,"textAnchor":322,"fontSize":375,"fill":296},"reads all",[318,934,935],{"x":320,"y":515,"textAnchor":322,"fontSize":516,"fontStyle":419,"fill":296},"Read-only wrappers belong outermost; wrappers that add fields belong innermost.",[422,937,938],{},"Wrappers nest rather than queue: the first to enter is the last to see the finished report.",[19,940,942],{"id":941},"edge-cases-and-failure-modes","Edge cases and failure modes",[24,944,945,954,963,977,990],{},[27,946,947,953],{},[30,948,949,950,952],{},"Forgetting ",[14,951,189],{}," filtering"," makes the body run three times per test, which triples the cost and produces duplicate log lines for every test.",[27,955,956,962],{},[30,957,958,959,961],{},"Calling ",[14,960,185],{}," before the yield"," raises, because no implementation has run yet. The result only exists after control returns.",[27,964,965,968,969,972,973,976],{},[30,966,967],{},"Raising inside the wrapper breaks the run."," An exception after the yield propagates into pytest's internals and produces an ",[14,970,971],{},"INTERNALERROR",", not a test failure. Wrap risky work in ",[14,974,975],{},"try\u002Fexcept"," and degrade quietly.",[27,978,979,985,986,989],{},[30,980,981,984],{},[14,982,983],{},"rep_call"," may not exist."," When setup fails, the call phase never runs, so fixture teardown must use ",[14,987,988],{},"getattr(request.node, \"rep_call\", None)"," rather than attribute access.",[27,991,992,999,1000,54],{},[30,993,994,995,998],{},"Under ",[14,996,997],{},"pytest-xdist",", wrappers run in the worker process."," Anything written to a shared path needs the worker id in its name, exactly as described in ",[50,1001,1003],{"href":1002},"\u002Fadvanced-pytest-architecture-configuration\u002Foptimizing-test-discovery\u002Fpytest-xdist-vs-pytest-parallel-performance-comparison\u002F","pytest-xdist vs pytest-parallel",[10,1005,1006,1007,1009],{},"One more consideration before shipping a wrapper to a shared codebase: decide what happens when the enrichment itself fails. A wrapper that raises turns every test in the suite into an internal error, so the safe shape is a narrow ",[14,1008,975],{}," around the body with a single warning emitted once per session. Failing quietly is the right trade here — a reporting plugin that degrades to standard pytest output is an inconvenience, while one that aborts the run is an outage.",[10,1011,1012,1013,1016,1017,1019,1020,1022,1023,1026],{},"For pytest 8 and later there is a newer wrapper form worth knowing, because new plugins should prefer it: ",[14,1014,1015],{},"@pytest.hookimpl(wrapper=True)"," uses a ",[14,1018,44],{}," value instead of ",[14,1021,185],{},", and exceptions propagate normally rather than being captured in the outcome object. The old ",[14,1024,1025],{},"hookwrapper=True"," form still works and remains the right choice for a plugin that must support pytest 7, but the modern form is shorter and its error handling is far less surprising.",[19,1028,1030],{"id":1029},"frequently-asked-questions","Frequently Asked Questions",[10,1032,1033,1036],{},[30,1034,1035],{},"What is the difference between a hookwrapper and a normal hook implementation?","\nA normal implementation contributes a result. A hookwrapper runs around every other implementation: code before the yield runs first, the yield hands control to the real implementations, and code after the yield sees their result and can inspect or modify it.",[10,1038,1039,1042,1043,1045,1046,54],{},[30,1040,1041],{},"Why does my makereport hook run three times per test?","\nBecause pytest reports each phase separately: setup, call and teardown. Filter on ",[14,1044,189],{}," so the logic you want only runs for the phase you mean, usually ",[14,1047,193],{},[10,1049,1050,1053,1054,1057,1058,54],{},[30,1051,1052],{},"How do I make a fixture know whether the test passed?","\nStash the report on the item from the wrapper — ",[14,1055,1056],{},"item.stash"," or a plain attribute keyed by phase — then read it in the fixture's teardown after the ",[14,1059,439],{},[19,1061,1063],{"id":1062},"related","Related",[24,1065,1066,1072,1079,1086],{},[27,1067,1068,1071],{},[50,1069,1070],{"href":52},"Building custom pytest plugins"," — packaging, registration and the rest of the hook surface.",[27,1073,1074,1078],{},[50,1075,1077],{"href":1076},"\u002Fadvanced-pytest-architecture-configuration\u002Fbuilding-custom-pytest-plugins\u002Fpackaging-a-pytest-plugin-with-entry-points\u002F","Packaging a pytest plugin with entry points"," — shipping the wrapper as an installable plugin.",[27,1080,1081,1085],{},[50,1082,1084],{"href":1083},"\u002Fadvanced-pytest-architecture-configuration\u002Foptimizing-test-discovery\u002Fdebugging-flaky-tests-with-pytest-rerunfailures\u002F","Debugging flaky tests with pytest-rerunfailures"," — the same hook, used to record every rerun attempt.",[27,1087,1088,1092],{},[50,1089,1091],{"href":1090},"\u002Fadvanced-pytest-architecture-configuration\u002Fpytest-configuration-best-practices\u002F","Pytest configuration best practices"," — where a project-wide wrapper belongs in the configuration hierarchy.",[10,1094,1095,1096],{},"← Back to ",[50,1097,1098],{"href":52},"Building Custom Pytest Plugins",[1100,1101,1102],"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":68,"searchDepth":81,"depth":81,"links":1104},[1105,1106,1107,1108,1109,1110,1111,1112],{"id":21,"depth":81,"text":22},{"id":57,"depth":81,"text":58},{"id":427,"depth":81,"text":428},{"id":523,"depth":81,"text":524},{"id":716,"depth":81,"text":717},{"id":941,"depth":81,"text":942},{"id":1029,"depth":81,"text":1030},{"id":1062,"depth":81,"text":1063},"Wrap pytest_runtest_makereport with a hookwrapper to enrich, tag and route test reports: yield semantics, phase filtering, and attaching state to the item.","md",{"slug":1116,"type":1117,"breadcrumb":1118,"datePublished":1119,"dateModified":1119,"faq":1120,"howto":1127},"writing-a-hookwrapper-for-test-reports","article","Report Hookwrapper","2026-08-01",[1121,1123,1125],{"q":1035,"a":1122},"A normal implementation contributes a result. A hookwrapper runs around every other implementation: code before the yield runs first, the yield hands control to the real implementations, and code after the yield sees their result and can inspect or modify it.",{"q":1041,"a":1124},"Because pytest reports each phase separately: setup, call and teardown. Filter on report.when so the logic you want only runs for the phase you mean, usually call.",{"q":1052,"a":1126},"Stash the report on the item from the wrapper, for example item.stash or a plain attribute keyed by phase, then read it in the fixture's teardown after the yield.",{"name":1128,"description":1129,"steps":1130},"How to write a pytest report hookwrapper","Wrap pytest_runtest_makereport to enrich reports and expose the outcome to fixtures.",[1131,1134,1137,1140],{"name":1132,"text":1133},"Declare the wrapper","Mark the hook implementation with hookwrapper=True and yield exactly once inside it.",{"name":1135,"text":1136},"Take the result after the yield","Call outcome.get_result() to obtain the report other implementations produced.",{"name":1138,"text":1139},"Filter on the phase","Check report.when so the logic applies to setup, call or teardown only, as intended.",{"name":1141,"text":1142},"Expose the outcome to fixtures","Attach the report to the item so teardown code can branch on pass or fail.","\u002Fadvanced-pytest-architecture-configuration\u002Fbuilding-custom-pytest-plugins\u002Fwriting-a-hookwrapper-for-test-reports",{"title":5,"description":1113},"advanced-pytest-architecture-configuration\u002Fbuilding-custom-pytest-plugins\u002Fwriting-a-hookwrapper-for-test-reports\u002Findex","xmXLLHdUHoY7KB29q7KJrUT0pmb4EvIDgvmrKSsJ0ms",1785613404376]