[{"data":1,"prerenderedAt":903},["ShallowReactive",2],{"page-\u002Fadvanced-pytest-architecture-configuration\u002Fpytest-configuration-best-practices\u002Fcapturing-logs-with-caplog-and-log-cli\u002F":3},{"id":4,"title":5,"body":6,"description":866,"extension":867,"meta":868,"navigation":119,"path":899,"seo":900,"stem":901,"__hash__":902},"content\u002Fadvanced-pytest-architecture-configuration\u002Fpytest-configuration-best-practices\u002Fcapturing-logs-with-caplog-and-log-cli\u002Findex.md","Capturing Logs with caplog and log_cli",{"type":7,"value":8,"toc":855},"minimark",[9,18,21,26,52,56,103,192,236,383,387,405,414,418,472,476,482,496,504,559,566,663,667,677,680,684,687,690,693,757,760,764,784,799,811,815,844,851],[10,11,12,13,17],"p",{},"Logging sits in an awkward place in a test suite. Most of it is diagnostic — useful when a test fails, irrelevant when it passes — and a few records are contractual, such as an audit trail or a security event that must be emitted. pytest's logging integration handles both: every test's records are captured and attached to its failure report automatically, and ",[14,15,16],"code",{},"caplog"," exposes them for the small number of tests that need to assert on them.",[10,19,20],{},"Done well, this costs almost nothing: two or three configuration lines, a fixture that quiets the chattier libraries, and a habit of asserting on fields rather than wording. Done badly, it produces failure reports too noisy to read and log assertions too brittle to maintain. The two mistakes to avoid are opposite ones. Asserting on log wording couples tests to strings nobody considers stable, so a rephrased message breaks a dozen tests. And capturing at the wrong level — raising the root logger to DEBUG to see one module's records — floods every failure report with output from every library in the process.",[22,23,25],"h2",{"id":24},"prerequisites","Prerequisites",[27,28,29,43],"ul",{},[30,31,32,35,36,38,39,42],"li",{},[14,33,34],{},"pytest >= 8.0","; ",[14,37,16],{}," and ",[14,40,41],{},"log_cli"," are built in.",[30,44,45,46,51],{},"Familiarity with Python's logger hierarchy and propagation, from ",[47,48,50],"a",{"href":49},"\u002Fsystematic-debugging-performance-profiling\u002Flogging-and-observability-for-debugging\u002F","logging and observability for debugging",".",[22,53,55],{"id":54},"solution","Solution",[57,58,63],"pre",{"className":59,"code":60,"language":61,"meta":62,"style":62},"language-toml shiki shiki-themes github-light github-dark","# pyproject.toml\n[tool.pytest.ini_options]\nlog_level = \"INFO\"                        # captured for failure reports\nlog_format = \"%(levelname)-5s %(name)s: %(message)s\"\nlog_cli = false                           # off by default; enable per run\nlog_cli_level = \"INFO\"\n","toml","",[14,64,65,73,79,85,91,97],{"__ignoreMap":62},[66,67,70],"span",{"class":68,"line":69},"line",1,[66,71,72],{},"# pyproject.toml\n",[66,74,76],{"class":68,"line":75},2,[66,77,78],{},"[tool.pytest.ini_options]\n",[66,80,82],{"class":68,"line":81},3,[66,83,84],{},"log_level = \"INFO\"                        # captured for failure reports\n",[66,86,88],{"class":68,"line":87},4,[66,89,90],{},"log_format = \"%(levelname)-5s %(name)s: %(message)s\"\n",[66,92,94],{"class":68,"line":93},5,[66,95,96],{},"log_cli = false                           # off by default; enable per run\n",[66,98,100],{"class":68,"line":99},6,[66,101,102],{},"log_cli_level = \"INFO\"\n",[57,104,108],{"className":105,"code":106,"language":107,"meta":62,"style":62},"language-python shiki shiki-themes github-light github-dark","import logging\n\n\ndef test_refund_emits_an_audit_record(caplog, billing):\n    # Scope the capture to the logger that owns the contract.\n    caplog.set_level(logging.INFO, logger=\"myapp.audit\")\n\n    billing.refund(order_id=\"ord_1\", amount_minor=4999)\n\n    audit = [r for r in caplog.records if r.name == \"myapp.audit\"]\n    assert len(audit) == 1\n    # Fields, not wording: the message can be rephrased without breaking this.\n    assert audit[0].levelname == \"INFO\"\n    assert audit[0].event == \"refund_issued\"\n    assert audit[0].order_id == \"ord_1\"\n","python",[14,109,110,115,121,125,130,135,140,145,151,156,162,168,174,180,186],{"__ignoreMap":62},[66,111,112],{"class":68,"line":69},[66,113,114],{},"import logging\n",[66,116,117],{"class":68,"line":75},[66,118,120],{"emptyLinePlaceholder":119},true,"\n",[66,122,123],{"class":68,"line":81},[66,124,120],{"emptyLinePlaceholder":119},[66,126,127],{"class":68,"line":87},[66,128,129],{},"def test_refund_emits_an_audit_record(caplog, billing):\n",[66,131,132],{"class":68,"line":93},[66,133,134],{},"    # Scope the capture to the logger that owns the contract.\n",[66,136,137],{"class":68,"line":99},[66,138,139],{},"    caplog.set_level(logging.INFO, logger=\"myapp.audit\")\n",[66,141,143],{"class":68,"line":142},7,[66,144,120],{"emptyLinePlaceholder":119},[66,146,148],{"class":68,"line":147},8,[66,149,150],{},"    billing.refund(order_id=\"ord_1\", amount_minor=4999)\n",[66,152,154],{"class":68,"line":153},9,[66,155,120],{"emptyLinePlaceholder":119},[66,157,159],{"class":68,"line":158},10,[66,160,161],{},"    audit = [r for r in caplog.records if r.name == \"myapp.audit\"]\n",[66,163,165],{"class":68,"line":164},11,[66,166,167],{},"    assert len(audit) == 1\n",[66,169,171],{"class":68,"line":170},12,[66,172,173],{},"    # Fields, not wording: the message can be rephrased without breaking this.\n",[66,175,177],{"class":68,"line":176},13,[66,178,179],{},"    assert audit[0].levelname == \"INFO\"\n",[66,181,183],{"class":68,"line":182},14,[66,184,185],{},"    assert audit[0].event == \"refund_issued\"\n",[66,187,189],{"class":68,"line":188},15,[66,190,191],{},"    assert audit[0].order_id == \"ord_1\"\n",[57,193,197],{"className":194,"code":195,"language":196,"meta":62,"style":62},"language-bash shiki shiki-themes github-light github-dark","# Watching a slow test live, for one run only.\npytest tests\u002Ftest_sync.py -o log_cli=true -o log_cli_level=DEBUG -k stuck\n","bash",[14,198,199,205],{"__ignoreMap":62},[66,200,201],{"class":68,"line":69},[66,202,204],{"class":203},"sJ8bj","# Watching a slow test live, for one run only.\n",[66,206,207,211,215,219,222,225,227,230,233],{"class":68,"line":75},[66,208,210],{"class":209},"sScJk","pytest",[66,212,214],{"class":213},"sZZnC"," tests\u002Ftest_sync.py",[66,216,218],{"class":217},"sj4cs"," -o",[66,220,221],{"class":213}," log_cli=",[66,223,224],{"class":217},"true",[66,226,218],{"class":217},[66,228,229],{"class":213}," log_cli_level=DEBUG",[66,231,232],{"class":217}," -k",[66,234,235],{"class":213}," stuck\n",[237,238,241,372],"figure",{"className":239},[240],"diagram",[242,243,250,251,250,255,250,259,250,277,250,285,250,294,250,303,250,309,250,313,250,319,250,326,250,330,250,334,250,340,250,344,250,352,250,356,250,360,250,364,250,368],"svg",{"viewBox":244,"role":245,"ariaLabelledBy":246,"xmlns":249},"0 0 820 262","img",[247,248],"cl-t","cl-d","http:\u002F\u002Fwww.w3.org\u002F2000\u002Fsvg","\n  ",[252,253,254],"title",{"id":247},"Two consumers of the same log records",[256,257,258],"desc",{"id":248},"A log record emitted by application code reaches pytest's handlers at the root logger. The capture handler stores it for caplog assertions and for the failure report. The live handler, when log_cli is enabled, writes it to the terminal immediately. Each handler has its own level.",[260,261,262,263,250],"defs",{},"\n    ",[264,265,272],"marker",{"id":266,"viewBox":267,"refX":268,"refY":269,"markerWidth":270,"markerHeight":270,"orient":271},"cl-a","0 0 10 10","9","5","7","auto-start-reverse",[273,274],"path",{"d":275,"fill":276},"M0 0 L10 5 L0 10 z","#3d405b",[278,279],"rect",{"x":280,"y":280,"width":281,"height":282,"rx":283,"fill":284},"0","820","262","14","#fffdf8",[286,287,293],"text",{"x":288,"y":289,"textAnchor":290,"fontSize":291,"fontWeight":292,"fill":276},"410","28","middle","16","700","One record, two destinations, independent levels",[278,295],{"x":296,"y":297,"width":298,"height":299,"rx":300,"fill":301,"stroke":276,"strokeWidth":302},"26","100","200","64","11","#f4f1de","1.6",[286,304,308],{"x":305,"y":306,"textAnchor":290,"fontSize":307,"fontWeight":292,"fill":276},"126","128","12","logger.info(…)",[286,310,312],{"x":305,"y":311,"textAnchor":290,"fontSize":300,"fill":276},"148","myapp.audit",[68,314],{"x1":315,"y1":316,"x2":317,"y2":316,"stroke":276,"strokeWidth":302,"markerEnd":318},"230","132","276","url(#cl-a)",[278,320],{"x":321,"y":297,"width":322,"height":299,"rx":300,"fill":323,"stroke":324,"strokeWidth":325},"282","180","#f7f0da","#f2cc8f","2",[286,327,329],{"x":328,"y":306,"textAnchor":290,"fontSize":307,"fontWeight":292,"fill":276},"372","root logger",[286,331,333],{"x":328,"y":311,"textAnchor":290,"fontSize":300,"fill":332},"#8a5a00","if propagate is on",[68,335],{"x1":336,"y1":337,"x2":338,"y2":339,"stroke":276,"strokeWidth":302,"markerEnd":318},"466","120","530","78",[68,341],{"x1":336,"y1":342,"x2":338,"y2":343,"stroke":276,"strokeWidth":302,"markerEnd":318},"144","186",[278,345],{"x":346,"y":347,"width":348,"height":349,"rx":300,"fill":350,"stroke":351,"strokeWidth":325},"536","46","258","72","#e6f0ea","#81b29a",[286,353,355],{"x":354,"y":349,"textAnchor":290,"fontSize":307,"fontWeight":292,"fill":276},"665","capture handler",[286,357,359],{"x":354,"y":358,"textAnchor":290,"fontSize":300,"fill":276},"94","caplog.records + failure report",[278,361],{"x":346,"y":362,"width":348,"height":349,"rx":300,"fill":284,"stroke":363,"strokeWidth":302},"150","rgba(61,64,91,0.35)",[286,365,367],{"x":354,"y":366,"textAnchor":290,"fontSize":307,"fontWeight":292,"fill":276},"176","live handler",[286,369,371],{"x":354,"y":370,"textAnchor":290,"fontSize":300,"fill":276},"198","terminal, only with log_cli",[373,374,375,376,379,380,382],"figcaption",{},"A logger with ",[14,377,378],{},"propagate = False"," never reaches either handler, which is the usual reason ",[14,381,16],{}," appears to capture nothing.",[22,384,386],{"id":385},"why-this-works","Why this works",[10,388,389,390,393,394,397,398,401,402,404],{},"pytest installs its own handlers on the root logger for the duration of each test. The capture handler stores every record at or above ",[14,391,392],{},"log_level"," — or the level set with ",[14,395,396],{},"caplog.set_level"," — and that storage is what ",[14,399,400],{},"caplog.records"," exposes and what appears under \"Captured log call\" when a test fails. The live handler, active only with ",[14,403,41],{},", writes records to the terminal as they are emitted, which is the only way to see output from a test that never finishes.",[10,406,407,409,410,413],{},[14,408,396],{}," with a ",[14,411,412],{},"logger"," argument sets the level on that specific logger for the duration of the test and restores it afterwards. Setting it on the named logger rather than the root is what keeps capture precise: records from other libraries stay at their own levels and do not flood the report.",[22,415,417],{"id":416},"edge-cases-and-failure-modes","Edge cases and failure modes",[27,419,420,428,437,450,463],{},[30,421,422,427],{},[423,424,425,51],"strong",{},[14,426,378],{}," Records never reach the root, so neither handler sees them. Re-enable propagation in a fixture for tests that need it.",[30,429,430,436],{},[423,431,432,435],{},[14,433,434],{},"logging.basicConfig"," at import."," Installs a second handler on the root, so every record appears twice. Configure logging in an entry point, never at module import.",[30,438,439,445,446,449],{},[423,440,441,442,51],{},"Asserting on ",[14,443,444],{},"caplog.text"," Couples the test to the format string and the wording. Assert on ",[14,447,448],{},"records"," and their attributes.",[30,451,452,455,456,458,459,462],{},[423,453,454],{},"Records from setup and teardown."," ",[14,457,400],{}," covers the call phase; ",[14,460,461],{},"caplog.get_records(\"setup\")"," retrieves the others when a fixture's logging matters.",[30,464,465,471],{},[423,466,467,468,470],{},"Leaving ",[14,469,41],{}," on in configuration."," Every run streams everything, which makes output unreadable for a whole suite. Enable it per run.",[22,473,475],{"id":474},"choosing-capture-levels-for-the-whole-suite","Choosing capture levels for the whole suite",[10,477,478,479,481],{},"The ",[14,480,392],{}," setting in configuration decides what every failing test's report contains, and it is worth choosing deliberately rather than leaving at pytest's default.",[10,483,484,485,488,489,492,493,51],{},"Too low — ",[14,486,487],{},"DEBUG"," across the board — and a failure report includes every debug line from every library in the process: HTTP connection pools announcing reuse, ORMs printing SQL, retry libraries narrating their back-off. The one line that explains the failure is somewhere in several hundred. Too high — ",[14,490,491],{},"WARNING"," — and the operational narrative that would have explained the failure is gone, because it was logged at ",[14,494,495],{},"INFO",[10,497,498,500,501,503],{},[14,499,495],{}," at the root, with specific noisy libraries raised to ",[14,502,491],{},", is the configuration that works for most applications. It keeps the application's own narrative in every failure report and removes the chatter that nobody reads. The raised levels belong in a session-scoped fixture that sets them once, so every test inherits the same quiet baseline.",[57,505,507],{"className":105,"code":506,"language":107,"meta":62,"style":62},"import logging\n\nimport pytest\n\nNOISY = (\"urllib3\", \"botocore\", \"asyncio\", \"sqlalchemy.engine.Engine\")\n\n\n@pytest.fixture(autouse=True, scope=\"session\")\ndef quiet_libraries():\n    for name in NOISY:\n        logging.getLogger(name).setLevel(logging.WARNING)\n",[14,508,509,513,517,522,526,531,535,539,544,549,554],{"__ignoreMap":62},[66,510,511],{"class":68,"line":69},[66,512,114],{},[66,514,515],{"class":68,"line":75},[66,516,120],{"emptyLinePlaceholder":119},[66,518,519],{"class":68,"line":81},[66,520,521],{},"import pytest\n",[66,523,524],{"class":68,"line":87},[66,525,120],{"emptyLinePlaceholder":119},[66,527,528],{"class":68,"line":93},[66,529,530],{},"NOISY = (\"urllib3\", \"botocore\", \"asyncio\", \"sqlalchemy.engine.Engine\")\n",[66,532,533],{"class":68,"line":99},[66,534,120],{"emptyLinePlaceholder":119},[66,536,537],{"class":68,"line":142},[66,538,120],{"emptyLinePlaceholder":119},[66,540,541],{"class":68,"line":147},[66,542,543],{},"@pytest.fixture(autouse=True, scope=\"session\")\n",[66,545,546],{"class":68,"line":153},[66,547,548],{},"def quiet_libraries():\n",[66,550,551],{"class":68,"line":158},[66,552,553],{},"    for name in NOISY:\n",[66,555,556],{"class":68,"line":164},[66,557,558],{},"        logging.getLogger(name).setLevel(logging.WARNING)\n",[10,560,561,562,565],{},"When a specific failure needs more detail, a single run with ",[14,563,564],{},"-o log_level=DEBUG"," provides it without changing the baseline for everyone. Keep the list of quietened libraries short and review it occasionally, since a library that was noisy two years ago may now log only what matters.",[237,567,569,660],{"className":568},[240],[242,570,250,575,250,578,250,581,250,585,250,590,250,598,250,602,250,609,250,613,250,616,250,618,250,622,250,627,250,630,250,633,250,637,250,640,250,642,250,646,250,650,250,653,250,656],{"viewBox":571,"role":245,"ariaLabelledBy":572,"xmlns":249},"0 0 800 234",[573,574],"lvl-t","lvl-d",[252,576,577],{"id":573},"What a failure report contains at each capture level",[256,579,580],{"id":574},"Three failure reports. At DEBUG everywhere the report is dominated by library chatter with the relevant line buried. At WARNING the application's informational narrative is missing. At INFO with noisy libraries raised to WARNING, the report contains the application's narrative and little else.",[278,582],{"x":280,"y":280,"width":583,"height":584,"rx":283,"fill":284},"800","234",[286,586,589],{"x":587,"y":289,"textAnchor":290,"fontSize":588,"fontWeight":292,"fill":276},"400","15.5","The right level makes the report readable",[278,591],{"x":592,"y":593,"width":594,"height":595,"rx":307,"fill":596,"stroke":597,"strokeWidth":325},"24","50","240","164","#fbe9e3","#e07a5f",[286,599,601],{"x":342,"y":600,"textAnchor":290,"fontSize":307,"fontWeight":292,"fill":276},"76","DEBUG everywhere",[278,603],{"x":604,"y":605,"width":606,"height":607,"rx":325,"fill":608},"40","90","208","8","rgba(61,64,91,0.14)",[278,610],{"x":604,"y":611,"width":612,"height":607,"rx":325,"fill":608},"104","190",[278,614],{"x":604,"y":615,"width":298,"height":607,"rx":325,"fill":608},"118",[278,617],{"x":604,"y":316,"width":337,"height":607,"rx":325,"fill":597},[278,619],{"x":604,"y":620,"width":621,"height":607,"rx":325,"fill":608},"146","206",[286,623,626],{"x":342,"y":624,"textAnchor":290,"fontSize":300,"fill":625},"192","#8f3d22","the clue is buried",[278,628],{"x":629,"y":593,"width":594,"height":595,"rx":307,"fill":323,"stroke":324,"strokeWidth":325},"280",[286,631,632],{"x":587,"y":600,"textAnchor":290,"fontSize":307,"fontWeight":292,"fill":276},"WARNING everywhere",[278,634],{"x":635,"y":337,"width":636,"height":607,"rx":325,"fill":608},"296","140",[286,638,639],{"x":587,"y":624,"textAnchor":290,"fontSize":300,"fill":332},"the narrative is missing",[278,641],{"x":346,"y":593,"width":594,"height":595,"rx":307,"fill":350,"stroke":351,"strokeWidth":325},[286,643,645],{"x":644,"y":600,"textAnchor":290,"fontSize":307,"fontWeight":292,"fill":276},"656","INFO, libraries quiet",[278,647],{"x":648,"y":649,"width":322,"height":607,"rx":325,"fill":351},"552","96",[278,651],{"x":648,"y":652,"width":362,"height":607,"rx":325,"fill":351},"110",[278,654],{"x":648,"y":655,"width":337,"height":607,"rx":325,"fill":597},"124",[286,657,659],{"x":644,"y":624,"textAnchor":290,"fontSize":300,"fill":658},"#2a5f49","the story, and the clue",[373,661,662],{},"The right-hand report is short enough to read in full, which is what makes it useful.",[22,664,666],{"id":665},"log-records-as-evidence-in-ci","Log records as evidence in CI",[10,668,669,670,673,674,51],{},"Captured logs are most valuable in CI, where a failure cannot be re-run interactively and the report is all there is. Two settings make sure they arrive intact. ",[14,671,672],{},"junit_logging = \"all\""," copies captured records into the JUnit XML for failing tests, so dashboards show them next to the failure. And a consistent format with the logger name and, where available, a correlation id lets a reader filter the relevant lines out of an interleaved run under ",[14,675,676],{},"pytest-xdist",[10,678,679],{},"The payoff is the same one this site returns to repeatedly: a failure report that contains enough to diagnose the problem without running anything again. Logs captured at the right level, with the right format, attached to the right report, are the cheapest way to get there. They require no extra instrumentation in the tests themselves, only a few decisions made once in configuration and then left alone, which is why they are worth making carefully on day one rather than revisiting after the first frustrating CI failure.",[22,681,683],{"id":682},"deciding-which-log-lines-deserve-an-assertion","Deciding which log lines deserve an assertion",[10,685,686],{},"The large majority of log statements in an application should never appear in a test assertion, and the discipline of keeping it that way is what stops logging tests from becoming a maintenance burden.",[10,688,689],{},"A log line deserves an assertion when something outside the codebase depends on it. Audit records consumed by a compliance process, security events that trigger alerts, operational messages referenced in a runbook, metrics derived from log parsing — each of these is an interface, and changing it silently would break something downstream. Tests on those lines are contract tests, and they should assert on the structured fields the consumer reads.",[10,691,692],{},"A log line does not deserve an assertion when it exists for a developer reading output during debugging. Those lines should be free to change wording, level and content as the code evolves, and a test pinning them converts every improvement into a test update. They still earn their keep in tests — they appear automatically in failure reports — without any test asserting on them.",[237,694,696,754],{"className":695},[240],[242,697,250,702,250,705,250,708,250,711,250,714,250,717,250,721,250,725,250,728,250,731,250,734,250,737,250,741,250,745,250,748,250,751],{"viewBox":698,"role":245,"ariaLabelledBy":699,"xmlns":249},"0 0 800 236",[700,701],"which-t","which-d",[252,703,704],{"id":700},"Contractual versus diagnostic log lines",[256,706,707],{"id":701},"Two columns. Contractual log lines such as audit records, security events and runbook messages are consumed outside the code and deserve assertions on their structured fields. Diagnostic log lines exist for developers and should never be asserted on, though they appear automatically in failure reports.",[278,709],{"x":280,"y":280,"width":583,"height":710,"rx":283,"fill":284},"236",[286,712,713],{"x":587,"y":289,"textAnchor":290,"fontSize":588,"fontWeight":292,"fill":276},"Assert on interfaces; let diagnostics change freely",[278,715],{"x":296,"y":593,"width":716,"height":595,"rx":307,"fill":323,"stroke":324,"strokeWidth":325},"360",[286,718,720],{"x":621,"y":600,"textAnchor":290,"fontSize":719,"fontWeight":292,"fill":276},"12.5","contractual",[286,722,724],{"x":723,"y":611,"fontSize":300,"fill":276},"44","audit trail, security events",[286,726,727],{"x":723,"y":305,"fontSize":300,"fill":276},"runbook messages, log metrics",[286,729,730],{"x":723,"y":595,"fontSize":300,"fontWeight":292,"fill":332},"assert on structured fields",[286,732,733],{"x":723,"y":343,"fontSize":300,"fill":276},"something downstream depends on it",[278,735],{"x":736,"y":593,"width":716,"height":595,"rx":307,"fill":350,"stroke":351,"strokeWidth":325},"414",[286,738,740],{"x":739,"y":600,"textAnchor":290,"fontSize":719,"fontWeight":292,"fill":276},"594","diagnostic",[286,742,744],{"x":743,"y":611,"fontSize":300,"fill":276},"432","\"retrying\", \"cache miss\",",[286,746,747],{"x":743,"y":305,"fontSize":300,"fill":276},"\"parsed 12 rows\"",[286,749,750],{"x":743,"y":595,"fontSize":300,"fontWeight":292,"fill":658},"never assert",[286,752,753],{"x":743,"y":343,"fontSize":300,"fill":276},"shown in failure reports anyway",[373,755,756],{},"Most suites have a handful of tests in the left column and none in the right. A suite with many log-wording assertions has put diagnostic lines in the wrong column.",[10,758,759],{},"When a contractual log line does need testing, the test belongs next to the code that emits it and should read like any other contract test: arrange the operation, perform it, assert that exactly one record with the expected event name and fields was emitted. Asserting on the count as well as the content catches the duplicated audit record, which is a real and surprisingly common defect when a retry loop logs before rather than after deciding to retry. It also catches the opposite regression, where a refactor moves the logging call behind a condition that the test's scenario no longer satisfies, and the record silently stops being written for the case that mattered.",[22,761,763],{"id":762},"frequently-asked-questions","Frequently Asked Questions",[10,765,766,769,770,773,774,777,778,780,781,783],{},[423,767,768],{},"Why is caplog.records empty when my code clearly logged something?","\nUsually the logger's effective level is higher than the record's, or the logger has ",[14,771,772],{},"propagate"," set to ",[14,775,776],{},"False"," so records never reach the root handler ",[14,779,16],{}," attaches to. Call ",[14,782,396],{}," with the specific logger name and check that propagation is on.",[10,785,786,789,791,792,794,795,798],{},[423,787,788],{},"What is the difference between caplog.text and caplog.records?",[14,790,444],{}," is the formatted output as a single string; ",[14,793,400],{}," is the list of ",[14,796,797],{},"LogRecord"," objects with their attributes. Assert on records and their fields for anything durable, since the formatted text changes whenever the format string or the message wording changes.",[10,800,801,804,805,807,808,810],{},[423,802,803],{},"Does log_cli affect what caplog captures?","\nNo. ",[14,806,41],{}," streams records to the terminal as they happen; ",[14,809,16],{}," captures them for assertions. Both can be active at once, and each has its own level setting.",[22,812,814],{"id":813},"related","Related",[27,816,817,823,830,837],{},[30,818,819,822],{},[47,820,821],{"href":49},"Logging & Observability for Debugging"," — structured records and correlation ids.",[30,824,825,829],{},[47,826,828],{"href":827},"\u002Fsystematic-debugging-performance-profiling\u002Flogging-and-observability-for-debugging\u002Fstructured-logging-that-survives-pytest-capture\u002F","Structured Logging That Survives pytest Capture"," — making JSON logging and capture coexist.",[30,831,832,836],{},[47,833,835],{"href":834},"\u002Fadvanced-pytest-architecture-configuration\u002Fpytest-configuration-best-practices\u002Fturning-warnings-into-errors-with-filterwarnings\u002F","Turning Warnings into Errors with filterwarnings"," — the companion capture mechanism for warnings.",[30,838,839,843],{},[47,840,842],{"href":841},"\u002Fadvanced-pytest-architecture-configuration\u002Fassertion-introspection-and-reporting\u002F","Assertion Introspection & Test Reporting"," — getting captured logs into CI reports.",[10,845,846,847],{},"← Back to ",[47,848,850],{"href":849},"\u002Fadvanced-pytest-architecture-configuration\u002Fpytest-configuration-best-practices\u002F","pytest Configuration Best Practices",[852,853,854],"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);}html pre.shiki code .sJ8bj, html code.shiki .sJ8bj{--shiki-default:#6A737D;--shiki-dark:#6A737D}html pre.shiki code .sScJk, html code.shiki .sScJk{--shiki-default:#6F42C1;--shiki-dark:#B392F0}html pre.shiki code .sZZnC, html code.shiki .sZZnC{--shiki-default:#032F62;--shiki-dark:#9ECBFF}html pre.shiki code .sj4cs, html code.shiki .sj4cs{--shiki-default:#005CC5;--shiki-dark:#79B8FF}",{"title":62,"searchDepth":75,"depth":75,"links":856},[857,858,859,860,861,862,863,864,865],{"id":24,"depth":75,"text":25},{"id":54,"depth":75,"text":55},{"id":385,"depth":75,"text":386},{"id":416,"depth":75,"text":417},{"id":474,"depth":75,"text":475},{"id":665,"depth":75,"text":666},{"id":682,"depth":75,"text":683},{"id":762,"depth":75,"text":763},{"id":813,"depth":75,"text":814},"Assert on log records with caplog, stream logs live with log_cli, choose capture levels per logger, and keep log assertions from coupling tests to wording.","md",{"slug":869,"type":870,"breadcrumb":871,"datePublished":872,"dateModified":872,"faq":873,"howto":880},"capturing-logs-with-caplog-and-log-cli","article","caplog & log_cli","2026-09-18",[874,876,878],{"q":768,"a":875},"Usually the logger's effective level is higher than the record's, or the logger has propagate set to False so records never reach the root handler caplog attaches to. Call caplog.set_level with the specific logger name and check that propagation is on.",{"q":788,"a":877},"caplog.text is the formatted output as a single string; caplog.records is the list of LogRecord objects with their attributes. Assert on records and their fields for anything durable, since the formatted text changes whenever the format string or the message wording changes.",{"q":803,"a":879},"No. log_cli streams records to the terminal as they happen; caplog captures them for assertions. Both can be active at once, and each has its own level setting.",{"name":881,"description":882,"steps":883},"How to capture and assert on logs in pytest","Set the capture level per logger, assert on record fields rather than text, and stream logs live only when watching a run.",[884,887,890,893,896],{"name":885,"text":886},"Set the level on the logger under test","Call caplog.set_level(logging.INFO, logger='myapp.billing') rather than raising the root level.",{"name":888,"text":889},"Exercise the code","Run the operation whose logging is part of the contract.",{"name":891,"text":892},"Assert on records","Filter caplog.records by logger or attribute and assert on levelname and structured fields.",{"name":894,"text":895},"Stream live when needed","Use -o log_cli=true for a single run to watch a slow or hanging test.",{"name":897,"text":898},"Keep the configuration in one place","Set log_level, log_format and log_cli defaults in pyproject.toml.","\u002Fadvanced-pytest-architecture-configuration\u002Fpytest-configuration-best-practices\u002Fcapturing-logs-with-caplog-and-log-cli",{"title":5,"description":866},"advanced-pytest-architecture-configuration\u002Fpytest-configuration-best-practices\u002Fcapturing-logs-with-caplog-and-log-cli\u002Findex","Yy8aRdTrVL2bInZAd1D6yquw0J9a9Xff4fC1EkdOSTY",1789718768754]