[{"data":1,"prerenderedAt":1201},["ShallowReactive",2],{"page-\u002Ftesting-async-and-concurrent-python\u002Fpytest-asyncio-in-depth\u002Ftesting-async-generators-and-context-managers\u002F":3},{"id":4,"title":5,"body":6,"description":1163,"extension":1164,"meta":1165,"navigation":92,"path":1197,"seo":1198,"stem":1199,"__hash__":1200},"content\u002Ftesting-async-and-concurrent-python\u002Fpytest-asyncio-in-depth\u002Ftesting-async-generators-and-context-managers\u002Findex.md","Testing Async Generators and Context Managers",{"type":7,"value":8,"toc":1152},"minimark",[9,25,30,63,67,70,299,437,441,464,471,475,553,557,563,720,731,845,849,852,859,897,904,962,969,972,976,995,1051,1057,1068,1072,1088,1097,1111,1115,1143,1148],[10,11,12,13,17,18,21,22,24],"p",{},"An async generator that streams rows and closes its cursor in a ",[14,15,16],"code",{},"finally"," looks correct and is routinely broken: a caller that breaks out of the ",[14,19,20],{},"async for"," leaves the generator suspended, the ",[14,23,16],{}," does not run, and the cursor stays open until garbage collection — possibly after the loop has closed, which raises during interpreter shutdown. Tests for these objects have to drive the whole lifecycle, including the paths that end early.",[26,27,29],"h2",{"id":28},"prerequisites","Prerequisites",[31,32,33,45,56],"ul",{},[34,35,36,37,40,41,44],"li",{},"Python 3.10+ for ",[14,38,39],{},"contextlib.aclosing","; 3.7+ for ",[14,42,43],{},"asynccontextmanager",".",[34,46,47,50,51,44],{},[14,48,49],{},"pytest >= 8.0"," with a runner configured per ",[52,53,55],"a",{"href":54},"\u002Ftesting-async-and-concurrent-python\u002Fpytest-asyncio-in-depth\u002F","pytest-asyncio in depth",[34,57,58,59,62],{},"An understanding that cancellation and ",[14,60,61],{},"GeneratorExit"," both arrive as exceptions at the suspension point.",[26,64,66],{"id":65},"solution","Solution",[10,68,69],{},"Test three lifecycles for a generator — full consumption, early exit, and failure inside the body — and assert on cleanup each time.",[71,72,77],"pre",{"className":73,"code":74,"language":75,"meta":76,"style":76},"language-python shiki shiki-themes github-light github-dark","import contextlib\n\nimport pytest\n\n\nasync def stream_rows(pool):\n    \"\"\"Yields rows, and must always release the connection.\"\"\"\n    connection = await pool.acquire()\n    try:\n        async for row in connection.cursor(\"SELECT id FROM widget\"):\n            yield row\n    finally:\n        await pool.release(connection)      # must run on every exit path\n\n\nasync def test_full_consumption_releases_the_connection(pool):\n    rows = [row async for row in stream_rows(pool)]\n    assert len(rows) == 3\n    assert pool.in_use == 0\n\n\nasync def test_early_exit_releases_the_connection(pool):\n    # aclosing() guarantees aclose() at the end of the block, which throws\n    # GeneratorExit into the suspended generator and runs its finally.\n    async with contextlib.aclosing(stream_rows(pool)) as stream:\n        async for _row in stream:\n            break                            # abandon after one row\n\n    assert pool.in_use == 0                  # fails without aclosing()\n\n\nasync def test_failure_inside_the_body_still_releases(pool):\n    with pytest.raises(ValueError):\n        async with contextlib.aclosing(stream_rows(pool)) as stream:\n            async for _row in stream:\n                raise ValueError(\"consumer blew up\")\n\n    assert pool.in_use == 0\n","python","",[14,78,79,87,94,100,105,110,116,122,128,134,140,146,152,158,163,168,174,180,186,192,197,202,208,214,220,226,232,238,243,249,254,259,265,271,277,283,289,294],{"__ignoreMap":76},[80,81,84],"span",{"class":82,"line":83},"line",1,[80,85,86],{},"import contextlib\n",[80,88,90],{"class":82,"line":89},2,[80,91,93],{"emptyLinePlaceholder":92},true,"\n",[80,95,97],{"class":82,"line":96},3,[80,98,99],{},"import pytest\n",[80,101,103],{"class":82,"line":102},4,[80,104,93],{"emptyLinePlaceholder":92},[80,106,108],{"class":82,"line":107},5,[80,109,93],{"emptyLinePlaceholder":92},[80,111,113],{"class":82,"line":112},6,[80,114,115],{},"async def stream_rows(pool):\n",[80,117,119],{"class":82,"line":118},7,[80,120,121],{},"    \"\"\"Yields rows, and must always release the connection.\"\"\"\n",[80,123,125],{"class":82,"line":124},8,[80,126,127],{},"    connection = await pool.acquire()\n",[80,129,131],{"class":82,"line":130},9,[80,132,133],{},"    try:\n",[80,135,137],{"class":82,"line":136},10,[80,138,139],{},"        async for row in connection.cursor(\"SELECT id FROM widget\"):\n",[80,141,143],{"class":82,"line":142},11,[80,144,145],{},"            yield row\n",[80,147,149],{"class":82,"line":148},12,[80,150,151],{},"    finally:\n",[80,153,155],{"class":82,"line":154},13,[80,156,157],{},"        await pool.release(connection)      # must run on every exit path\n",[80,159,161],{"class":82,"line":160},14,[80,162,93],{"emptyLinePlaceholder":92},[80,164,166],{"class":82,"line":165},15,[80,167,93],{"emptyLinePlaceholder":92},[80,169,171],{"class":82,"line":170},16,[80,172,173],{},"async def test_full_consumption_releases_the_connection(pool):\n",[80,175,177],{"class":82,"line":176},17,[80,178,179],{},"    rows = [row async for row in stream_rows(pool)]\n",[80,181,183],{"class":82,"line":182},18,[80,184,185],{},"    assert len(rows) == 3\n",[80,187,189],{"class":82,"line":188},19,[80,190,191],{},"    assert pool.in_use == 0\n",[80,193,195],{"class":82,"line":194},20,[80,196,93],{"emptyLinePlaceholder":92},[80,198,200],{"class":82,"line":199},21,[80,201,93],{"emptyLinePlaceholder":92},[80,203,205],{"class":82,"line":204},22,[80,206,207],{},"async def test_early_exit_releases_the_connection(pool):\n",[80,209,211],{"class":82,"line":210},23,[80,212,213],{},"    # aclosing() guarantees aclose() at the end of the block, which throws\n",[80,215,217],{"class":82,"line":216},24,[80,218,219],{},"    # GeneratorExit into the suspended generator and runs its finally.\n",[80,221,223],{"class":82,"line":222},25,[80,224,225],{},"    async with contextlib.aclosing(stream_rows(pool)) as stream:\n",[80,227,229],{"class":82,"line":228},26,[80,230,231],{},"        async for _row in stream:\n",[80,233,235],{"class":82,"line":234},27,[80,236,237],{},"            break                            # abandon after one row\n",[80,239,241],{"class":82,"line":240},28,[80,242,93],{"emptyLinePlaceholder":92},[80,244,246],{"class":82,"line":245},29,[80,247,248],{},"    assert pool.in_use == 0                  # fails without aclosing()\n",[80,250,252],{"class":82,"line":251},30,[80,253,93],{"emptyLinePlaceholder":92},[80,255,257],{"class":82,"line":256},31,[80,258,93],{"emptyLinePlaceholder":92},[80,260,262],{"class":82,"line":261},32,[80,263,264],{},"async def test_failure_inside_the_body_still_releases(pool):\n",[80,266,268],{"class":82,"line":267},33,[80,269,270],{},"    with pytest.raises(ValueError):\n",[80,272,274],{"class":82,"line":273},34,[80,275,276],{},"        async with contextlib.aclosing(stream_rows(pool)) as stream:\n",[80,278,280],{"class":82,"line":279},35,[80,281,282],{},"            async for _row in stream:\n",[80,284,286],{"class":82,"line":285},36,[80,287,288],{},"                raise ValueError(\"consumer blew up\")\n",[80,290,292],{"class":82,"line":291},37,[80,293,93],{"emptyLinePlaceholder":92},[80,295,297],{"class":82,"line":296},38,[80,298,191],{},[300,301,304,426],"figure",{"className":302},[303],"diagram",[305,306,313,314,313,318,313,322,313,330,313,340,313,350,313,356,313,362,313,366,313,370,313,375,313,379,313,384,313,387,313,391,313,394,313,398,313,401,313,404,313,407,313,411,313,415,313,418,313,421,313,423],"svg",{"viewBox":307,"role":308,"ariaLabelledBy":309,"xmlns":312},"0 0 820 268","img",[310,311],"agen-t","agen-d","http:\u002F\u002Fwww.w3.org\u002F2000\u002Fsvg","\n  ",[315,316,317],"title",{"id":310},"Three exit paths from an async generator",[319,320,321],"desc",{"id":311},"Three columns. Full consumption runs the generator to exhaustion and its finally block executes. Early exit without aclosing leaves the generator suspended so cleanup is deferred to garbage collection. Early exit inside an aclosing block throws GeneratorExit into the generator immediately, so cleanup runs at the end of the block.",[323,324],"rect",{"x":325,"y":325,"width":326,"height":327,"rx":328,"fill":329},"0","820","268","14","#fffdf8",[331,332,339],"text",{"x":333,"y":334,"textAnchor":335,"fontSize":336,"fontWeight":337,"fill":338},"410","28","middle","16","700","#3d405b","Where the finally block runs, and when",[323,341],{"x":342,"y":343,"width":344,"height":345,"rx":346,"fill":347,"stroke":348,"strokeWidth":349},"26","52","244","188","12","#e6f0ea","#81b29a","2",[331,351,355],{"x":352,"y":353,"textAnchor":335,"fontSize":354,"fontWeight":337,"fill":338},"148","78","12.5","consumed fully",[331,357,361],{"x":358,"y":359,"fontSize":360,"fill":338},"44","106","11","loop runs to exhaustion",[331,363,365],{"x":358,"y":364,"fontSize":360,"fill":338},"130","StopAsyncIteration raised",[331,367,369],{"x":358,"y":368,"fontSize":360,"fill":338},"154","finally runs immediately",[331,371,374],{"x":358,"y":372,"fontSize":360,"fontWeight":337,"fill":373},"186","#2a5f49","connection released",[331,376,378],{"x":358,"y":377,"fontSize":360,"fill":338},"212","the path everyone tests",[323,380],{"x":381,"y":343,"width":344,"height":345,"rx":346,"fill":382,"stroke":383,"strokeWidth":349},"288","#fbe9e3","#e07a5f",[331,385,386],{"x":333,"y":353,"textAnchor":335,"fontSize":354,"fontWeight":337,"fill":338},"break, no aclosing",[331,388,390],{"x":389,"y":359,"fontSize":360,"fill":338},"306","generator left suspended",[331,392,393],{"x":389,"y":364,"fontSize":360,"fill":338},"finally deferred to GC",[331,395,397],{"x":389,"y":368,"fontSize":360,"fill":396},"#8f3d22","may run after loop close",[331,399,400],{"x":389,"y":372,"fontSize":360,"fontWeight":337,"fill":396},"connection still held",[331,402,403],{"x":389,"y":377,"fontSize":360,"fill":338},"the path that leaks",[323,405],{"x":406,"y":343,"width":344,"height":345,"rx":346,"fill":347,"stroke":348,"strokeWidth":349},"550",[331,408,410],{"x":409,"y":353,"textAnchor":335,"fontSize":354,"fontWeight":337,"fill":338},"672","break, with aclosing",[331,412,414],{"x":413,"y":359,"fontSize":360,"fill":338},"568","aclose() at block exit",[331,416,417],{"x":413,"y":364,"fontSize":360,"fill":338},"GeneratorExit thrown in",[331,419,420],{"x":413,"y":368,"fontSize":360,"fill":338},"finally runs deterministically",[331,422,374],{"x":413,"y":372,"fontSize":360,"fontWeight":337,"fill":373},[331,424,425],{"x":413,"y":377,"fontSize":360,"fill":338},"the path to write",[427,428,429,430,432,433,436],"figcaption",{},"The middle column is the default behaviour of a plain ",[14,431,20],{}," with a ",[14,434,435],{},"break",", which is why the leak is so common and so rarely noticed in tests.",[26,438,440],{"id":439},"why-this-works","Why this works",[10,442,443,444,447,448,451,452,454,455,457,458,460,461,463],{},"An async generator suspended at a ",[14,445,446],{},"yield"," has no way to know the consumer has stopped. ",[14,449,450],{},"aclose()"," is what tells it: the coroutine throws ",[14,453,61],{}," in at the suspension point, the ",[14,456,16],{}," executes, and the generator is marked closed. ",[14,459,39],{}," is a context manager whose only job is to call ",[14,462,450],{}," on exit, which makes the cleanup deterministic and tied to a lexical block rather than to the garbage collector.",[10,465,466,467,470],{},"Without it, cleanup happens when the generator object is finalised. CPython will attempt that through the loop's asynchronous-generator finalisation hooks, but only while the loop is still running — after the loop closes, the pending finaliser produces ",[14,468,469],{},"RuntimeError: Event loop is closed"," during shutdown, or simply never runs. Which of those you get depends on timing, which is why the symptom is intermittent.",[26,472,474],{"id":473},"edge-cases-and-failure-modes","Edge cases and failure modes",[31,476,477,495,510,519,539],{},[34,478,479,486,487,490,491,44],{},[480,481,482,483,485],"strong",{},"Awaiting inside the ",[14,484,16],{}," after cancellation."," A cleanup that awaits while the generator is being cancelled is itself cancelled. Wrap the essential part in ",[14,488,489],{},"asyncio.shield"," with its own deadline, as in ",[52,492,494],{"href":493},"\u002Ftesting-async-and-concurrent-python\u002Ftimeouts-cancellation-and-deadlines\u002F","timeouts, cancellation and deadlines",[34,496,497,506,507,509],{},[480,498,499,501,502,505],{},[14,500,446],{}," inside a ",[14,503,504],{},"try\u002Ffinally"," inside a lock."," If the consumer abandons the generator, the lock is held until ",[14,508,450],{},". This is the same bug as the connection leak with worse consequences.",[34,511,512,515,516,518],{},[480,513,514],{},"Reusing an exhausted generator."," A second ",[14,517,20],{}," over the same object yields nothing rather than restarting. Tests that reuse a generator fixture across two tests silently get an empty stream in the second.",[34,520,521,527,528,530,531,534,535,44],{},[480,522,523,526],{},[14,524,525],{},"__aexit__"," returning a truthy mock."," In a test that replaces the manager with a mock, an unconfigured ",[14,529,525],{}," returns a ",[14,532,533],{},"Mock",", which is truthy and therefore suppresses the exception — see ",[52,536,538],{"href":537},"\u002Fadvanced-mocking-test-doubles-in-python\u002Fpatching-async-code-and-coroutines\u002Fpatching-an-async-context-manager\u002F","patching an async context manager",[34,540,541,546,547,550,551,44],{},[480,542,543,545],{},[14,544,43],{}," over a generator that yields twice."," It raises ",[14,548,549],{},"RuntimeError: generator didn't stop",", which is confusing but literal: the manager expects exactly one ",[14,552,446],{},[26,554,556],{"id":555},"testing-an-async-context-managers-failure-path","Testing an async context manager's failure path",[10,558,559,562],{},[14,560,561],{},"@asynccontextmanager"," turns a generator into a manager, and the same lifecycle questions apply with one addition: what happens to an exception raised in the body.",[71,564,566],{"className":73,"code":565,"language":75,"meta":76,"style":76},"import contextlib\n\nimport pytest\n\n\n@contextlib.asynccontextmanager\nasync def transaction(connection):\n    tx = connection.transaction()\n    await tx.start()\n    try:\n        yield tx\n    except Exception:\n        await tx.rollback()          # the path most tests never exercise\n        raise                        # re-raise: suppression would hide the bug\n    else:\n        await tx.commit()\n\n\nasync def test_body_failure_rolls_back(connection):\n    with pytest.raises(ValueError):\n        async with transaction(connection) as tx:\n            await connection.execute(\"INSERT INTO widget (name) VALUES ('x')\")\n            raise ValueError(\"business rule violated\")\n\n    # The assertion that matters: state, not that rollback was called.\n    assert await connection.fetchval(\"SELECT count(*) FROM widget\") == 0\n\n\nasync def test_success_commits(connection):\n    async with transaction(connection):\n        await connection.execute(\"INSERT INTO widget (name) VALUES ('y')\")\n\n    assert await connection.fetchval(\"SELECT count(*) FROM widget\") == 1\n",[14,567,568,572,576,580,584,588,593,598,603,608,612,617,622,627,632,637,642,646,650,655,659,664,669,674,678,683,688,692,696,701,706,711,715],{"__ignoreMap":76},[80,569,570],{"class":82,"line":83},[80,571,86],{},[80,573,574],{"class":82,"line":89},[80,575,93],{"emptyLinePlaceholder":92},[80,577,578],{"class":82,"line":96},[80,579,99],{},[80,581,582],{"class":82,"line":102},[80,583,93],{"emptyLinePlaceholder":92},[80,585,586],{"class":82,"line":107},[80,587,93],{"emptyLinePlaceholder":92},[80,589,590],{"class":82,"line":112},[80,591,592],{},"@contextlib.asynccontextmanager\n",[80,594,595],{"class":82,"line":118},[80,596,597],{},"async def transaction(connection):\n",[80,599,600],{"class":82,"line":124},[80,601,602],{},"    tx = connection.transaction()\n",[80,604,605],{"class":82,"line":130},[80,606,607],{},"    await tx.start()\n",[80,609,610],{"class":82,"line":136},[80,611,133],{},[80,613,614],{"class":82,"line":142},[80,615,616],{},"        yield tx\n",[80,618,619],{"class":82,"line":148},[80,620,621],{},"    except Exception:\n",[80,623,624],{"class":82,"line":154},[80,625,626],{},"        await tx.rollback()          # the path most tests never exercise\n",[80,628,629],{"class":82,"line":160},[80,630,631],{},"        raise                        # re-raise: suppression would hide the bug\n",[80,633,634],{"class":82,"line":165},[80,635,636],{},"    else:\n",[80,638,639],{"class":82,"line":170},[80,640,641],{},"        await tx.commit()\n",[80,643,644],{"class":82,"line":176},[80,645,93],{"emptyLinePlaceholder":92},[80,647,648],{"class":82,"line":182},[80,649,93],{"emptyLinePlaceholder":92},[80,651,652],{"class":82,"line":188},[80,653,654],{},"async def test_body_failure_rolls_back(connection):\n",[80,656,657],{"class":82,"line":194},[80,658,270],{},[80,660,661],{"class":82,"line":199},[80,662,663],{},"        async with transaction(connection) as tx:\n",[80,665,666],{"class":82,"line":204},[80,667,668],{},"            await connection.execute(\"INSERT INTO widget (name) VALUES ('x')\")\n",[80,670,671],{"class":82,"line":210},[80,672,673],{},"            raise ValueError(\"business rule violated\")\n",[80,675,676],{"class":82,"line":216},[80,677,93],{"emptyLinePlaceholder":92},[80,679,680],{"class":82,"line":222},[80,681,682],{},"    # The assertion that matters: state, not that rollback was called.\n",[80,684,685],{"class":82,"line":228},[80,686,687],{},"    assert await connection.fetchval(\"SELECT count(*) FROM widget\") == 0\n",[80,689,690],{"class":82,"line":234},[80,691,93],{"emptyLinePlaceholder":92},[80,693,694],{"class":82,"line":240},[80,695,93],{"emptyLinePlaceholder":92},[80,697,698],{"class":82,"line":245},[80,699,700],{},"async def test_success_commits(connection):\n",[80,702,703],{"class":82,"line":251},[80,704,705],{},"    async with transaction(connection):\n",[80,707,708],{"class":82,"line":256},[80,709,710],{},"        await connection.execute(\"INSERT INTO widget (name) VALUES ('y')\")\n",[80,712,713],{"class":82,"line":261},[80,714,93],{"emptyLinePlaceholder":92},[80,716,717],{"class":82,"line":267},[80,718,719],{},"    assert await connection.fetchval(\"SELECT count(*) FROM widget\") == 1\n",[10,721,722,723,726,727,730],{},"The ",[14,724,725],{},"raise"," after ",[14,728,729],{},"rollback()"," is easy to omit and catastrophic when omitted: the generator would swallow the exception, the caller would proceed as if the operation succeeded, and the test above is the only thing that would catch it. That asymmetry — one missing keyword converting an error into silent data loss — is why the failure path deserves a test of its own rather than being assumed.",[300,732,734,839],{"className":733},[303],[305,735,313,740,313,743,313,746,313,763,313,767,313,771,313,779,313,783,313,789,313,794,313,798,313,802,313,808,313,814,313,817,313,821,313,826,313,829,313,833,313,836],{"viewBox":736,"role":308,"ariaLabelledBy":737,"xmlns":312},"0 0 800 246",[738,739],"acm-t","acm-d",[315,741,742],{"id":738},"Success and failure paths through an async context manager",[319,744,745],{"id":739},"A single manager with two paths. On success, the body completes, the else branch commits and control returns normally. On failure, the exception enters the except branch, the transaction is rolled back and the exception is re-raised so the caller sees it. A note marks that omitting the re-raise silently suppresses the error.",[747,748,749,750,313],"defs",{},"\n    ",[751,752,759],"marker",{"id":753,"viewBox":754,"refX":755,"refY":756,"markerWidth":757,"markerHeight":757,"orient":758},"acm-a","0 0 10 10","9","5","7","auto-start-reverse",[760,761],"path",{"d":762,"fill":338},"M0 0 L10 5 L0 10 z",[323,764],{"x":325,"y":325,"width":765,"height":766,"rx":328,"fill":329},"800","246",[331,768,770],{"x":769,"y":334,"textAnchor":335,"fontSize":336,"fontWeight":337,"fill":338},"400","Two exits, one of which is usually untested",[323,772],{"x":773,"y":774,"width":775,"height":358,"rx":776,"fill":777,"stroke":338,"strokeWidth":778},"300","48","200","10","#f4f1de","1.6",[331,780,782],{"x":769,"y":781,"textAnchor":335,"fontSize":346,"fontWeight":337,"fill":338},"76","yield tx — body runs",[82,784],{"x1":773,"y1":785,"x2":786,"y2":787,"stroke":348,"strokeWidth":778,"markerEnd":788},"70","214","108","url(#acm-a)",[331,790,793],{"x":791,"y":792,"fontSize":360,"fill":373},"238","88","no exception",[82,795],{"x1":796,"y1":785,"x2":797,"y2":787,"stroke":383,"strokeWidth":778,"markerEnd":788},"500","586",[331,799,801],{"x":800,"y":792,"fontSize":360,"fill":396},"552","raises",[323,803],{"x":804,"y":805,"width":806,"height":807,"rx":776,"fill":347,"stroke":348,"strokeWidth":349},"40","112","290","50",[331,809,813],{"x":810,"y":811,"textAnchor":335,"fontSize":812,"fill":338},"185","142","11.5","else: await tx.commit()",[323,815],{"x":816,"y":805,"width":806,"height":807,"rx":776,"fill":382,"stroke":383,"strokeWidth":349},"470",[331,818,820],{"x":819,"y":811,"textAnchor":335,"fontSize":812,"fill":338},"615","except: await tx.rollback()",[82,822],{"x1":819,"y1":823,"x2":819,"y2":824,"stroke":338,"strokeWidth":825,"markerEnd":788},"166","184","1.5",[323,827],{"x":816,"y":345,"width":806,"height":358,"rx":776,"fill":329,"stroke":383,"strokeWidth":828},"1.8",[331,830,832],{"x":819,"y":831,"textAnchor":335,"fontSize":812,"fontWeight":337,"fill":338},"215","raise — omit this and it is swallowed",[323,834],{"x":804,"y":345,"width":806,"height":358,"rx":776,"fill":329,"stroke":835,"strokeWidth":825},"rgba(61,64,91,0.35)",[331,837,838],{"x":810,"y":831,"textAnchor":335,"fontSize":812,"fill":338},"caller continues normally",[427,840,841,842,844],{},"Both branches need a test. The right-hand one is where a missing ",[14,843,725],{}," turns a failed operation into a silently committed one.",[26,846,848],{"id":847},"asserting-on-what-the-stream-produced","Asserting on what the stream produced",[10,850,851],{},"Generators tempt tests into checking only the values that came out, which misses two properties worth holding.",[10,853,854,855,858],{},"The first is ",[480,856,857],{},"laziness",". A generator that eagerly builds the whole result before yielding anything defeats the purpose of streaming, and the test for it is a counter on the source:",[71,860,862],{"className":73,"code":861,"language":75,"meta":76,"style":76},"async def test_stream_is_lazy(pool, instrumented_cursor):\n    async with contextlib.aclosing(stream_rows(pool)) as stream:\n        first = await anext(stream)\n\n    assert first is not None\n    # Only one batch should have been fetched, not the whole table.\n    assert instrumented_cursor.fetch_calls == 1\n",[14,863,864,869,873,878,882,887,892],{"__ignoreMap":76},[80,865,866],{"class":82,"line":83},[80,867,868],{},"async def test_stream_is_lazy(pool, instrumented_cursor):\n",[80,870,871],{"class":82,"line":89},[80,872,225],{},[80,874,875],{"class":82,"line":96},[80,876,877],{},"        first = await anext(stream)\n",[80,879,880],{"class":82,"line":102},[80,881,93],{"emptyLinePlaceholder":92},[80,883,884],{"class":82,"line":107},[80,885,886],{},"    assert first is not None\n",[80,888,889],{"class":82,"line":112},[80,890,891],{},"    # Only one batch should have been fetched, not the whole table.\n",[80,893,894],{"class":82,"line":118},[80,895,896],{},"    assert instrumented_cursor.fetch_calls == 1\n",[10,898,899,900,903],{},"The second is ",[480,901,902],{},"ordering and completeness together",". Collecting into a list and comparing to an expected list covers both in one assertion, and is preferable to checking length and membership separately — a stream that duplicates one row and drops another passes both of those and fails the list comparison.",[300,905,907,959],{"className":906},[303],[305,908,313,913,313,916,313,919,313,923,313,928,313,932,313,936,313,940,313,944,313,947,313,951,313,955],{"viewBox":909,"role":308,"ariaLabelledBy":910,"xmlns":312},"0 0 780 226",[911,912],"lazy-t","lazy-d",[315,914,915],{"id":911},"What a lazy stream does differently from an eager one",[319,917,918],{"id":912},"Two rows. The lazy generator fetches one batch, yields its rows, and fetches the next batch only when the consumer asks, so memory stays flat and the first row arrives immediately. The eager implementation fetches every batch before yielding anything, so the first row is delayed and memory grows with the result size.",[323,920],{"x":325,"y":325,"width":921,"height":922,"rx":328,"fill":329},"780","226",[331,924,927],{"x":925,"y":334,"textAnchor":335,"fontSize":926,"fontWeight":337,"fill":338},"390","15.5","Laziness is a property a test can assert",[323,929],{"x":342,"y":807,"width":930,"height":931,"rx":360,"fill":347,"stroke":348,"strokeWidth":349},"728","74",[331,933,935],{"x":934,"y":931,"fontSize":346,"fontWeight":337,"fill":338},"46","lazy",[331,937,939],{"x":934,"y":938,"fontSize":360,"fill":338},"96","fetch batch 1 → yield rows → consumer asks → fetch batch 2 → …",[331,941,943],{"x":934,"y":942,"fontSize":360,"fill":373},"116","first row immediately · memory flat · fetch_calls == 1 after one row",[323,945],{"x":342,"y":946,"width":930,"height":931,"rx":360,"fill":382,"stroke":383,"strokeWidth":349},"136",[331,948,950],{"x":934,"y":949,"fontSize":346,"fontWeight":337,"fill":338},"160","eager",[331,952,954],{"x":934,"y":953,"fontSize":360,"fill":338},"182","fetch every batch → build a list → yield from it",[331,956,958],{"x":934,"y":957,"fontSize":360,"fill":396},"202","first row delayed · memory grows with the result · fetch_calls == N",[427,960,961],{},"Both implementations yield identical values, so a test that only compares outputs cannot tell them apart — and the difference is the entire reason the generator exists.",[10,963,964,965,968],{},"Where the stream is consumed by a pipeline rather than by a list comprehension, the useful assertion moves downstream: assert the consumer processed items incrementally, for example by checking that a progress callback fired more than once before the stream ended. That is the behavioural statement of the same property, and it survives a refactor of the generator's internals in a way the ",[14,966,967],{},"fetch_calls"," counter does not.",[10,970,971],{},"One more assertion is worth adding wherever the stream can be long: that the generator stops when told to. A consumer that sets a stop flag, or a deadline that fires, should end the iteration promptly rather than after the current batch of ten thousand rows. Testing it means driving the generator with a small batch size, signalling a stop after the first item, and asserting the source was not queried again — which is the same shape as the laziness test with the condition inverted.",[26,973,975],{"id":974},"fixtures-that-are-themselves-async-generators","Fixtures that are themselves async generators",[10,977,978,979,982,983,985,986,990,991,994],{},"A ",[14,980,981],{},"pytest_asyncio.fixture"," written with ",[14,984,446],{}," ",[987,988,989],"em",{},"is"," an async generator, and the same lifecycle rules apply to it — with the difference that ",[14,992,993],{},"pytest-asyncio"," handles the closing, correctly, as long as the loop scopes line up.",[71,996,998],{"className":73,"code":997,"language":75,"meta":76,"style":76},"import pytest_asyncio\n\n\n@pytest_asyncio.fixture(loop_scope=\"module\")\nasync def stream(pool):\n    # The plugin calls aclose() during teardown, on the fixture's loop.\n    generator = stream_rows(pool)\n    try:\n        yield generator\n    finally:\n        await generator.aclose()     # explicit: do not rely on finalisation\n",[14,999,1000,1005,1009,1013,1018,1023,1028,1033,1037,1042,1046],{"__ignoreMap":76},[80,1001,1002],{"class":82,"line":83},[80,1003,1004],{},"import pytest_asyncio\n",[80,1006,1007],{"class":82,"line":89},[80,1008,93],{"emptyLinePlaceholder":92},[80,1010,1011],{"class":82,"line":96},[80,1012,93],{"emptyLinePlaceholder":92},[80,1014,1015],{"class":82,"line":102},[80,1016,1017],{},"@pytest_asyncio.fixture(loop_scope=\"module\")\n",[80,1019,1020],{"class":82,"line":107},[80,1021,1022],{},"async def stream(pool):\n",[80,1024,1025],{"class":82,"line":112},[80,1026,1027],{},"    # The plugin calls aclose() during teardown, on the fixture's loop.\n",[80,1029,1030],{"class":82,"line":118},[80,1031,1032],{},"    generator = stream_rows(pool)\n",[80,1034,1035],{"class":82,"line":124},[80,1036,133],{},[80,1038,1039],{"class":82,"line":130},[80,1040,1041],{},"        yield generator\n",[80,1043,1044],{"class":82,"line":136},[80,1045,151],{},[80,1047,1048],{"class":82,"line":142},[80,1049,1050],{},"        await generator.aclose()     # explicit: do not rely on finalisation\n",[10,1052,1053,1054,1056],{},"Closing explicitly in the fixture's own ",[14,1055,16],{}," is worth the extra line. It makes the teardown ordering visible, it runs while the loop is guaranteed alive, and it does not depend on the plugin's finalisation behaviour staying the same across versions.",[10,1058,1059,1060,1063,1064,44],{},"The failure this avoids is a familiar one: a fixture whose generator is finalised after its loop has closed produces an exception during teardown that pytest reports against the ",[987,1061,1062],{},"next"," test, or as an error with no test attached at all. Making the close explicit and scope-matched keeps the teardown inside the window where it can succeed, which is the same principle that governs pools and servers in ",[52,1065,1067],{"href":1066},"\u002Ftesting-async-and-concurrent-python\u002Fpytest-asyncio-in-depth\u002Fsharing-an-event-loop-across-a-test-module\u002F","sharing an event loop across a test module",[26,1069,1071],{"id":1070},"frequently-asked-questions","Frequently Asked Questions",[10,1073,1074,1077,1078,1080,1081,1083,1084,1087],{},[480,1075,1076],{},"Why does my async generator's finally block not run?","\nBecause the generator was abandoned rather than closed. Breaking out of an ",[14,1079,20],{}," leaves the generator suspended, and its cleanup runs only when ",[14,1082,450],{}," is called or the object is finalised — which may be much later, on a different loop, or never. Use ",[14,1085,1086],{},"contextlib.aclosing()"," so the generator is closed deterministically at the end of the block.",[10,1089,1090,1093,1094,1096],{},[480,1091,1092],{},"How do I test that an async context manager cleans up when the body raises?","\nRaise deliberately inside the block, catch the exception outside it, and then assert on the side effects cleanup was supposed to produce — a released connection, a rolled-back transaction, a closed file. Asserting that ",[14,1095,525],{}," was called proves only that Python ran it, not that it did the right thing.",[10,1098,1099,1106,1107,1110],{},[480,1100,1101,1102,1105],{},"Should ",[480,1103,1104],{},"aexit"," ever return True?","\nOnly when the manager genuinely exists to swallow a specific exception, which is rare. A truthy return suppresses the exception and hides failures from every caller, including tests. Return ",[14,1108,1109],{},"False",", or nothing at all, unless suppression is the manager's documented purpose.",[26,1112,1114],{"id":1113},"related","Related",[31,1116,1117,1123,1130,1136],{},[34,1118,1119,1122],{},[52,1120,1121],{"href":54},"pytest-asyncio in Depth"," — the loop rules that decide when teardown can run at all.",[34,1124,1125,1129],{},[52,1126,1128],{"href":1127},"\u002Ftesting-async-and-concurrent-python\u002Ftimeouts-cancellation-and-deadlines\u002Ftesting-cancellation-and-cleanup-paths\u002F","Testing Cancellation and Cleanup Paths"," — the same assertions applied to cancellation rather than early exit.",[34,1131,1132,1135],{},[52,1133,1134],{"href":537},"Patching an Async Context Manager"," — how to double one of these without suppressing exceptions by accident.",[34,1137,1138,1142],{},[52,1139,1141],{"href":1140},"\u002Fsystematic-debugging-performance-profiling\u002Fdebugging-async-code-and-event-loops\u002Fdebugging-event-loop-is-closed-runtimeerror\u002F","Debugging the Event Loop is Closed RuntimeError"," — what deferred finalisation looks like when it fails.",[10,1144,1145,1146],{},"← Back to ",[52,1147,1121],{"href":54},[1149,1150,1151],"style",{},"html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}",{"title":76,"searchDepth":89,"depth":89,"links":1153},[1154,1155,1156,1157,1158,1159,1160,1161,1162],{"id":28,"depth":89,"text":29},{"id":65,"depth":89,"text":66},{"id":439,"depth":89,"text":440},{"id":473,"depth":89,"text":474},{"id":555,"depth":89,"text":556},{"id":847,"depth":89,"text":848},{"id":974,"depth":89,"text":975},{"id":1070,"depth":89,"text":1071},{"id":1113,"depth":89,"text":1114},"Test async generators and async context managers properly: aclose semantics, GeneratorExit, cleanup on early exit, and asserting that __aexit__ ran on the failure path.","md",{"slug":1166,"type":1167,"breadcrumb":1168,"datePublished":1169,"dateModified":1169,"faq":1170,"howto":1178},"testing-async-generators-and-context-managers","article","Async Generators","2026-09-18",[1171,1173,1175],{"q":1076,"a":1172},"Because the generator was abandoned rather than closed. Breaking out of an async for leaves the generator suspended, and its cleanup runs only when aclose() is called or the object is finalised — which may be much later, on a different loop, or never. Use contextlib.aclosing() so the generator is closed deterministically at the end of the block.",{"q":1092,"a":1174},"Raise deliberately inside the block, catch the exception outside it, and then assert on the side effects cleanup was supposed to produce — a released connection, a rolled-back transaction, a closed file. Asserting that __aexit__ was called proves only that Python ran it, not that it did the right thing.",{"q":1176,"a":1177},"Should __aexit__ ever return True?","Only when the manager genuinely exists to swallow a specific exception, which is rare. A truthy return suppresses the exception and hides failures from every caller, including tests. Return False, or nothing at all, unless suppression is the manager's documented purpose.",{"name":1179,"description":1180,"steps":1181},"How to test async generators and context managers","Drive the object through its full lifecycle, including early exit and failure, and assert on cleanup side effects.",[1182,1185,1188,1191,1194],{"name":1183,"text":1184},"Close generators deterministically","Wrap consumption in contextlib.aclosing so aclose runs at the end of the block rather than at garbage collection.",{"name":1186,"text":1187},"Test the early-exit path","Break out of the loop after one item and assert the generator's cleanup ran.",{"name":1189,"text":1190},"Test the failure path of the context manager","Raise inside the async with block and assert the exception propagates and the resource was released.",{"name":1192,"text":1193},"Check exhaustion and reuse","Assert that a second iteration of an exhausted generator yields nothing rather than restarting.",{"name":1195,"text":1196},"Assert on state, not on calls","Verify the released connection or rolled-back transaction rather than that __aexit__ was invoked.","\u002Ftesting-async-and-concurrent-python\u002Fpytest-asyncio-in-depth\u002Ftesting-async-generators-and-context-managers",{"title":5,"description":1163},"testing-async-and-concurrent-python\u002Fpytest-asyncio-in-depth\u002Ftesting-async-generators-and-context-managers\u002Findex","r5G1Af6K9NcfH87swkdyW5YCNNaPZ7TUrwOrwzNU1rY",1789718768340]