[{"data":1,"prerenderedAt":1566},["ShallowReactive",2],{"page-\u002Fadvanced-pytest-architecture-configuration\u002Fcoverage-measurement-and-enforcement\u002F":3},{"id":4,"title":5,"body":6,"description":1530,"extension":1531,"meta":1532,"navigation":134,"path":1562,"seo":1563,"stem":1564,"__hash__":1565},"content\u002Fadvanced-pytest-architecture-configuration\u002Fcoverage-measurement-and-enforcement\u002Findex.md","Coverage Measurement and Enforcement",{"type":7,"value":8,"toc":1516},"minimark",[9,18,23,50,54,60,76,184,194,323,327,337,348,354,368,382,400,506,510,513,525,555,566,576,585,598,627,634,638,641,690,703,707,710,716,729,744,747,877,881,884,909,965,972,984,996,1074,1077,1081,1189,1193,1196,1206,1209,1222,1235,1239,1242,1248,1254,1304,1310,1313,1430,1434,1446,1452,1462,1473,1477,1505,1512],[10,11,12,13,17],"p",{},"Coverage is the most reported and least trusted number in a Python test suite. It is trusted too much when it is high — a suite can execute every line and assert nothing — and dismissed too easily when it drops for a reason nobody investigates. The value is not the percentage; it is the list of lines your tests never reached, and getting that list to be accurate takes more configuration than ",[14,15,16],"code",{},"--cov"," alone.",[19,20,22],"h2",{"id":21},"prerequisites","Prerequisites",[24,25,26,41],"ul",{},[27,28,29,33,34,33,37,40],"li",{},[30,31,32],"strong",{},"pytest 7.0+",", ",[30,35,36],{},"pytest-cov 4.1+",[30,38,39],{},"coverage 7.3+",".",[27,42,43,44,49],{},"A parallel run configured as described in ",[45,46,48],"a",{"href":47},"\u002Fadvanced-pytest-architecture-configuration\u002Foptimizing-test-discovery\u002Fpytest-xdist-vs-pytest-parallel-performance-comparison\u002F","pytest-xdist vs pytest-parallel"," if the suite uses workers.",[19,51,53],{"id":52},"core-concept-what-coverage-actually-records","Core concept: what coverage actually records",[10,55,56,59],{},[14,57,58],{},"coverage.py"," installs a trace function that records which lines executed and, in branch mode, which line-to-line transitions occurred. That distinction is the whole difference between a number that means something and one that does not.",[10,61,62,63,67,68,71,72,75],{},"Line coverage marks a line as covered when it executed at all. Branch coverage records the ",[64,65,66],"em",{},"arcs"," between lines, so an ",[14,69,70],{},"if"," without an ",[14,73,74],{},"else"," has two possible arcs — into the body and past it — and a test that only takes one of them leaves the other uncovered. On a typical codebase, switching from line to branch coverage drops the reported figure by five to fifteen points, and every point of that drop is a decision path no test exercises.",[77,78,83],"pre",{"className":79,"code":80,"language":81,"meta":82,"style":82},"language-toml shiki shiki-themes github-light github-dark","# pyproject.toml\n[tool.coverage.run]\nbranch = true                       # measure arcs, not just lines\nsource = [\"src\u002Fmyapp\"]              # only our code; not site-packages, not tests\nparallel = true                     # per-process data files, combined later\nconcurrency = [\"multiprocessing\"]   # required when tests spawn subprocesses\nomit = [\"*\u002Fmigrations\u002F*\", \"*\u002F_generated\u002F*\"]\n\n[tool.coverage.report]\nshow_missing = true\nskip_covered = true                 # keep the report to the lines that matter\nexclude_also = [\n  \"if TYPE_CHECKING:\",              # never executed at runtime\n  \"raise NotImplementedError\",\n  \"if __name__ == .__main__.:\",\n]\n","toml","",[14,84,85,93,99,105,111,117,123,129,136,142,148,154,160,166,172,178],{"__ignoreMap":82},[86,87,90],"span",{"class":88,"line":89},"line",1,[86,91,92],{},"# pyproject.toml\n",[86,94,96],{"class":88,"line":95},2,[86,97,98],{},"[tool.coverage.run]\n",[86,100,102],{"class":88,"line":101},3,[86,103,104],{},"branch = true                       # measure arcs, not just lines\n",[86,106,108],{"class":88,"line":107},4,[86,109,110],{},"source = [\"src\u002Fmyapp\"]              # only our code; not site-packages, not tests\n",[86,112,114],{"class":88,"line":113},5,[86,115,116],{},"parallel = true                     # per-process data files, combined later\n",[86,118,120],{"class":88,"line":119},6,[86,121,122],{},"concurrency = [\"multiprocessing\"]   # required when tests spawn subprocesses\n",[86,124,126],{"class":88,"line":125},7,[86,127,128],{},"omit = [\"*\u002Fmigrations\u002F*\", \"*\u002F_generated\u002F*\"]\n",[86,130,132],{"class":88,"line":131},8,[86,133,135],{"emptyLinePlaceholder":134},true,"\n",[86,137,139],{"class":88,"line":138},9,[86,140,141],{},"[tool.coverage.report]\n",[86,143,145],{"class":88,"line":144},10,[86,146,147],{},"show_missing = true\n",[86,149,151],{"class":88,"line":150},11,[86,152,153],{},"skip_covered = true                 # keep the report to the lines that matter\n",[86,155,157],{"class":88,"line":156},12,[86,158,159],{},"exclude_also = [\n",[86,161,163],{"class":88,"line":162},13,[86,164,165],{},"  \"if TYPE_CHECKING:\",              # never executed at runtime\n",[86,167,169],{"class":88,"line":168},14,[86,170,171],{},"  \"raise NotImplementedError\",\n",[86,173,175],{"class":88,"line":174},15,[86,176,177],{},"  \"if __name__ == .__main__.:\",\n",[86,179,181],{"class":88,"line":180},16,[86,182,183],{},"]\n",[10,185,186,189,190,193],{},[14,187,188],{},"source"," matters as much as ",[14,191,192],{},"branch",". Without it, coverage measures whatever was imported, which pulls in dependencies and dilutes the number until it stops moving in response to your tests.",[195,196,199,319],"figure",{"className":197},[198],"diagram",[200,201,208,209,208,213,208,217,208,225,208,234,208,243,208,249,208,253,208,257,208,262,208,267,208,270,208,273,208,276,208,280,208,283,208,286,208,289,208,293,208,296,208,299,208,302,208,306,208,309,208,312,208,316],"svg",{"viewBox":202,"role":203,"ariaLabelledBy":204,"xmlns":207},"0 0 760 270","img",[205,206],"covkinds-t","covkinds-d","http:\u002F\u002Fwww.w3.org\u002F2000\u002Fsvg","\n  ",[210,211,212],"title",{"id":205},"What each coverage mode can and cannot see",[214,215,216],"desc",{"id":206},"A table comparing line coverage, branch coverage and contextual coverage across what each records, the defect each is able to reveal, and its measurement cost.",[218,219],"rect",{"x":220,"y":220,"width":221,"height":222,"rx":223,"fill":224},"0","760","270","14","#fffdf8",[226,227,212],"text",{"x":228,"y":229,"textAnchor":230,"fontSize":231,"fontWeight":232,"fill":233},"380","30","middle","15","700","#3d405b",[218,235],{"x":236,"y":237,"width":238,"height":239,"rx":240,"fill":241,"stroke":233,"strokeWidth":242},"24","52","712","40","10","#f2cc8f","1.5",[226,244,248],{"x":245,"y":246,"fontSize":247,"fontWeight":232,"fill":233},"38","76","12","Criterion",[226,250,252],{"x":251,"y":246,"textAnchor":230,"fontSize":247,"fontWeight":232,"fill":233},"390","Records",[226,254,256],{"x":255,"y":246,"textAnchor":230,"fontSize":247,"fontWeight":232,"fill":233},"620","Reveals",[218,258],{"x":236,"y":259,"width":238,"height":239,"fill":260,"stroke":233,"strokeWidth":261},"92","#f4f1de","1",[226,263,266],{"x":245,"y":264,"fontSize":265,"fill":233},"116","11.5","Line coverage",[226,268,269],{"x":251,"y":264,"textAnchor":230,"fontSize":265,"fill":233},"lines executed",[226,271,272],{"x":255,"y":264,"textAnchor":230,"fontSize":265,"fill":233},"entirely untested code",[218,274],{"x":236,"y":275,"width":238,"height":239,"fill":224,"stroke":233,"strokeWidth":261},"132",[226,277,279],{"x":245,"y":278,"fontSize":265,"fill":233},"156","Branch coverage",[226,281,282],{"x":251,"y":278,"textAnchor":230,"fontSize":265,"fill":233},"arcs between lines",[226,284,285],{"x":255,"y":278,"textAnchor":230,"fontSize":265,"fill":233},"untaken decision paths",[218,287],{"x":236,"y":288,"width":238,"height":239,"fill":260,"stroke":233,"strokeWidth":261},"172",[226,290,292],{"x":245,"y":291,"fontSize":265,"fill":233},"196","Contextual coverage",[226,294,295],{"x":251,"y":291,"textAnchor":230,"fontSize":265,"fill":233},"which test hit a line",[226,297,298],{"x":255,"y":291,"textAnchor":230,"fontSize":265,"fill":233},"over-coupled tests",[218,300],{"x":236,"y":301,"width":238,"height":239,"fill":224,"stroke":233,"strokeWidth":261},"212",[226,303,305],{"x":245,"y":304,"fontSize":265,"fill":233},"236","Mutation testing",[226,307,308],{"x":251,"y":304,"textAnchor":230,"fontSize":265,"fill":233},"assertions that matter",[226,310,311],{"x":255,"y":304,"textAnchor":230,"fontSize":265,"fill":233},"tests with no assertions",[88,313],{"x1":314,"y1":237,"x2":314,"y2":315,"stroke":233,"strokeWidth":261},"274","252",[88,317],{"x1":318,"y1":237,"x2":318,"y2":315,"stroke":233,"strokeWidth":261},"505",[320,321,322],"figcaption",{},"Each row sees something the row above it cannot; branch coverage is the minimum worth reporting, and mutation testing is the only one that measures assertions.",[19,324,326],{"id":325},"step-by-step-implementation","Step-by-step implementation",[10,328,329,332,333,336],{},[30,330,331],{},"1. Let pytest-cov own the run."," The single most common cause of a wrong number is starting coverage outside pytest. Use the plugin, not ",[14,334,335],{},"coverage run -m pytest",", so the plugin can hook worker startup and combine the results:",[77,338,342],{"className":339,"code":340,"language":341,"meta":82,"style":82},"language-console shiki shiki-themes github-light github-dark","$ pytest --cov --cov-report=term-missing --cov-report=xml -n 8\n","console",[14,343,344],{"__ignoreMap":82},[86,345,346],{"class":88,"line":89},[86,347,340],{},[10,349,350,353],{},[30,351,352],{},"2. Add contexts so the report can answer \"which test covered this\"."," Contextual coverage records the test id alongside each line, which turns the HTML report into a navigable map of what exercises what:",[77,355,357],{"className":79,"code":356,"language":81,"meta":82,"style":82},"[tool.coverage.run]\ndynamic_context = \"test_function\"\n",[14,358,359,363],{"__ignoreMap":82},[86,360,361],{"class":88,"line":89},[86,362,98],{},[86,364,365],{"class":88,"line":95},[86,366,367],{},"dynamic_context = \"test_function\"\n",[10,369,370,373,374,377,378,381],{},[30,371,372],{},"3. Report what is missing, not what is present."," ",[14,375,376],{},"--cov-report=term-missing"," with ",[14,379,380],{},"skip_covered = true"," prints only files with uncovered lines and the exact line numbers. That output is short enough to read in a pull request, which the full table never is.",[10,383,384,387,388,391,392,395,396,399],{},[30,385,386],{},"4. Keep the data file out of the repository."," Set ",[14,389,390],{},"data_file"," to a path under a build directory, and add it to ",[14,393,394],{},".gitignore","; a committed ",[14,397,398],{},".coverage"," file from someone's laptop produces confusing combined reports.",[195,401,403,503],{"className":402},[198],[200,404,208,409,208,412,208,415,208,433,208,435,208,437,208,443,208,449,208,454,208,460,208,463,208,467,208,470,208,474,208,477,208,481,208,484,208,488,208,491,208,495,208,498],{"viewBox":405,"role":203,"ariaLabelledBy":406,"xmlns":207},"0 0 760 172",[407,408],"covpipeline-t","covpipeline-d",[210,410,411],{"id":407},"The coverage pipeline under parallel tests",[214,413,414],{"id":408},"A left-to-right pipeline: each xdist worker traces its own process and writes a separate data file, pytest-cov combines the files at the end of the session, the combined data is analysed against the configured source, and the report is rendered.",[416,417,418,419,208],"defs",{},"\n    ",[420,421,428],"marker",{"id":422,"viewBox":423,"refX":424,"refY":425,"markerWidth":426,"markerHeight":426,"orient":427},"covpipeline-a","0 0 10 10","9","5","7","auto-start-reverse",[429,430],"path",{"d":431,"fill":432},"M0 0 L10 5 L0 10 z","#81b29a",[218,434],{"x":220,"y":220,"width":221,"height":288,"rx":223,"fill":224},[226,436,411],{"x":228,"y":229,"textAnchor":230,"fontSize":231,"fontWeight":232,"fill":233},[218,438],{"x":439,"y":440,"width":441,"height":246,"rx":247,"fill":260,"stroke":432,"strokeWidth":442},"26","62","142","1.8",[226,444,448],{"x":445,"y":446,"textAnchor":230,"fontSize":447,"fontWeight":232,"fill":233},"97","96","12.5","workers trace",[226,450,453],{"x":445,"y":451,"textAnchor":230,"fontSize":452,"fill":233},"113","11","one file each",[88,455],{"x1":456,"y1":457,"x2":458,"y2":457,"stroke":432,"strokeWidth":442,"markerEnd":459},"176","100","208","url(#covpipeline-a)",[218,461],{"x":462,"y":440,"width":441,"height":246,"rx":247,"fill":224,"stroke":432,"strokeWidth":442},"214",[226,464,466],{"x":465,"y":446,"textAnchor":230,"fontSize":447,"fontWeight":232,"fill":233},"286","combine",[226,468,469],{"x":465,"y":451,"textAnchor":230,"fontSize":452,"fill":233},"pytest-cov, at exit",[88,471],{"x1":472,"y1":457,"x2":473,"y2":457,"stroke":432,"strokeWidth":442,"markerEnd":459},"364","396",[218,475],{"x":476,"y":440,"width":441,"height":246,"rx":247,"fill":260,"stroke":432,"strokeWidth":442},"403",[226,478,480],{"x":479,"y":446,"textAnchor":230,"fontSize":447,"fontWeight":232,"fill":233},"474","analyse",[226,482,483],{"x":479,"y":451,"textAnchor":230,"fontSize":452,"fill":233},"against source",[88,485],{"x1":486,"y1":457,"x2":487,"y2":457,"stroke":432,"strokeWidth":442,"markerEnd":459},"552","584",[218,489],{"x":490,"y":440,"width":441,"height":246,"rx":247,"fill":224,"stroke":432,"strokeWidth":442},"592",[226,492,494],{"x":493,"y":446,"textAnchor":230,"fontSize":447,"fontWeight":232,"fill":233},"663","report",[226,496,497],{"x":493,"y":451,"textAnchor":230,"fontSize":452,"fill":233},"term, xml, html",[226,499,502],{"x":228,"y":500,"textAnchor":230,"fontSize":265,"fontStyle":501,"fill":233},"164","italic","parallel = true is what makes the per-worker files distinct rather than overwriting each other.",[320,504,505],{},"The combine step is the one that breaks when coverage is started outside pytest, producing a report from one worker only.",[19,507,509],{"id":508},"making-the-report-answer-real-questions","Making the report answer real questions",[10,511,512],{},"A percentage answers one question badly. The same data answers four questions well, and each needs a different report.",[10,514,515,373,518,377,521,524],{},[30,516,517],{},"Which lines are untested?",[14,519,520],{},"term-missing",[14,522,523],{},"skip_covered"," prints file, percentage and the exact missing line numbers, and nothing about files that are already complete:",[77,526,528],{"className":339,"code":527,"language":341,"meta":82,"style":82},"$ pytest --cov --cov-report=term-missing:skip-covered -q\nName                          Stmts   Miss Branch BrPart  Cover   Missing\n------------------------------------------------------------------------\nsrc\u002Fmyapp\u002Fbilling\u002Frefund.py      84     11     26      4    83%   47-52, 91->95, 118\nsrc\u002Fmyapp\u002Fwire\u002Fdecode.py         56      3     18      2    92%   77, 102->exit\n",[14,529,530,535,540,545,550],{"__ignoreMap":82},[86,531,532],{"class":88,"line":89},[86,533,534],{},"$ pytest --cov --cov-report=term-missing:skip-covered -q\n",[86,536,537],{"class":88,"line":95},[86,538,539],{},"Name                          Stmts   Miss Branch BrPart  Cover   Missing\n",[86,541,542],{"class":88,"line":101},[86,543,544],{},"------------------------------------------------------------------------\n",[86,546,547],{"class":88,"line":107},[86,548,549],{},"src\u002Fmyapp\u002Fbilling\u002Frefund.py      84     11     26      4    83%   47-52, 91->95, 118\n",[86,551,552],{"class":88,"line":113},[86,553,554],{},"src\u002Fmyapp\u002Fwire\u002Fdecode.py         56      3     18      2    92%   77, 102->exit\n",[10,556,557,558,561,562,565],{},"The arrow notation is branch coverage speaking: ",[14,559,560],{},"91->95"," means the arc from line 91 to line 95 never ran — the false path of a conditional — and ",[14,563,564],{},"102->exit"," means a loop never terminated normally in any test. Neither is visible in line coverage, and both are usually real gaps.",[10,567,568,571,572,575],{},[30,569,570],{},"Which test covers this line?"," With ",[14,573,574],{},"dynamic_context = \"test_function\"",", the HTML report annotates every line with the tests that executed it. That turns two questions into lookups: \"is this line covered by a test that actually asserts on it, or incidentally by an integration test three layers up\", and \"what will break if I change this function\".",[10,577,578,373,581,584],{},[30,579,580],{},"What changed?",[14,582,583],{},"coverage json"," emits a machine-readable report that a CI job can diff against the previous run, which is how a ratchet is implemented without a third-party service.",[10,586,587,590,591,594,595,597],{},[30,588,589],{},"What is not measured at all?"," Files with zero coverage are absent from some report formats entirely, which hides whole modules that no test imports. ",[14,592,593],{},"--cov-report=term"," includes them only when the files are inside ",[14,596,188],{},", which is another reason to set it explicitly.",[77,599,601],{"className":79,"code":600,"language":81,"meta":82,"style":82},"[tool.coverage.report]\n# Fail the report step rather than silently skipping unmeasured files.\nfail_under = 0            # the ratchet lives in CI, not here\nprecision = 1\nsort = \"Cover\"\n",[14,602,603,607,612,617,622],{"__ignoreMap":82},[86,604,605],{"class":88,"line":89},[86,606,141],{},[86,608,609],{"class":88,"line":95},[86,610,611],{},"# Fail the report step rather than silently skipping unmeasured files.\n",[86,613,614],{"class":88,"line":101},[86,615,616],{},"fail_under = 0            # the ratchet lives in CI, not here\n",[86,618,619],{"class":88,"line":107},[86,620,621],{},"precision = 1\n",[86,623,624],{"class":88,"line":113},[86,625,626],{},"sort = \"Cover\"\n",[10,628,629,630,633],{},"Setting ",[14,631,632],{},"precision = 1"," matters more than it looks: with integer precision, a change from 84.4% to 84.6% reads as no change, and a ratchet built on integers accumulates a full percentage point of silent regression before it triggers.",[19,635,637],{"id":636},"verification","Verification",[10,639,640],{},"Three checks confirm the measurement is real before anyone starts enforcing it.",[77,642,644],{"className":339,"code":643,"language":341,"meta":82,"style":82},"$ coverage debug data                 # how many data files were combined?\npath: \u002Fbuild\u002F.coverage\nhas_arcs: True\n2 files:\n  gw0: 412 lines, 118 arcs\n  gw1: 388 lines, 104 arcs\n\n$ coverage report --fail-under=0 -m   # does the file list look right?\n$ coverage html && open htmlcov\u002Findex.html\n",[14,645,646,651,656,661,666,671,676,680,685],{"__ignoreMap":82},[86,647,648],{"class":88,"line":89},[86,649,650],{},"$ coverage debug data                 # how many data files were combined?\n",[86,652,653],{"class":88,"line":95},[86,654,655],{},"path: \u002Fbuild\u002F.coverage\n",[86,657,658],{"class":88,"line":101},[86,659,660],{},"has_arcs: True\n",[86,662,663],{"class":88,"line":107},[86,664,665],{},"2 files:\n",[86,667,668],{"class":88,"line":113},[86,669,670],{},"  gw0: 412 lines, 118 arcs\n",[86,672,673],{"class":88,"line":119},[86,674,675],{},"  gw1: 388 lines, 104 arcs\n",[86,677,678],{"class":88,"line":125},[86,679,135],{"emptyLinePlaceholder":134},[86,681,682],{"class":88,"line":131},[86,683,684],{},"$ coverage report --fail-under=0 -m   # does the file list look right?\n",[86,686,687],{"class":88,"line":138},[86,688,689],{},"$ coverage html && open htmlcov\u002Findex.html\n",[10,691,692,695,696,699,700,702],{},[14,693,694],{},"has_arcs: True"," proves branch mode is active. A single data file when eight workers ran proves the combine did not happen. And a file list that includes ",[14,697,698],{},"site-packages"," proves ",[14,701,188],{}," is unset or too broad. All three are silent failures in the reported percentage, which is why they are worth checking once, at setup, and then never again.",[19,704,706],{"id":705},"enforcement-that-resists-gaming","Enforcement that resists gaming",[10,708,709],{},"An absolute threshold — \"the build fails below 80%\" — is easy to satisfy and easy to game. Two better rules, used together:",[10,711,712,715],{},[30,713,714],{},"A ratchet on the total."," Store the current percentage and fail when a pull request lowers it. The rule is directional rather than absolute, so a legacy codebase can improve gradually while new work cannot make things worse.",[10,717,718,373,721,724,725,40],{},[30,719,720],{},"Full coverage on changed lines.",[14,722,723],{},"diff-cover"," compares the coverage XML against the diff and reports only lines the pull request touched, which is the number a reviewer can act on. This is the subject of ",[45,726,728],{"href":727},"\u002Fadvanced-pytest-architecture-configuration\u002Fcoverage-measurement-and-enforcement\u002Fenforcing-diff-coverage-on-pull-requests\u002F","enforcing diff coverage on pull requests",[77,730,732],{"className":339,"code":731,"language":341,"meta":82,"style":82},"$ pytest --cov --cov-report=xml -q\n$ diff-cover coverage.xml --compare-branch=origin\u002Fmain --fail-under=100\n",[14,733,734,739],{"__ignoreMap":82},[86,735,736],{"class":88,"line":89},[86,737,738],{},"$ pytest --cov --cov-report=xml -q\n",[86,740,741],{"class":88,"line":95},[86,742,743],{},"$ diff-cover coverage.xml --compare-branch=origin\u002Fmain --fail-under=100\n",[10,745,746],{},"Both rules share a property the absolute threshold lacks: they are about the change under review, so the feedback lands on the person who can act on it, while it is still cheap to act.",[195,748,750,874],{"className":749},[198],[200,751,208,756,208,759,208,762,208,785,208,788,208,790,208,794,208,798,208,802,208,807,208,812,208,817,208,821,208,825,208,829,208,833,208,837,208,840,208,843,208,846,208,849,208,853,208,858,208,861,208,864,208,867,208,870],{"viewBox":752,"role":203,"ariaLabelledBy":753,"xmlns":207},"0 0 760 332",[754,755],"covenforce-t","covenforce-d",[210,757,758],{"id":754},"Choosing an enforcement rule",[214,760,761],{"id":755},"A decision diagram: a greenfield project can require full diff coverage, a legacy codebase should use a ratchet that forbids regressions, and a codebase mid-migration can combine a ratchet on the total with full coverage on changed lines.",[416,763,418,764,418,769,418,775,418,780,208],{},[420,765,767],{"id":766,"viewBox":423,"refX":424,"refY":425,"markerWidth":426,"markerHeight":426,"orient":427},"covenforce-a",[429,768],{"d":431,"fill":233},[420,770,772],{"id":771,"viewBox":423,"refX":424,"refY":425,"markerWidth":426,"markerHeight":426,"orient":427},"covenforce-c",[429,773],{"d":431,"fill":774},"#e07a5f",[420,776,778],{"id":777,"viewBox":423,"refX":424,"refY":425,"markerWidth":426,"markerHeight":426,"orient":427},"covenforce-s",[429,779],{"d":431,"fill":432},[420,781,783],{"id":782,"viewBox":423,"refX":424,"refY":425,"markerWidth":426,"markerHeight":426,"orient":427},"covenforce-g",[429,784],{"d":431,"fill":241},[218,786],{"x":220,"y":220,"width":221,"height":787,"rx":223,"fill":224},"332",[226,789,758],{"x":228,"y":229,"textAnchor":230,"fontSize":231,"fontWeight":232,"fill":233},[791,792],"polygon",{"points":793,"fill":260,"stroke":233,"strokeWidth":442},"380,50 590,108 380,166 170,108",[226,795,797],{"x":228,"y":796,"textAnchor":230,"fontSize":247,"fontWeight":232,"fill":233},"104","What is the state of",[226,799,801],{"x":228,"y":800,"textAnchor":230,"fontSize":247,"fontWeight":232,"fill":233},"121","the codebase?",[429,803],{"d":804,"fill":805,"stroke":432,"strokeWidth":442,"markerEnd":806},"M380 166 L380 192 L133 192 L133 206","none","url(#covenforce-s)",[226,808,811],{"x":809,"y":291,"textAnchor":230,"fontSize":452,"fontWeight":232,"fill":810},"133","#2a5f49","greenfield",[218,813],{"x":236,"y":462,"width":814,"height":815,"rx":247,"fill":224,"stroke":432,"strokeWidth":816},"217","84","2",[226,818,820],{"x":809,"y":819,"textAnchor":230,"fontSize":447,"fontWeight":232,"fill":233},"245","diff coverage 100%",[226,822,824],{"x":809,"y":823,"textAnchor":230,"fontSize":452,"fill":233},"262","every new line covered",[226,826,828],{"x":809,"y":827,"textAnchor":230,"fontSize":452,"fill":233},"277","no legacy debt",[429,830],{"d":831,"fill":805,"stroke":241,"strokeWidth":442,"markerEnd":832},"M380 166 L380 192 L380 192 L380 206","url(#covenforce-g)",[226,834,836],{"x":228,"y":291,"textAnchor":230,"fontSize":452,"fontWeight":232,"fill":835},"#8a5a00","legacy",[218,838],{"x":839,"y":462,"width":814,"height":815,"rx":247,"fill":224,"stroke":241,"strokeWidth":816},"271",[226,841,842],{"x":228,"y":819,"textAnchor":230,"fontSize":447,"fontWeight":232,"fill":233},"ratchet only",[226,844,845],{"x":228,"y":823,"textAnchor":230,"fontSize":452,"fill":233},"must not fall",[226,847,848],{"x":228,"y":827,"textAnchor":230,"fontSize":452,"fill":233},"improves gradually",[429,850],{"d":851,"fill":805,"stroke":774,"strokeWidth":442,"markerEnd":852},"M380 166 L380 192 L627 192 L627 206","url(#covenforce-c)",[226,854,857],{"x":855,"y":291,"textAnchor":230,"fontSize":452,"fontWeight":232,"fill":856},"627","#8f3d22","mid-migration",[218,859],{"x":860,"y":462,"width":814,"height":815,"rx":247,"fill":224,"stroke":774,"strokeWidth":816},"519",[226,862,863],{"x":855,"y":819,"textAnchor":230,"fontSize":447,"fontWeight":232,"fill":233},"both rules",[226,865,866],{"x":855,"y":823,"textAnchor":230,"fontSize":452,"fill":233},"ratchet plus diff",[226,868,869],{"x":855,"y":827,"textAnchor":230,"fontSize":452,"fill":233},"the usual answer",[226,871,873],{"x":228,"y":872,"textAnchor":230,"fontSize":452,"fontStyle":501,"fill":233},"320","Neither rule measures assertion quality — that needs mutation testing.",[320,875,876],{},"Enforce against the change, not against a number: absolute thresholds are satisfied by tests that assert nothing.",[19,878,880],{"id":879},"coverage-in-the-presence-of-subprocesses-and-threads","Coverage in the presence of subprocesses and threads",[10,882,883],{},"Two execution models break naive coverage measurement, and both are common in real suites.",[10,885,886,889,890,893,894,897,898,900,901,904,905,908],{},[30,887,888],{},"Subprocesses."," Code launched with ",[14,891,892],{},"subprocess.run"," or ",[14,895,896],{},"multiprocessing"," runs in a fresh interpreter with no trace function installed, so everything it executes is invisible. ",[14,899,58],{}," solves this with a process-startup hook, enabled by an environment variable and a ",[14,902,903],{},".pth"," file that the ",[14,906,907],{},"coverage"," package installs:",[77,910,914],{"className":911,"code":912,"language":913,"meta":82,"style":82},"language-python shiki shiki-themes github-light github-dark","# conftest.py\nimport os\nimport pytest\n\n@pytest.fixture(autouse=True, scope=\"session\")\ndef _cover_subprocesses():\n    # COVERAGE_PROCESS_START points child interpreters at the same config file,\n    # and coverage's .pth hook starts measurement before the child's main module runs.\n    os.environ[\"COVERAGE_PROCESS_START\"] = str(pathlib.Path(\"pyproject.toml\").resolve())\n    yield\n","python",[14,915,916,921,926,931,935,940,945,950,955,960],{"__ignoreMap":82},[86,917,918],{"class":88,"line":89},[86,919,920],{},"# conftest.py\n",[86,922,923],{"class":88,"line":95},[86,924,925],{},"import os\n",[86,927,928],{"class":88,"line":101},[86,929,930],{},"import pytest\n",[86,932,933],{"class":88,"line":107},[86,934,135],{"emptyLinePlaceholder":134},[86,936,937],{"class":88,"line":113},[86,938,939],{},"@pytest.fixture(autouse=True, scope=\"session\")\n",[86,941,942],{"class":88,"line":119},[86,943,944],{},"def _cover_subprocesses():\n",[86,946,947],{"class":88,"line":125},[86,948,949],{},"    # COVERAGE_PROCESS_START points child interpreters at the same config file,\n",[86,951,952],{"class":88,"line":131},[86,953,954],{},"    # and coverage's .pth hook starts measurement before the child's main module runs.\n",[86,956,957],{"class":88,"line":138},[86,958,959],{},"    os.environ[\"COVERAGE_PROCESS_START\"] = str(pathlib.Path(\"pyproject.toml\").resolve())\n",[86,961,962],{"class":88,"line":144},[86,963,964],{},"    yield\n",[10,966,967,968,971],{},"With ",[14,969,970],{},"parallel = true"," each child writes its own data file, and the combine step at the end picks them all up. Without the environment variable the children are simply not measured, and the missing lines look like untested code.",[10,973,974,373,977,979,980,983],{},[30,975,976],{},"Threads.",[14,978,58],{}," traces threads automatically because the trace function is installed per thread by ",[14,981,982],{},"threading.settrace",", but only for threads started after coverage begins. A thread started at import time — a background poller in a module-level singleton, for example — escapes measurement, and the fix is to start it lazily rather than to configure coverage differently.",[10,985,986,989,990,992,993,995],{},[30,987,988],{},"Native extensions and generated code."," Neither is traceable by ",[14,991,58],{},", which is a Python-level tool. Cython modules compiled with tracing enabled are the exception; everything else should be excluded from ",[14,994,188],{}," so it does not appear as permanently uncovered and depress the number in a way nobody can act on.",[195,997,999,1071],{"className":998},[198],[200,1000,208,1005,208,1008,208,1011,208,1014,208,1016,208,1019,208,1023,208,1027,208,1032,208,1035,208,1037,208,1041,208,1044,208,1047,208,1049,208,1052,208,1055,208,1058,208,1060,208,1064,208,1067],{"viewBox":1001,"role":203,"ariaLabelledBy":1002,"xmlns":207},"0 0 760 338",[1003,1004],"covexec-t","covexec-d",[210,1006,1007],{"id":1003},"How each execution model is measured",[214,1009,1010],{"id":1004},"A stack of four execution models - the main process, threads, subprocesses and native extensions - with whether coverage measures each by default and what configuration is required.",[218,1012],{"x":220,"y":220,"width":221,"height":1013,"rx":223,"fill":224},"338",[226,1015,1007],{"x":228,"y":229,"textAnchor":230,"fontSize":231,"fontWeight":232,"fill":233},[218,1017],{"x":229,"y":1018,"width":232,"height":237,"rx":240,"fill":260,"stroke":432,"strokeWidth":442},"56",[218,1020],{"x":229,"y":1018,"width":1021,"height":237,"rx":1022,"fill":432},"8","4",[226,1024,1026],{"x":237,"y":1025,"fontSize":447,"fontWeight":232,"fill":233},"86","main process",[226,1028,1031],{"x":1029,"y":1025,"textAnchor":1030,"fontSize":452,"fill":233},"714","end","measured automatically once pytest-cov starts",[218,1033],{"x":229,"y":1034,"width":232,"height":237,"rx":240,"fill":224,"stroke":432,"strokeWidth":442},"120",[218,1036],{"x":229,"y":1034,"width":1021,"height":237,"rx":1022,"fill":432},[226,1038,1040],{"x":237,"y":1039,"fontSize":447,"fontWeight":232,"fill":233},"150","threads started later",[226,1042,1043],{"x":1029,"y":1039,"textAnchor":1030,"fontSize":452,"fill":233},"measured — the trace hook applies per thread",[218,1045],{"x":229,"y":1046,"width":232,"height":237,"rx":240,"fill":260,"stroke":241,"strokeWidth":442},"184",[218,1048],{"x":229,"y":1046,"width":1021,"height":237,"rx":1022,"fill":241},[226,1050,1051],{"x":237,"y":462,"fontSize":447,"fontWeight":232,"fill":233},"subprocesses",[226,1053,1054],{"x":1029,"y":462,"textAnchor":1030,"fontSize":452,"fill":233},"needs COVERAGE_PROCESS_START and parallel = true",[218,1056],{"x":229,"y":1057,"width":232,"height":237,"rx":240,"fill":224,"stroke":774,"strokeWidth":442},"248",[218,1059],{"x":229,"y":1057,"width":1021,"height":237,"rx":1022,"fill":774},[226,1061,1063],{"x":237,"y":1062,"fontSize":447,"fontWeight":232,"fill":233},"278","native \u002F Cython code",[226,1065,1066],{"x":1029,"y":1062,"textAnchor":1030,"fontSize":452,"fill":233},"not measurable — exclude it from source",[226,1068,1070],{"x":228,"y":1069,"textAnchor":230,"fontSize":452,"fontStyle":501,"fill":233},"330","A silently unmeasured subprocess is the most common cause of a mysteriously low number.",[320,1072,1073],{},"Only the last row is a hard limit; the middle two are configuration, and both fail silently as apparently untested code.",[10,1075,1076],{},"Once these are configured, re-run the three verification checks above. The count of combined data files should equal the number of workers plus the number of subprocess groups, and any file you expected to see measured should appear in the report with a non-zero statement count.",[19,1078,1080],{"id":1079},"troubleshooting","Troubleshooting",[1082,1083,1084,1100],"table",{},[1085,1086,1087],"thead",{},[1088,1089,1090,1094,1097],"tr",{},[1091,1092,1093],"th",{},"Symptom",[1091,1095,1096],{},"Root cause",[1091,1098,1099],{},"Fix",[1101,1102,1103,1124,1140,1158,1172],"tbody",{},[1088,1104,1105,1112,1115],{},[1106,1107,1108,1109],"td",{},"0% reported under ",[14,1110,1111],{},"-n 8",[1106,1113,1114],{},"coverage started outside pytest; data files never combined",[1106,1116,1117,1118,1121,1122],{},"run through ",[14,1119,1120],{},"pytest --cov",", set ",[14,1123,970],{},[1088,1125,1126,1129,1134],{},[1106,1127,1128],{},"Percentage includes dependencies",[1106,1130,1131,1133],{},[14,1132,188],{}," unset, so everything imported is measured",[1106,1135,1136,1137,1139],{},"set ",[14,1138,188],{}," to your package",[1088,1141,1142,1145,1148],{},[1106,1143,1144],{},"Coverage of subprocess code missing",[1106,1146,1147],{},"subprocesses not traced",[1106,1149,1150,1151,1154,1155],{},"add ",[14,1152,1153],{},"concurrency = [\"multiprocessing\"]"," and ",[14,1156,1157],{},"sigterm = true",[1088,1159,1160,1163,1166],{},[1106,1161,1162],{},"Number moves when tests are reordered",[1106,1164,1165],{},"dynamic contexts writing to a shared file",[1106,1167,1168,1169,1171],{},"give each worker its own ",[14,1170,390],{}," suffix",[1088,1173,1174,1180,1183],{},[1106,1175,1176,1179],{},[14,1177,1178],{},"if TYPE_CHECKING"," blocks reported uncovered",[1106,1181,1182],{},"they never execute at runtime",[1106,1184,1185,1186],{},"list them in ",[14,1187,1188],{},"exclude_also",[19,1190,1192],{"id":1191},"what-coverage-cannot-tell-you","What coverage cannot tell you",[10,1194,1195],{},"Every coverage number carries an implicit claim — \"this code is tested\" — that the measurement does not support. Being explicit about the gap prevents the two failure modes that follow from over-trusting it.",[10,1197,1198,1199,1154,1202,1205],{},"The first is the assertion-free test. Coverage records execution, so a test that calls a function and asserts nothing produces exactly the same coverage as one that verifies every output. A suite can reach ninety-five percent while proving almost nothing, and the number gives no hint. Mutation testing closes this gap by changing the code and checking that some test notices: ",[14,1200,1201],{},"mutmut",[14,1203,1204],{},"cosmic-ray"," are the usual tools, and both are slow enough to belong in a nightly job rather than in a pull-request gate. Running mutation testing on the ten files with the highest change frequency is a cheap approximation that finds most of the value.",[10,1207,1208],{},"The second is the coverage-driven test. When a threshold is the target, the cheapest way to reach it is a test that imports a module and executes its lines without asserting anything meaningful — and such tests are worse than no tests, because they cost maintenance and provide false confidence. This is precisely why the enforcement rules above are directional (do not regress) and scoped to the diff, rather than absolute.",[10,1210,1211,1212,1214,1215,1217,1218,1221],{},"There is also a category of code that should not be covered. Defensive branches for conditions that cannot occur in the test environment, platform-specific paths, and ",[14,1213,1178],{}," blocks are all legitimately unexercised. Excluding them with ",[14,1216,1188],{}," keeps the report honest, and each exclusion should be narrow enough that a reviewer can tell what was excluded and why. A broad ",[14,1219,1220],{},"# pragma: no cover"," on a whole class is where honest exclusion becomes hiding.",[10,1223,1224,1225,1229,1230,1234],{},"Used with those limits in mind, coverage is still the highest-value cheap signal a suite produces: it is the only automated way to discover code that no test touches at all, and that list — not the percentage — is what should reach a reviewer. Pair it with the ",[45,1226,1228],{"href":1227},"\u002Fproperty-based-fuzz-testing-strategies\u002F","property-based testing track"," for the inputs your example tests never generate, and with ",[45,1231,1233],{"href":1232},"\u002Fadvanced-mocking-test-doubles-in-python\u002F","mutation-style scrutiny"," of the doubles that might be absorbing assertions before they reach real code.",[19,1236,1238],{"id":1237},"rolling-coverage-out-on-an-existing-codebase","Rolling coverage out on an existing codebase",[10,1240,1241],{},"Introducing coverage to a repository that has never measured it produces one predictable reaction — the number is lower than anyone expected — and one predictable mistake: setting a target and writing tests to reach it. A three-stage rollout avoids both.",[10,1243,1244,1247],{},[30,1245,1246],{},"Stage one: measure and publish, enforce nothing."," Run the suite with coverage in CI, upload the HTML report as an artefact, and say nothing about the percentage for a sprint. The value in this stage is the list of files with zero coverage, which is almost always a surprise and almost always includes something important — an error-handling module, a serialization path, a migration helper.",[10,1249,1250,1253],{},[30,1251,1252],{},"Stage two: ratchet the total."," Record the current figure and fail when it falls. This is a directional rule, so it can be enabled with no work and no debate, and it stops the situation getting worse while the team decides what to do about the existing gaps.",[77,1255,1257],{"className":339,"code":1256,"language":341,"meta":82,"style":82},"# A minimal ratchet, no third-party service required\n$ coverage json -o coverage.json\n$ python -c \"\nimport json, sys\ncur = json.load(open('coverage.json'))['totals']['percent_covered']\nprev = float(open('.coverage-baseline').read())\nprint(f'{cur:.1f}% (baseline {prev:.1f}%)')\nsys.exit(0 if cur >= prev - 0.05 else 1)\n\"\n",[14,1258,1259,1264,1269,1274,1279,1284,1289,1294,1299],{"__ignoreMap":82},[86,1260,1261],{"class":88,"line":89},[86,1262,1263],{},"# A minimal ratchet, no third-party service required\n",[86,1265,1266],{"class":88,"line":95},[86,1267,1268],{},"$ coverage json -o coverage.json\n",[86,1270,1271],{"class":88,"line":101},[86,1272,1273],{},"$ python -c \"\n",[86,1275,1276],{"class":88,"line":107},[86,1277,1278],{},"import json, sys\n",[86,1280,1281],{"class":88,"line":113},[86,1282,1283],{},"cur = json.load(open('coverage.json'))['totals']['percent_covered']\n",[86,1285,1286],{"class":88,"line":119},[86,1287,1288],{},"prev = float(open('.coverage-baseline').read())\n",[86,1290,1291],{"class":88,"line":125},[86,1292,1293],{},"print(f'{cur:.1f}% (baseline {prev:.1f}%)')\n",[86,1295,1296],{"class":88,"line":131},[86,1297,1298],{},"sys.exit(0 if cur >= prev - 0.05 else 1)\n",[86,1300,1301],{"class":88,"line":138},[86,1302,1303],{},"\"\n",[10,1305,1306,1309],{},[30,1307,1308],{},"Stage three: require diff coverage."," Once the ratchet has held for a few weeks, add the per-change gate, which is where the behaviour actually changes: new code arrives tested, and the total drifts upward on its own without anyone writing coverage-driven tests.",[10,1311,1312],{},"The order matters because each stage produces the information the next one needs. Skipping to stage three on a codebase with unmeasured subprocesses or a broken parallel combine means the gate fails on correct code, and the first thing the team learns about coverage is that it cannot be trusted.",[195,1314,1316,1427],{"className":1315},[198],[200,1317,208,1322,208,1325,208,1328,208,1331,208,1333,208,1336,208,1342,208,1347,208,1351,208,1355,208,1360,208,1366,208,1368,208,1371,208,1376,208,1380,208,1384,208,1389,208,1392,208,1395,208,1399,208,1402,208,1405,208,1408,208,1411,208,1414,208,1418,208,1421,208,1424],{"viewBox":1318,"role":203,"ariaLabelledBy":1319,"xmlns":207},"0 0 760 268",[1320,1321],"covrollout-t","covrollout-d",[210,1323,1324],{"id":1320},"Three stages of a coverage rollout",[214,1326,1327],{"id":1321},"A timeline of the rollout: measure and publish without enforcement, add a ratchet that forbids regression, then require full coverage on changed lines, with the outcome of each stage noted.",[218,1329],{"x":220,"y":220,"width":221,"height":1330,"rx":223,"fill":224},"268",[226,1332,1324],{"x":228,"y":229,"textAnchor":230,"fontSize":231,"fontWeight":232,"fill":233},[88,1334],{"x1":239,"y1":1039,"x2":1335,"y2":1039,"stroke":233,"strokeWidth":816},"720",[218,1337],{"x":1338,"y":1339,"width":240,"height":1340,"rx":1341,"fill":233},"35","136","28","3",[218,1343],{"x":247,"y":1344,"width":1345,"height":1346,"rx":240,"fill":224,"stroke":233,"strokeWidth":442},"50","209","72",[226,1348,1350],{"x":264,"y":1349,"textAnchor":230,"fontSize":452,"fontWeight":232,"fill":233},"74","sprint 1",[226,1352,1354],{"x":264,"y":1353,"textAnchor":230,"fontSize":447,"fontWeight":232,"fill":233},"89","measure only",[226,1356,1359],{"x":264,"y":1357,"textAnchor":230,"fontSize":1358,"fill":233},"106","10.5","find the zero-coverage files",[88,1361],{"x1":239,"y1":1362,"x2":239,"y2":1363,"stroke":233,"strokeWidth":1364,"strokeDashArray":1365},"124","134","1.4",[1022,1341],[218,1367],{"x":823,"y":1339,"width":240,"height":1340,"rx":1341,"fill":432},[218,1369],{"x":1370,"y":456,"width":1345,"height":1346,"rx":240,"fill":224,"stroke":432,"strokeWidth":442},"162",[226,1372,1375],{"x":1373,"y":1374,"textAnchor":230,"fontSize":452,"fontWeight":232,"fill":233},"267","200","sprint 2",[226,1377,1379],{"x":1373,"y":1378,"textAnchor":230,"fontSize":447,"fontWeight":232,"fill":233},"215","ratchet the total",[226,1381,1383],{"x":1373,"y":1382,"textAnchor":230,"fontSize":1358,"fill":233},"232","stop it getting worse",[88,1385],{"x1":1373,"y1":1386,"x2":1373,"y2":1387,"stroke":432,"strokeWidth":1364,"strokeDashArray":1388},"166","174",[1022,1341],[218,1390],{"x":1391,"y":1339,"width":240,"height":1340,"rx":1341,"fill":241},"488",[218,1393],{"x":1394,"y":1344,"width":1345,"height":1346,"rx":240,"fill":224,"stroke":241,"strokeWidth":442},"389",[226,1396,1398],{"x":1397,"y":1349,"textAnchor":230,"fontSize":452,"fontWeight":232,"fill":233},"493","sprint 3",[226,1400,1401],{"x":1397,"y":1353,"textAnchor":230,"fontSize":447,"fontWeight":232,"fill":233},"gate the diff",[226,1403,1404],{"x":1397,"y":1357,"textAnchor":230,"fontSize":1358,"fill":233},"new code arrives tested",[88,1406],{"x1":1397,"y1":1362,"x2":1397,"y2":1363,"stroke":241,"strokeWidth":1364,"strokeDashArray":1407},[1022,1341],[218,1409],{"x":1410,"y":1339,"width":240,"height":1340,"rx":1341,"fill":774},"715",[218,1412],{"x":1413,"y":456,"width":1345,"height":1346,"rx":240,"fill":224,"stroke":774,"strokeWidth":442},"539",[226,1415,1417],{"x":1416,"y":1374,"textAnchor":230,"fontSize":452,"fontWeight":232,"fill":233},"644","ongoing",[226,1419,1420],{"x":1416,"y":1378,"textAnchor":230,"fontSize":447,"fontWeight":232,"fill":233},"mutation spot-checks",[226,1422,1423],{"x":1416,"y":1382,"textAnchor":230,"fontSize":1358,"fill":233},"verify assertions exist",[88,1425],{"x1":1335,"y1":1386,"x2":1335,"y2":1387,"stroke":774,"strokeWidth":1364,"strokeDashArray":1426},[1022,1341],[320,1428,1429],{},"Each stage supplies the confidence the next one needs; skipping to enforcement on an unverified measurement is what makes teams distrust coverage.",[19,1431,1433],{"id":1432},"frequently-asked-questions","Frequently Asked Questions",[10,1435,1436,1439,1440,1442,1443,1445],{},[30,1437,1438],{},"Is line coverage or branch coverage the right target?","\nBranch coverage. Line coverage counts a line as covered when any path through it runs, so an ",[14,1441,70],{}," statement with no ",[14,1444,74],{}," is fully covered by a test that never takes the false path. Branch coverage counts both outcomes and is the only measure that reflects decision coverage.",[10,1447,1448,1451],{},[30,1449,1450],{},"What coverage percentage should a project require?","\nA ratchet rather than a number: require that coverage does not fall, and require full coverage on changed lines. An absolute target invites tests written to touch code rather than to assert behaviour.",[10,1453,1454,1457,1458,1461],{},[30,1455,1456],{},"Why does coverage drop when tests run in parallel?","\nBecause each xdist worker writes its own data file and they must be combined. ",[14,1459,1460],{},"pytest-cov"," does that automatically when it owns the run; starting coverage outside pytest leaves the files uncombined and the report incomplete.",[10,1463,1464,1467,1468,1154,1470,1472],{},[30,1465,1466],{},"Does coverage measure test quality?","\nNo. It measures which lines executed, not whether anything was asserted. A suite with no assertions can reach full coverage, which is why mutation testing exists as the complementary measure.\nA closing note on tooling choice: everything above is standard ",[14,1469,58],{},[14,1471,1460],{},", with no hosted service required. Services add pull-request annotations and historical charts, which are genuinely useful at scale, but none of them fix a broken measurement — a service fed by an uncombined parallel run displays the same wrong number more attractively. Get the local pipeline correct first, then decide whether the presentation layer is worth its cost.",[19,1474,1476],{"id":1475},"related-guides","Related guides",[24,1478,1479,1486,1492,1498],{},[27,1480,1481,1485],{},[45,1482,1484],{"href":1483},"\u002Fadvanced-pytest-architecture-configuration\u002Fcoverage-measurement-and-enforcement\u002Fwhy-pytest-cov-reports-zero-under-xdist\u002F","Why pytest-cov reports 0% under xdist"," — the combine step, in detail, with the three configurations that break it.",[27,1487,1488,1491],{},[45,1489,1490],{"href":727},"Enforcing diff coverage on pull requests"," — turning the report into a review-time gate.",[27,1493,1494,1497],{},[45,1495,1496],{"href":47},"pytest-xdist vs pytest-parallel performance comparison"," — the parallel execution model coverage has to follow.",[27,1499,1500,1504],{},[45,1501,1503],{"href":1502},"\u002Fadvanced-pytest-architecture-configuration\u002Fpytest-configuration-best-practices\u002F","Pytest configuration best practices"," — where the coverage settings belong alongside the rest.",[10,1506,1507,1508],{},"← Back to ",[45,1509,1511],{"href":1510},"\u002Fadvanced-pytest-architecture-configuration\u002F","Advanced Pytest Architecture & Configuration",[1513,1514,1515],"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":82,"searchDepth":95,"depth":95,"links":1517},[1518,1519,1520,1521,1522,1523,1524,1525,1526,1527,1528,1529],{"id":21,"depth":95,"text":22},{"id":52,"depth":95,"text":53},{"id":325,"depth":95,"text":326},{"id":508,"depth":95,"text":509},{"id":636,"depth":95,"text":637},{"id":705,"depth":95,"text":706},{"id":879,"depth":95,"text":880},{"id":1079,"depth":95,"text":1080},{"id":1191,"depth":95,"text":1192},{"id":1237,"depth":95,"text":1238},{"id":1432,"depth":95,"text":1433},{"id":1475,"depth":95,"text":1476},"Measure Python test coverage that means something: branch coverage, per-worker data files under xdist, contextual coverage, and enforcement that resists gaming.","md",{"slug":1533,"type":1534,"breadcrumb":1535,"datePublished":1536,"dateModified":1536,"faq":1537,"howto":1546},"coverage-measurement-and-enforcement","topic","Coverage","2026-08-01",[1538,1540,1542,1544],{"q":1438,"a":1539},"Branch coverage. Line coverage counts a line as covered when any path through it runs, so an if statement with no else is fully covered by a test that never takes the false path. Branch coverage counts both outcomes and is the only measure that reflects decision coverage.",{"q":1450,"a":1541},"A ratchet rather than a number: require that coverage does not fall, and require full coverage on changed lines. An absolute target invites tests written to touch code rather than to assert behaviour.",{"q":1456,"a":1543},"Because each xdist worker writes its own data file and they must be combined. pytest-cov does that automatically when it owns the run; starting coverage outside pytest leaves the files uncombined and the report incomplete.",{"q":1466,"a":1545},"No. It measures which lines executed, not whether anything was asserted. A suite with no assertions can reach full coverage, which is why mutation testing exists as the complementary measure.",{"name":1547,"description":1548,"steps":1549},"How to measure and enforce Python test coverage","Configure branch coverage, combine parallel data files, and enforce a ratchet that cannot be gamed.",[1550,1553,1556,1559],{"name":1551,"text":1552},"Enable branch coverage","Turn on branch measurement in the coverage configuration so both outcomes of every decision are counted.",{"name":1554,"text":1555},"Scope the measurement to your own code","Restrict source to the packages under test and omit generated or vendored files.",{"name":1557,"text":1558},"Combine data from parallel workers","Let pytest-cov own the run so per-worker data files are combined into one report.",{"name":1560,"text":1561},"Enforce a ratchet in CI","Fail when total coverage falls or when changed lines are uncovered, rather than on an absolute threshold.","\u002Fadvanced-pytest-architecture-configuration\u002Fcoverage-measurement-and-enforcement",{"title":5,"description":1530},"advanced-pytest-architecture-configuration\u002Fcoverage-measurement-and-enforcement\u002Findex","9lYSsFNTd6sTDW0w913cQ47UYli6sQR4O3EAAPenbto",1785613403104]