[{"data":1,"prerenderedAt":1255},["ShallowReactive",2],{"page-\u002Ftesting-async-and-concurrent-python\u002Ftesting-with-anyio-and-trio\u002Ftesting-code-that-uses-task-groups\u002F":3},{"id":4,"title":5,"body":6,"description":1218,"extension":1219,"meta":1220,"navigation":113,"path":1251,"seo":1252,"stem":1253,"__hash__":1254},"content\u002Ftesting-async-and-concurrent-python\u002Ftesting-with-anyio-and-trio\u002Ftesting-code-that-uses-task-groups\u002Findex.md","Testing Code That Uses Task Groups",{"type":7,"value":8,"toc":1207},"minimark",[9,30,35,78,82,85,287,445,449,463,474,478,558,562,569,698,710,720,780,784,787,908,911,990,996,1000,1003,1074,1084,1094,1102,1106,1130,1143,1161,1165,1198,1203],[10,11,12,13,17,18,21,22,25,26,29],"p",{},"Migrating from ",[14,15,16],"code",{},"asyncio.gather"," to a task group changes the type that reaches the caller, and tests written for the old shape stop asserting anything useful. ",[14,19,20],{},"pytest.raises(ValueError)"," fails with \"DID NOT RAISE\" even though a ",[14,23,24],{},"ValueError"," was raised, because what escaped the block was an ",[14,27,28],{},"ExceptionGroup"," containing it. Testing structured concurrency means asserting on the group's contents, on the cancellation of siblings, and on the guarantee that nothing outlives the block.",[31,32,34],"h2",{"id":33},"prerequisites","Prerequisites",[36,37,38,61,73],"ul",{},[39,40,41,42,45,46,48,49,52,53,56,57,60],"li",{},"Python 3.11+ for ",[14,43,44],{},"asyncio.TaskGroup",", ",[14,47,28],{}," and ",[14,50,51],{},"except*","; on 3.10 use ",[14,54,55],{},"anyio.create_task_group()"," with the ",[14,58,59],{},"exceptiongroup"," backport.",[39,62,63,66,67,72],{},[14,64,65],{},"anyio >= 4.0"," if the code is backend-portable, plus the setup in ",[68,69,71],"a",{"href":70},"\u002Ftesting-async-and-concurrent-python\u002Ftesting-with-anyio-and-trio\u002Frunning-one-test-on-asyncio-and-trio\u002F","running one test on asyncio and Trio",".",[39,74,75,72],{},[14,76,77],{},"pytest >= 8.0",[31,79,81],{"id":80},"solution","Solution",[10,83,84],{},"Assert on the group's shape, then on the side effects its cancellation was supposed to produce.",[86,87,92],"pre",{"className":88,"code":89,"language":90,"meta":91,"style":91},"language-python shiki shiki-themes github-light github-dark","import anyio\nimport pytest\n\npytestmark = pytest.mark.anyio\n\n\nasync def fan_out(urls, fetch, cancelled):\n    async def run(url):\n        try:\n            return await fetch(url)\n        finally:\n            # Observable cleanup: how the test proves this task was stopped.\n            if anyio.get_cancelled_exc_class() and not _completed(url):\n                cancelled.append(url)\n\n    async with anyio.create_task_group() as tg:\n        for url in urls:\n            tg.start_soon(run, url)\n\n\nasync def test_one_failure_cancels_the_siblings(failing_fetch):\n    cancelled: list[str] = []\n\n    # The group is what propagates; matching the leaf type would not raise.\n    with pytest.raises(ExceptionGroup) as excinfo:\n        await fan_out([\"\u002Fa\", \"\u002Fb\", \"\u002Fc\"], failing_fetch, cancelled)\n\n    # Shape: exactly one real failure, the rest cancelled cleanly.\n    assert len(excinfo.value.exceptions) == 1\n    assert isinstance(excinfo.value.exceptions[0], TimeoutError)\n\n    # Behaviour: the siblings actually stopped, rather than being left running.\n    assert sorted(cancelled) == [\"\u002Fc\"]\n","python","",[14,93,94,102,108,115,121,126,131,137,143,149,155,161,167,173,179,184,190,196,202,207,212,218,224,229,235,241,247,252,258,264,270,275,281],{"__ignoreMap":91},[95,96,99],"span",{"class":97,"line":98},"line",1,[95,100,101],{},"import anyio\n",[95,103,105],{"class":97,"line":104},2,[95,106,107],{},"import pytest\n",[95,109,111],{"class":97,"line":110},3,[95,112,114],{"emptyLinePlaceholder":113},true,"\n",[95,116,118],{"class":97,"line":117},4,[95,119,120],{},"pytestmark = pytest.mark.anyio\n",[95,122,124],{"class":97,"line":123},5,[95,125,114],{"emptyLinePlaceholder":113},[95,127,129],{"class":97,"line":128},6,[95,130,114],{"emptyLinePlaceholder":113},[95,132,134],{"class":97,"line":133},7,[95,135,136],{},"async def fan_out(urls, fetch, cancelled):\n",[95,138,140],{"class":97,"line":139},8,[95,141,142],{},"    async def run(url):\n",[95,144,146],{"class":97,"line":145},9,[95,147,148],{},"        try:\n",[95,150,152],{"class":97,"line":151},10,[95,153,154],{},"            return await fetch(url)\n",[95,156,158],{"class":97,"line":157},11,[95,159,160],{},"        finally:\n",[95,162,164],{"class":97,"line":163},12,[95,165,166],{},"            # Observable cleanup: how the test proves this task was stopped.\n",[95,168,170],{"class":97,"line":169},13,[95,171,172],{},"            if anyio.get_cancelled_exc_class() and not _completed(url):\n",[95,174,176],{"class":97,"line":175},14,[95,177,178],{},"                cancelled.append(url)\n",[95,180,182],{"class":97,"line":181},15,[95,183,114],{"emptyLinePlaceholder":113},[95,185,187],{"class":97,"line":186},16,[95,188,189],{},"    async with anyio.create_task_group() as tg:\n",[95,191,193],{"class":97,"line":192},17,[95,194,195],{},"        for url in urls:\n",[95,197,199],{"class":97,"line":198},18,[95,200,201],{},"            tg.start_soon(run, url)\n",[95,203,205],{"class":97,"line":204},19,[95,206,114],{"emptyLinePlaceholder":113},[95,208,210],{"class":97,"line":209},20,[95,211,114],{"emptyLinePlaceholder":113},[95,213,215],{"class":97,"line":214},21,[95,216,217],{},"async def test_one_failure_cancels_the_siblings(failing_fetch):\n",[95,219,221],{"class":97,"line":220},22,[95,222,223],{},"    cancelled: list[str] = []\n",[95,225,227],{"class":97,"line":226},23,[95,228,114],{"emptyLinePlaceholder":113},[95,230,232],{"class":97,"line":231},24,[95,233,234],{},"    # The group is what propagates; matching the leaf type would not raise.\n",[95,236,238],{"class":97,"line":237},25,[95,239,240],{},"    with pytest.raises(ExceptionGroup) as excinfo:\n",[95,242,244],{"class":97,"line":243},26,[95,245,246],{},"        await fan_out([\"\u002Fa\", \"\u002Fb\", \"\u002Fc\"], failing_fetch, cancelled)\n",[95,248,250],{"class":97,"line":249},27,[95,251,114],{"emptyLinePlaceholder":113},[95,253,255],{"class":97,"line":254},28,[95,256,257],{},"    # Shape: exactly one real failure, the rest cancelled cleanly.\n",[95,259,261],{"class":97,"line":260},29,[95,262,263],{},"    assert len(excinfo.value.exceptions) == 1\n",[95,265,267],{"class":97,"line":266},30,[95,268,269],{},"    assert isinstance(excinfo.value.exceptions[0], TimeoutError)\n",[95,271,273],{"class":97,"line":272},31,[95,274,114],{"emptyLinePlaceholder":113},[95,276,278],{"class":97,"line":277},32,[95,279,280],{},"    # Behaviour: the siblings actually stopped, rather than being left running.\n",[95,282,284],{"class":97,"line":283},33,[95,285,286],{},"    assert sorted(cancelled) == [\"\u002Fc\"]\n",[288,289,292,437],"figure",{"className":290},[291],"diagram",[293,294,301,302,301,306,301,310,301,328,301,336,301,345,301,355,301,361,301,367,301,371,301,376,301,383,301,388,301,392,301,396,301,399,301,403,301,407,301,411,301,415,301,419,301,422,301,425,301,433],"svg",{"viewBox":295,"role":296,"ariaLabelledBy":297,"xmlns":300},"0 0 820 262","img",[298,299],"tg2-t","tg2-d","http:\u002F\u002Fwww.w3.org\u002F2000\u002Fsvg","\n  ",[303,304,305],"title",{"id":298},"What a test must assert about a task group",[307,308,309],"desc",{"id":299},"Three assertions arranged left to right. The type assertion checks that an ExceptionGroup propagated. The shape assertion checks how many real failures it contains. The behaviour assertion checks that sibling tasks ran their cleanup, which is the guarantee structured concurrency exists to provide.",[311,312,313,314,301],"defs",{},"\n    ",[315,316,323],"marker",{"id":317,"viewBox":318,"refX":319,"refY":320,"markerWidth":321,"markerHeight":321,"orient":322},"tg2-a","0 0 10 10","9","5","7","auto-start-reverse",[324,325],"path",{"d":326,"fill":327},"M0 0 L10 5 L0 10 z","#3d405b",[329,330],"rect",{"x":331,"y":331,"width":332,"height":333,"rx":334,"fill":335},"0","820","262","14","#fffdf8",[337,338,344],"text",{"x":339,"y":340,"textAnchor":341,"fontSize":342,"fontWeight":343,"fill":327},"410","28","middle","16","700","Three assertions, and only one of them is about the exception",[329,346],{"x":347,"y":348,"width":349,"height":350,"rx":351,"fill":352,"stroke":353,"strokeWidth":354},"26","56","244","120","12","#f7f0da","#f2cc8f","2",[337,356,360],{"x":357,"y":358,"textAnchor":341,"fontSize":359,"fontWeight":343,"fill":327},"148","82","12.5","1 · type",[337,362,366],{"x":363,"y":364,"fontSize":365,"fill":327},"44","110","11","pytest.raises(ExceptionGroup)",[337,368,370],{"x":363,"y":369,"fontSize":365,"fill":327},"134","not the leaf type",[337,372,375],{"x":363,"y":373,"fontSize":365,"fill":374},"158","#8a5a00","catches the migration mistake",[97,377],{"x1":378,"y1":379,"x2":380,"y2":379,"stroke":327,"strokeWidth":381,"markerEnd":382},"274","116","298","1.6","url(#tg2-a)",[329,384],{"x":385,"y":348,"width":349,"height":350,"rx":351,"fill":386,"stroke":387,"strokeWidth":354},"304","#e6f0ea","#81b29a",[337,389,391],{"x":390,"y":358,"textAnchor":341,"fontSize":359,"fontWeight":343,"fill":327},"426","2 · shape",[337,393,395],{"x":394,"y":364,"fontSize":365,"fill":327},"322","len(exc.exceptions) == 1",[337,397,398],{"x":394,"y":369,"fontSize":365,"fill":327},"one bug, not three",[337,400,402],{"x":394,"y":373,"fontSize":365,"fill":401},"#2a5f49","distinguishes the failure modes",[97,404],{"x1":405,"y1":379,"x2":406,"y2":379,"stroke":327,"strokeWidth":381,"markerEnd":382},"552","576",[329,408],{"x":409,"y":348,"width":410,"height":350,"rx":351,"fill":386,"stroke":387,"strokeWidth":354},"582","212",[337,412,414],{"x":413,"y":358,"textAnchor":341,"fontSize":359,"fontWeight":343,"fill":327},"688","3 · behaviour",[337,416,418],{"x":417,"y":364,"fontSize":365,"fill":327},"600","siblings ran cleanup",[337,420,421],{"x":417,"y":369,"fontSize":365,"fill":327},"nothing left running",[337,423,424],{"x":417,"y":373,"fontSize":365,"fontWeight":343,"fill":401},"the real guarantee",[329,426],{"x":347,"y":427,"width":428,"height":429,"rx":430,"fill":335,"stroke":431,"strokeWidth":432},"196","768","46","10","rgba(61,64,91,0.35)","1.5",[337,434,436],{"x":339,"y":435,"textAnchor":341,"fontSize":351,"fill":327},"224","A test with only the first assertion passes even when a sibling silently swallows its cancellation.",[438,439,440,441,444],"figcaption",{},"The third assertion is the one most often missing, and it is the one that fails when a task catches ",[14,442,443],{},"BaseException"," and keeps going.",[31,446,448],{"id":447},"why-this-works","Why this works",[10,450,451,452,455,456,458,459,462],{},"A task group's ",[14,453,454],{},"__aexit__"," waits for every child. If a child raises, the group cancels the remaining children, waits for them to finish unwinding, and then raises an ",[14,457,28],{}," containing every exception that actually escaped — cancellations that were absorbed cleanly do not appear. That is why counting ",[14,460,461],{},"exceptions"," distinguishes \"one thing failed and the others stopped properly\" from \"three independent failures\".",[10,464,465,466,469,470,473],{},"The cancellation is delivered as an exception at each sibling's next ",[14,467,468],{},"await",", so a sibling's ",[14,471,472],{},"finally"," runs and can record the fact. Observing that record is the only way a test outside the group can verify the cancellation happened, because the group owns the task objects and never exposes them.",[31,475,477],{"id":476},"edge-cases-and-failure-modes","Edge cases and failure modes",[36,479,480,495,517,523,545],{},[39,481,482,486,487,490,491,72],{},[483,484,485],"strong",{},"A child that swallows cancellation."," ",[14,488,489],{},"except BaseException: pass"," inside a task means the group waits forever for it. The test symptom is a hang, which is why these tests need a deadline — see ",[68,492,494],{"href":493},"\u002Ftesting-async-and-concurrent-python\u002Ftimeouts-cancellation-and-deadlines\u002Ffailing-fast-with-pytest-timeout\u002F","failing fast with pytest-timeout",[39,496,497,486,503,505,506,508,509,512,513,516],{},[483,498,499,500,502],{},"Matching with ",[14,501,51],{}," outside a group.",[14,504,51],{}," always binds an ",[14,507,28],{},", even when one exception matched, so ",[14,510,511],{},"group.exceptions"," is what to iterate — not ",[14,514,515],{},"group"," itself.",[39,518,519,522],{},[483,520,521],{},"Nested groups."," An inner group's failure propagates as a group inside the outer group's group. Python flattens some of this; assert on the flattened contents rather than the nesting depth.",[39,524,525,531,532,536,537,540,541,544],{},[483,526,527,530],{},[14,528,529],{},"start_soon"," with a coroutine object."," Both asyncio's and AnyIO's task groups want a ",[533,534,535],"em",{},"function"," and its arguments, not an already-created coroutine. Passing ",[14,538,539],{},"run(url)"," instead of ",[14,542,543],{},"run, url"," raises a confusing type error.",[39,546,547,550,551,554,555,557],{},[483,548,549],{},"Exceptions raised after the block."," Code placed after ",[14,552,553],{},"async with"," runs only if the group exited cleanly. Cleanup that must happen regardless belongs in a ",[14,556,472],{}," around the whole block.",[31,559,561],{"id":560},"using-start-instead-of-sleeping-for-readiness","Using start instead of sleeping for readiness",[10,563,564,565,568],{},"The commonest reason a task-group test is flaky is a sleep standing in for \"the server is listening now\". ",[14,566,567],{},"start()"," removes it.",[86,570,572],{"className":88,"code":571,"language":90,"meta":91,"style":91},"import anyio\nimport pytest\n\npytestmark = pytest.mark.anyio\n\n\nasync def serve(port_holder, *, task_status=anyio.TASK_STATUS_IGNORED):\n    listener = await anyio.create_tcp_listener(local_port=0)\n    port_holder.append(listener.extra(anyio.abc.SocketAttribute.local_address)[1])\n    # Signals the caller that initialisation is complete. start() returns here.\n    task_status.started()\n    async with listener:\n        await listener.serve(handle)\n\n\nasync def test_client_connects_to_the_started_server():\n    ports: list[int] = []\n    async with anyio.create_task_group() as tg:\n        # start() does NOT return until task_status.started() is called.\n        await tg.start(serve, ports)\n\n        # No sleep anywhere: the port is known and the listener is bound.\n        async with await anyio.connect_tcp(\"127.0.0.1\", ports[0]) as stream:\n            await stream.send(b\"ping\")\n            assert await stream.receive() == b\"pong\"\n\n        tg.cancel_scope.cancel()          # stop the server; the block then exits\n",[14,573,574,578,582,586,590,594,598,603,608,613,618,623,628,633,637,641,646,651,655,660,665,669,674,679,684,689,693],{"__ignoreMap":91},[95,575,576],{"class":97,"line":98},[95,577,101],{},[95,579,580],{"class":97,"line":104},[95,581,107],{},[95,583,584],{"class":97,"line":110},[95,585,114],{"emptyLinePlaceholder":113},[95,587,588],{"class":97,"line":117},[95,589,120],{},[95,591,592],{"class":97,"line":123},[95,593,114],{"emptyLinePlaceholder":113},[95,595,596],{"class":97,"line":128},[95,597,114],{"emptyLinePlaceholder":113},[95,599,600],{"class":97,"line":133},[95,601,602],{},"async def serve(port_holder, *, task_status=anyio.TASK_STATUS_IGNORED):\n",[95,604,605],{"class":97,"line":139},[95,606,607],{},"    listener = await anyio.create_tcp_listener(local_port=0)\n",[95,609,610],{"class":97,"line":145},[95,611,612],{},"    port_holder.append(listener.extra(anyio.abc.SocketAttribute.local_address)[1])\n",[95,614,615],{"class":97,"line":151},[95,616,617],{},"    # Signals the caller that initialisation is complete. start() returns here.\n",[95,619,620],{"class":97,"line":157},[95,621,622],{},"    task_status.started()\n",[95,624,625],{"class":97,"line":163},[95,626,627],{},"    async with listener:\n",[95,629,630],{"class":97,"line":169},[95,631,632],{},"        await listener.serve(handle)\n",[95,634,635],{"class":97,"line":175},[95,636,114],{"emptyLinePlaceholder":113},[95,638,639],{"class":97,"line":181},[95,640,114],{"emptyLinePlaceholder":113},[95,642,643],{"class":97,"line":186},[95,644,645],{},"async def test_client_connects_to_the_started_server():\n",[95,647,648],{"class":97,"line":192},[95,649,650],{},"    ports: list[int] = []\n",[95,652,653],{"class":97,"line":198},[95,654,189],{},[95,656,657],{"class":97,"line":204},[95,658,659],{},"        # start() does NOT return until task_status.started() is called.\n",[95,661,662],{"class":97,"line":209},[95,663,664],{},"        await tg.start(serve, ports)\n",[95,666,667],{"class":97,"line":214},[95,668,114],{"emptyLinePlaceholder":113},[95,670,671],{"class":97,"line":220},[95,672,673],{},"        # No sleep anywhere: the port is known and the listener is bound.\n",[95,675,676],{"class":97,"line":226},[95,677,678],{},"        async with await anyio.connect_tcp(\"127.0.0.1\", ports[0]) as stream:\n",[95,680,681],{"class":97,"line":231},[95,682,683],{},"            await stream.send(b\"ping\")\n",[95,685,686],{"class":97,"line":237},[95,687,688],{},"            assert await stream.receive() == b\"pong\"\n",[95,690,691],{"class":97,"line":243},[95,692,114],{"emptyLinePlaceholder":113},[95,694,695],{"class":97,"line":249},[95,696,697],{},"        tg.cancel_scope.cancel()          # stop the server; the block then exits\n",[10,699,700,702,703,706,707,709],{},[14,701,567],{}," is the difference between a test that waits exactly as long as startup takes and one that waits a fixed guess. It also carries information: whatever the task passes to ",[14,704,705],{},"started()"," is returned by ",[14,708,567],{},", so a server can hand back its bound port rather than the test fishing it out of a shared list.",[10,711,712,713,716,717,719],{},"The ",[14,714,715],{},"tg.cancel_scope.cancel()"," at the end is necessary because a serving task never finishes on its own, and the group will not exit until every child does. Forgetting it produces a test that hangs at the closing brace of the ",[14,718,553],{},", which is a confusing place for a hang until the rule is internalised: the block waits for all children, always.",[288,721,723,777],{"className":722},[291],[293,724,301,729,301,732,301,735,301,738,301,743,301,750,301,753,301,757,301,762,301,765,301,769,301,773],{"viewBox":725,"role":296,"ariaLabelledBy":726,"xmlns":300},"0 0 800 244",[727,728],"start-t","start-d",[303,730,731],{"id":727},"start_soon versus start for a task that needs initialisation",[307,733,734],{"id":728},"Two timelines. With start_soon the caller resumes immediately and must guess when the server is ready, typically with a sleep that is either too short and flaky or too long and slow. With start the caller is suspended until the task calls task_status.started, so it resumes exactly when initialisation is complete.",[329,736],{"x":331,"y":331,"width":737,"height":349,"rx":334,"fill":335},"800",[337,739,742],{"x":740,"y":340,"textAnchor":341,"fontSize":741,"fontWeight":343,"fill":327},"400","15.5","Who decides when the caller may continue",[329,744],{"x":347,"y":745,"width":746,"height":747,"rx":365,"fill":748,"stroke":749,"strokeWidth":354},"50","748","80","#fbe9e3","#e07a5f",[337,751,529],{"x":429,"y":752,"fontSize":351,"fontWeight":343,"fill":327},"74",[337,754,756],{"x":429,"y":755,"fontSize":365,"fill":327},"96","caller resumes at once → sleep(0.2) → hope the listener is bound",[337,758,761],{"x":429,"y":759,"fontSize":365,"fill":760},"118","#8f3d22","too short on a loaded runner, too long on every other run",[329,763],{"x":347,"y":764,"width":746,"height":747,"rx":365,"fill":386,"stroke":387,"strokeWidth":354},"146",[337,766,768],{"x":429,"y":767,"fontSize":351,"fontWeight":343,"fill":327},"170","start",[337,770,772],{"x":429,"y":771,"fontSize":365,"fill":327},"192","caller suspended → task binds the socket → task_status.started(port) → caller resumes",[337,774,776],{"x":429,"y":775,"fontSize":365,"fill":401},"214","exact, and the port comes back as the return value",[438,778,779],{},"The lower row is both faster and more reliable, which is unusual enough to be worth adopting wherever a task has an initialisation phase.",[31,781,783],{"id":782},"nesting-and-what-the-group-flattens","Nesting, and what the group flattens",[10,785,786],{},"Real code nests groups: a coordinator opens one, each child opens another. The exception shape that results is the part teams get wrong, because it is not simply \"a group of groups\".",[86,788,790],{"className":88,"code":789,"language":90,"meta":91,"style":91},"import anyio\nimport pytest\n\npytestmark = pytest.mark.anyio\n\n\nasync def test_nested_group_failures_are_readable(flaky_shard):\n    with pytest.raises(BaseExceptionGroup) as excinfo:\n        async with anyio.create_task_group() as outer:\n            for shard in (\"a\", \"b\"):\n                outer.start_soon(process_shard, shard, flaky_shard)\n\n    # Flatten before asserting: the nesting depth is an implementation detail\n    # of how many groups happened to be open, and it changes under refactoring.\n    leaves = list(_flatten(excinfo.value))\n    assert [type(e) for e in leaves] == [ValueError]\n    assert str(leaves[0]) == \"shard b is corrupt\"\n\n\ndef _flatten(exc):\n    if isinstance(exc, BaseExceptionGroup):\n        for inner in exc.exceptions:\n            yield from _flatten(inner)\n    else:\n        yield exc\n",[14,791,792,796,800,804,808,812,816,821,826,831,836,841,845,850,855,860,865,870,874,878,883,888,893,898,903],{"__ignoreMap":91},[95,793,794],{"class":97,"line":98},[95,795,101],{},[95,797,798],{"class":97,"line":104},[95,799,107],{},[95,801,802],{"class":97,"line":110},[95,803,114],{"emptyLinePlaceholder":113},[95,805,806],{"class":97,"line":117},[95,807,120],{},[95,809,810],{"class":97,"line":123},[95,811,114],{"emptyLinePlaceholder":113},[95,813,814],{"class":97,"line":128},[95,815,114],{"emptyLinePlaceholder":113},[95,817,818],{"class":97,"line":133},[95,819,820],{},"async def test_nested_group_failures_are_readable(flaky_shard):\n",[95,822,823],{"class":97,"line":139},[95,824,825],{},"    with pytest.raises(BaseExceptionGroup) as excinfo:\n",[95,827,828],{"class":97,"line":145},[95,829,830],{},"        async with anyio.create_task_group() as outer:\n",[95,832,833],{"class":97,"line":151},[95,834,835],{},"            for shard in (\"a\", \"b\"):\n",[95,837,838],{"class":97,"line":157},[95,839,840],{},"                outer.start_soon(process_shard, shard, flaky_shard)\n",[95,842,843],{"class":97,"line":163},[95,844,114],{"emptyLinePlaceholder":113},[95,846,847],{"class":97,"line":169},[95,848,849],{},"    # Flatten before asserting: the nesting depth is an implementation detail\n",[95,851,852],{"class":97,"line":175},[95,853,854],{},"    # of how many groups happened to be open, and it changes under refactoring.\n",[95,856,857],{"class":97,"line":181},[95,858,859],{},"    leaves = list(_flatten(excinfo.value))\n",[95,861,862],{"class":97,"line":186},[95,863,864],{},"    assert [type(e) for e in leaves] == [ValueError]\n",[95,866,867],{"class":97,"line":192},[95,868,869],{},"    assert str(leaves[0]) == \"shard b is corrupt\"\n",[95,871,872],{"class":97,"line":198},[95,873,114],{"emptyLinePlaceholder":113},[95,875,876],{"class":97,"line":204},[95,877,114],{"emptyLinePlaceholder":113},[95,879,880],{"class":97,"line":209},[95,881,882],{},"def _flatten(exc):\n",[95,884,885],{"class":97,"line":214},[95,886,887],{},"    if isinstance(exc, BaseExceptionGroup):\n",[95,889,890],{"class":97,"line":220},[95,891,892],{},"        for inner in exc.exceptions:\n",[95,894,895],{"class":97,"line":226},[95,896,897],{},"            yield from _flatten(inner)\n",[95,899,900],{"class":97,"line":231},[95,901,902],{},"    else:\n",[95,904,905],{"class":97,"line":237},[95,906,907],{},"        yield exc\n",[10,909,910],{},"Asserting on flattened leaves rather than on the nesting is the durable choice. Adding an intermediate group — because a coordinator grew a retry wrapper, say — changes the depth without changing what went wrong, and a test that asserted on depth breaks for no reason a reader can act on.",[288,912,914,987],{"className":913},[291],[293,915,301,919,301,922,301,925,301,932,301,934,301,937,301,943,301,947,301,952,301,956,301,961,301,964,301,968,301,973,301,977,301,981,301,984],{"viewBox":725,"role":296,"ariaLabelledBy":916,"xmlns":300},[917,918],"nest-t","nest-d",[303,920,921],{"id":917},"A nested group's exception structure versus its leaves",[307,923,924],{"id":918},"An outer exception group contains one inner exception group, which contains a single ValueError. Flattening the structure yields one leaf. A note observes that adding another layer of grouping changes the depth but not the leaves, so assertions on the leaves survive refactoring while assertions on depth do not.",[311,926,313,927,301],{},[315,928,930],{"id":929,"viewBox":318,"refX":319,"refY":320,"markerWidth":321,"markerHeight":321,"orient":322},"nest-a",[324,931],{"d":326,"fill":387},[329,933],{"x":331,"y":331,"width":737,"height":349,"rx":334,"fill":335},[337,935,936],{"x":740,"y":340,"textAnchor":341,"fontSize":741,"fontWeight":343,"fill":327},"Assert on the leaves, not the layers",[329,938],{"x":347,"y":745,"width":939,"height":940,"rx":351,"fill":941,"stroke":327,"strokeWidth":942},"380","164","#f4f1de","1.8",[337,944,946],{"x":429,"y":752,"fontSize":945,"fontWeight":343,"fill":327},"11.5","ExceptionGroup (outer group)",[329,948],{"x":745,"y":949,"width":950,"height":951,"rx":430,"fill":352,"stroke":353,"strokeWidth":942},"86","332","112",[337,953,955],{"x":954,"y":364,"fontSize":945,"fontWeight":343,"fill":327},"68","ExceptionGroup (inner group)",[329,957],{"x":752,"y":958,"width":959,"height":960,"rx":319,"fill":748,"stroke":749,"strokeWidth":942},"122","284","60",[337,962,24],{"x":963,"y":764,"textAnchor":341,"fontSize":945,"fontWeight":343,"fill":327},"216",[337,965,967],{"x":963,"y":966,"textAnchor":341,"fontSize":365,"fill":760},"166","\"shard b is corrupt\"",[97,969],{"x1":339,"y1":970,"x2":971,"y2":970,"stroke":387,"strokeWidth":942,"markerEnd":972},"132","446","url(#nest-a)",[329,974],{"x":975,"y":949,"width":394,"height":976,"rx":365,"fill":386,"stroke":387,"strokeWidth":354},"452","92",[337,978,980],{"x":979,"y":951,"textAnchor":341,"fontSize":351,"fontWeight":343,"fill":327},"613","flattened leaves",[337,982,983],{"x":979,"y":369,"textAnchor":341,"fontSize":365,"fill":327},"[ValueError(\"shard b is corrupt\")]",[337,985,986],{"x":979,"y":373,"textAnchor":341,"fontSize":365,"fill":401},"unchanged by adding a layer",[438,988,989],{},"Depth records how the concurrency was organised; leaves record what failed. Only the second is a property of the behaviour under test.",[10,991,992,993,995],{},"Python's own ",[14,994,51],{}," does some flattening for you — matching a leaf type reaches into nested groups — but the group it binds preserves the original structure, so a hand-written flatten is still the clearest thing to assert on. The helper is six lines, lives in the test package, and pays for itself the first time a coordinator gains a layer.",[31,997,999],{"id":998},"proving-nothing-outlives-the-block","Proving nothing outlives the block",[10,1001,1002],{},"The headline promise of structured concurrency is that no task escapes its scope. That promise is worth a test, because the ways it breaks are subtle and none of them fail loudly on their own.",[86,1004,1006],{"className":88,"code":1005,"language":90,"meta":91,"style":91},"import anyio\nimport pytest\n\npytestmark = pytest.mark.anyio\n\n\nasync def test_no_task_outlives_the_group(resource_tracker):\n    async with anyio.create_task_group() as tg:\n        tg.start_soon(worker, resource_tracker)\n        tg.start_soon(worker, resource_tracker)\n\n    # Every resource the workers took has been returned by the time the\n    # block exits — the block waited for them, so this cannot be a race.\n    assert resource_tracker.outstanding == 0\n    assert resource_tracker.opened == resource_tracker.closed\n",[14,1007,1008,1012,1016,1020,1024,1028,1032,1037,1041,1046,1050,1054,1059,1064,1069],{"__ignoreMap":91},[95,1009,1010],{"class":97,"line":98},[95,1011,101],{},[95,1013,1014],{"class":97,"line":104},[95,1015,107],{},[95,1017,1018],{"class":97,"line":110},[95,1019,114],{"emptyLinePlaceholder":113},[95,1021,1022],{"class":97,"line":117},[95,1023,120],{},[95,1025,1026],{"class":97,"line":123},[95,1027,114],{"emptyLinePlaceholder":113},[95,1029,1030],{"class":97,"line":128},[95,1031,114],{"emptyLinePlaceholder":113},[95,1033,1034],{"class":97,"line":133},[95,1035,1036],{},"async def test_no_task_outlives_the_group(resource_tracker):\n",[95,1038,1039],{"class":97,"line":139},[95,1040,189],{},[95,1042,1043],{"class":97,"line":145},[95,1044,1045],{},"        tg.start_soon(worker, resource_tracker)\n",[95,1047,1048],{"class":97,"line":151},[95,1049,1045],{},[95,1051,1052],{"class":97,"line":157},[95,1053,114],{"emptyLinePlaceholder":113},[95,1055,1056],{"class":97,"line":163},[95,1057,1058],{},"    # Every resource the workers took has been returned by the time the\n",[95,1060,1061],{"class":97,"line":169},[95,1062,1063],{},"    # block exits — the block waited for them, so this cannot be a race.\n",[95,1065,1066],{"class":97,"line":175},[95,1067,1068],{},"    assert resource_tracker.outstanding == 0\n",[95,1070,1071],{"class":97,"line":181},[95,1072,1073],{},"    assert resource_tracker.opened == resource_tracker.closed\n",[10,1075,1076,1077,1080,1081,1083],{},"The assertion is placed ",[533,1078,1079],{},"after"," the ",[14,1082,553],{},", and that position is what makes it meaningful. Inside the block it would be racing the workers; outside it, the group has already waited for every child, so any imbalance is a genuine leak rather than a timing artefact.",[10,1085,1086,1087,1090,1091,1093],{},"Three leaks this catches are worth naming. A worker that starts a nested task with ",[14,1088,1089],{},"asyncio.create_task"," rather than through the group escapes the scope entirely and keeps running after the block exits. A worker whose cleanup awaits something slow can be cancelled mid-cleanup, releasing half its resources. And a worker holding a lock when it is cancelled releases it only if the acquisition used ",[14,1092,553],{}," rather than a manual acquire\u002Frelease pair.",[10,1095,1096,1097,1101],{},"For a suite that uses task groups widely, the same check generalises into an autouse fixture asserting that no tasks remain pending at the end of every test, as described in ",[68,1098,1100],{"href":1099},"\u002Ftesting-async-and-concurrent-python\u002Fpytest-asyncio-in-depth\u002F","pytest-asyncio in depth",". Under a function-scoped runtime a stray task dies with the loop and the leak is invisible; under any shared scope it survives, and the fixture converts \"the suite goes strange after test forty\" into \"test thirty-nine leaked a task\".",[31,1103,1105],{"id":1104},"frequently-asked-questions","Frequently Asked Questions",[10,1107,1108,1111,1112,1114,1115,1117,1118,1120,1121,1123,1124,1126,1127,1129],{},[483,1109,1110],{},"Why does pytest.raises(ValueError) fail when a task group child raised ValueError?","\nBecause what propagated was an ",[14,1113,28],{}," wrapping it, not the ",[14,1116,24],{}," itself. Match on ",[14,1119,28],{}," and assert on its ",[14,1122,461],{}," list, or use ",[14,1125,366],{}," with a check on the contents. Python 3.11's ",[14,1128,51],{}," exists for exactly this.",[10,1131,1132,1135,1136,1138,1139,1142],{},[483,1133,1134],{},"How do I assert that sibling tasks were cancelled?","\nGive each task an observable cleanup side effect — appending to a list in its ",[14,1137,472],{},", releasing a lock, closing a fake connection — and assert on that after the group exits. Asserting on ",[14,1140,1141],{},"task.cancelled()"," is not possible from outside, because the group owns the task objects.",[10,1144,1145,1148,1150,1151,1153,1154,1157,1158,1160],{},[483,1146,1147],{},"What is the difference between start_soon and start?",[14,1149,529],{}," schedules the task and returns immediately; ",[14,1152,768],{}," runs the task until it calls ",[14,1155,1156],{},"task_status.started()"," and only then returns, so the caller knows initialisation finished. Use ",[14,1159,768],{}," whenever the test needs the task to be ready before it proceeds, which removes a whole class of sleep-based waiting.",[31,1162,1164],{"id":1163},"related","Related",[36,1166,1167,1174,1181,1191],{},[39,1168,1169,1173],{},[68,1170,1172],{"href":1171},"\u002Ftesting-async-and-concurrent-python\u002Ftesting-with-anyio-and-trio\u002F","Testing with AnyIO & Trio"," — the cancel-scope semantics these groups are built on.",[39,1175,1176,1180],{},[68,1177,1179],{"href":1178},"\u002Ftesting-async-and-concurrent-python\u002Ftimeouts-cancellation-and-deadlines\u002Ftesting-cancellation-and-cleanup-paths\u002F","Testing Cancellation and Cleanup Paths"," — asserting that the unwinding did what it should.",[39,1182,1183,1187,1188,1190],{},[68,1184,1186],{"href":1185},"\u002Ftesting-async-and-concurrent-python\u002Ftimeouts-cancellation-and-deadlines\u002Freplacing-sleep-based-waits-with-polling-assertions\u002F","Replacing Sleep-Based Waits with Polling Assertions"," — what to do when ",[14,1189,768],{}," is unavailable.",[39,1192,1193,1197],{},[68,1194,1196],{"href":1195},"\u002Fsystematic-debugging-performance-profiling\u002Freading-tracebacks-and-exception-chains\u002F","Reading Tracebacks & Exception Chains"," — how a nested group prints, and which frames matter.",[10,1199,1200,1201],{},"← Back to ",[68,1202,1172],{"href":1171},[1204,1205,1206],"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":91,"searchDepth":104,"depth":104,"links":1208},[1209,1210,1211,1212,1213,1214,1215,1216,1217],{"id":33,"depth":104,"text":34},{"id":80,"depth":104,"text":81},{"id":447,"depth":104,"text":448},{"id":476,"depth":104,"text":477},{"id":560,"depth":104,"text":561},{"id":782,"depth":104,"text":783},{"id":998,"depth":104,"text":999},{"id":1104,"depth":104,"text":1105},{"id":1163,"depth":104,"text":1164},"Assert on structured concurrency: ExceptionGroup shape, except* matching, sibling cancellation, start versus start_soon, and proving no task outlives its block.","md",{"slug":1221,"type":1222,"breadcrumb":1223,"datePublished":1224,"dateModified":1224,"faq":1225,"howto":1232},"testing-code-that-uses-task-groups","article","Task Groups","2026-09-18",[1226,1228,1230],{"q":1110,"a":1227},"Because what propagated was an ExceptionGroup wrapping it, not the ValueError itself. Match on ExceptionGroup and assert on its exceptions list, or use pytest.raises(ExceptionGroup) with a check on the contents. Python 3.11's except* exists for exactly this.",{"q":1134,"a":1229},"Give each task an observable cleanup side effect — appending to a list in its finally, releasing a lock, closing a fake connection — and assert on that after the group exits. Asserting on task.cancelled() is not possible from outside, because the group owns the task objects.",{"q":1147,"a":1231},"start_soon schedules the task and returns immediately; start runs the task until it calls task_status.started() and only then returns, so the caller knows initialisation finished. Use start whenever the test needs the task to be ready before it proceeds, which removes a whole class of sleep-based waiting.",{"name":1233,"description":1234,"steps":1235},"How to test code built on task groups","Match on the exception group, assert siblings were cancelled, and use start rather than sleeping for readiness.",[1236,1239,1242,1245,1248],{"name":1237,"text":1238},"Match the group, not the leaf","Wrap the call in pytest.raises(ExceptionGroup) and assert on excinfo.value.exceptions rather than expecting the bare exception type.",{"name":1240,"text":1241},"Give tasks observable cleanup","Have each task record its cancellation in a shared list so the test can assert siblings were stopped.",{"name":1243,"text":1244},"Use start for readiness","Replace sleeps with task_status.started() so the caller resumes exactly when the task is ready.",{"name":1246,"text":1247},"Assert nothing outlives the block","After the async with block, assert no tasks remain pending and every resource was released.",{"name":1249,"text":1250},"Cover the nested case","Test a group inside a group so the flattening and nesting behaviour of exception groups is exercised.","\u002Ftesting-async-and-concurrent-python\u002Ftesting-with-anyio-and-trio\u002Ftesting-code-that-uses-task-groups",{"title":5,"description":1218},"testing-async-and-concurrent-python\u002Ftesting-with-anyio-and-trio\u002Ftesting-code-that-uses-task-groups\u002Findex","TJExntSnNy4c5p67GbYmybD8QVfjj64KqtIH049OE1Y",1789718767415]