[{"data":1,"prerenderedAt":2163},["ShallowReactive",2],{"page-\u002Fscaling-and-operating-production-python-apis\u002Ftesting-python-apis-with-pytest\u002Ftesting-async-fastapi-endpoints-with-httpx\u002F":3,"faq-schema-\u002Fscaling-and-operating-production-python-apis\u002Ftesting-python-apis-with-pytest\u002Ftesting-async-fastapi-endpoints-with-httpx\u002F":2142},{"id":4,"title":5,"body":6,"description":2132,"extension":2133,"meta":2134,"navigation":477,"path":2138,"seo":2139,"stem":2140,"__hash__":2141},"content\u002Fscaling-and-operating-production-python-apis\u002Ftesting-python-apis-with-pytest\u002Ftesting-async-fastapi-endpoints-with-httpx\u002Findex.md","Testing Async FastAPI Endpoints with httpx",{"type":7,"value":8,"toc":2121},"minimark",[9,13,36,39,44,58,72,233,236,314,336,340,343,421,439,790,806,905,909,915,927,1404,1415,1431,1520,1524,1527,1538,1552,1565,1579,1583,1589,1660,1671,1675,1678,1737,1740,2000,2004,2023,2027,2033,2050,2056,2074,2080,2084,2117],[10,11,5],"h1",{"id":12},"testing-async-fastapi-endpoints-with-httpx",[14,15,16,17,21,22,25,26,29,30,35],"p",{},"Your endpoints are ",[18,19,20],"code",{},"async def",". Your database driver is async. Your outbound calls are async. And yet the default testing advice still hands you a synchronous client that spins up a background event loop, which means the moment you need to share an async database session between your test and your endpoint, everything falls apart with a cryptic \"attached to a different loop\" error. This page resolves that: run the whole test through ",[18,23,24],{},"httpx.AsyncClient"," with an ",[18,27,28],{},"ASGITransport"," pointed straight at your app object, so the test, the fixtures, and the endpoint all live on one event loop. It extends ",[31,32,34],"a",{"href":33},"\u002Fscaling-and-operating-production-python-apis\u002Ftesting-python-apis-with-pytest\u002F","Testing Python APIs with pytest",", the parent guide covering suite structure and coverage strategy.",[14,37,38],{},"The commercial argument is simple. In-process async tests cost nothing to start, run four to eight times faster than anything that opens a real socket, and — because they share a loop with your production dependencies — they actually exercise the async database and HTTP code paths you ship. Faster suites mean more pushes per day, and a test suite that runs in twelve seconds instead of ninety is the difference between running it on every save and running it once before a release.",[40,41,43],"h2",{"id":42},"asgitransport-skip-the-network-entirely","ASGITransport: skip the network entirely",[14,45,46,49,50,53,54,57],{},[18,47,48],{},"httpx.ASGITransport"," is a transport implementation that speaks the ASGI protocol directly instead of speaking HTTP over TCP. When your client issues ",[18,51,52],{},"await client.get(\"\u002Fv1\u002Fusage\")",", httpx builds an ASGI scope dictionary, awaits your FastAPI application as a coroutine, and turns the response messages back into an ",[18,55,56],{},"httpx.Response",". No port is bound, no socket is opened, no server process exists. Everything happens inside the same task on the same loop as your test function.",[14,59,60,61,65,66,68,69,71],{},"That matters far beyond speed. A live server runs your app on ",[62,63,64],"em",{},"its"," event loop in ",[62,67,64],{}," process, so a session-scoped SQLAlchemy engine created in a fixture is a completely different object from the one your endpoint uses. You cannot open a transaction in the test, let the endpoint write into it, and roll it back afterwards. With ",[18,70,28],{}," you can, because there is only one loop and one process.",[73,74,82,83,82,87,82,91,82,98,82,117,82,125,82,133,82,139,82,146,82,151,82,155,82,159,82,162,82,166,82,170,82,174,82,178,82,182,82,188,82,192,82,195,82,200,82,205,82,209,82,213,82,217,82,221,82,225,82,229],"svg",{"viewBox":75,"role":76,"ariaLabelledBy":77,"xmlns":80,"style":81},"0 0 720 330","img",[78,79],"async-httpx-path-t","async-httpx-path-d","http:\u002F\u002Fwww.w3.org\u002F2000\u002Fsvg","width:100%;height:auto;margin:1.5rem 0;font-family:var(--font-sans);","\n  ",[84,85,86],"title",{"id":78},"Live server request path versus ASGITransport request path",[88,89,90],"desc",{"id":79},"The top lane shows a test calling through a TCP socket into a uvicorn worker and then the FastAPI app, on a separate event loop. The bottom lane shows the test awaiting ASGITransport directly into the FastAPI app on the same event loop.",[92,93],"rect",{"x":94,"y":94,"width":95,"height":96,"fill":97},"0","720","330","var(--c-surface)",[99,100,101,102,82],"defs",{},"\n    ",[103,104,111,112,101],"marker",{"id":105,"viewBox":106,"refX":107,"refY":108,"markerWidth":109,"markerHeight":109,"orient":110},"async-httpx-path-arrow","0 0 10 10","9","5","7","auto-start-reverse","\n      ",[113,114],"path",{"d":115,"fill":116},"M0 0 L10 5 L0 10 z","var(--c-text-muted)",[118,119,124],"text",{"x":120,"y":121,"fill":122,"style":123},"24","32","var(--c-text)","font-size:13;font-family:var(--font-sans);","Live server: two loops, two processes",[92,126],{"x":120,"y":127,"width":128,"height":129,"rx":130,"fill":97,"stroke":131,"style":132},"48","128","52","8","var(--c-blue)","stroke-width:2;",[118,134,138],{"x":135,"y":136,"fill":122,"style":137},"88","79","text-anchor:middle;font-size:12;font-family:var(--font-sans);","pytest test",[140,141],"line",{"x1":142,"y1":143,"x2":144,"y2":143,"stroke":116,"style":145},"152","74","200","stroke-width:2;marker-end:url(#async-httpx-path-arrow);",[92,147],{"x":148,"y":127,"width":128,"height":129,"rx":130,"fill":149,"stroke":150,"style":132},"206","var(--c-surface-alt)","var(--c-coral)",[118,152,154],{"x":153,"y":136,"fill":122,"style":137},"270","TCP socket",[140,156],{"x1":157,"y1":143,"x2":158,"y2":143,"stroke":116,"style":145},"334","382",[92,160],{"x":161,"y":127,"width":128,"height":129,"rx":130,"fill":149,"stroke":150,"style":132},"388",[118,163,165],{"x":164,"y":136,"fill":122,"style":137},"452","uvicorn loop",[140,167],{"x1":168,"y1":143,"x2":169,"y2":143,"stroke":116,"style":145},"516","564",[92,171],{"x":172,"y":127,"width":173,"height":129,"rx":130,"fill":97,"stroke":131,"style":132},"570","126",[118,175,177],{"x":176,"y":136,"fill":122,"style":137},"633","FastAPI app",[118,179,181],{"x":120,"y":173,"fill":116,"style":180},"font-size:12;font-family:var(--font-sans);","Fixtures cannot share a session or transaction with the app.",[140,183],{"x1":120,"y1":184,"x2":185,"y2":184,"stroke":186,"style":187},"150","696","var(--c-border)","stroke-width:1;",[118,189,191],{"x":120,"y":190,"fill":122,"style":123},"186","ASGITransport: one loop, one process",[92,193],{"x":120,"y":194,"width":184,"height":129,"rx":130,"fill":97,"stroke":131,"style":132},"202",[118,196,199],{"x":197,"y":198,"fill":122,"style":137},"99","233","async test",[140,201],{"x1":202,"y1":203,"x2":204,"y2":203,"stroke":116,"style":145},"174","228","222",[92,206],{"x":203,"y":194,"width":207,"height":129,"rx":130,"fill":149,"stroke":208,"style":132},"180","var(--c-teal)",[118,210,212],{"x":211,"y":198,"fill":122,"style":137},"318","ASGITransport (await)",[140,214],{"x1":215,"y1":203,"x2":216,"y2":203,"stroke":116,"style":145},"408","456",[92,218],{"x":219,"y":194,"width":220,"height":129,"rx":130,"fill":97,"stroke":131,"style":132},"462","234",[118,222,224],{"x":223,"y":198,"fill":122,"style":137},"579","FastAPI app + your fixtures",[118,226,228],{"x":120,"y":227,"fill":116,"style":180},"280","Same loop, so an open transaction and a mocked client both reach the endpoint.",[118,230,232],{"x":120,"y":231,"fill":116,"style":180},"302","Startup cost per suite: about 1.2 s for a live server, effectively zero here.",[14,234,235],{},"The two options differ on more than plumbing. This is the table I would put in front of anyone still defaulting to the synchronous client.",[237,238,239,263],"table",{},[240,241,242],"thead",{},[243,244,245,249,255],"tr",{},[246,247,248],"th",{},"Aspect",[246,250,251,254],{},[18,252,253],{},"TestClient"," (sync)",[246,256,257,260,261],{},[18,258,259],{},"AsyncClient"," + ",[18,262,28],{},[264,265,266,278,289,300],"tbody",{},[243,267,268,272,275],{},[269,270,271],"td",{},"Event loop",[269,273,274],{},"Its own, hidden",[269,276,277],{},"Yours, shared",[243,279,280,283,286],{},[269,281,282],{},"Async fixtures",[269,284,285],{},"Cannot share state",[269,287,288],{},"Share freely",[243,290,291,294,297],{},[269,292,293],{},"Redirects",[269,295,296],{},"Followed by default",[269,298,299],{},"Not followed by default",[243,301,302,305,308],{},[269,303,304],{},"Lifespan events",[269,306,307],{},"Run on context enter",[269,309,310,311],{},"Need ",[18,312,313],{},"asgi-lifespan",[14,315,316,317,320,321,324,325,327,328,331,332,335],{},"The redirect row bites people during migrations: FastAPI redirects ",[18,318,319],{},"\u002Fitems"," to ",[18,322,323],{},"\u002Fitems\u002F"," with a 307, and a suite that passed under ",[18,326,253],{}," suddenly asserts ",[18,329,330],{},"307 != 200",". Pass ",[18,333,334],{},"follow_redirects=True"," on the client or fix the paths — fixing the paths is better, because it tells you your client SDK is also paying for an extra round trip.",[40,337,339],{"id":338},"the-setup-that-actually-works","The setup that actually works",[14,341,342],{},"Three files carry the whole pattern: pytest configuration that puts fixtures and tests on the same loop, a session-scoped app fixture that runs your lifespan handlers, and a function-scoped client. Start with configuration, because getting the loop scope wrong is the single most common cause of a hung suite.",[344,345,350],"pre",{"className":346,"code":347,"language":348,"meta":349,"style":349},"language-toml shiki shiki-themes github-light github-dark","# pyproject.toml\n[tool.pytest.ini_options]\nasyncio_mode = \"auto\"\nasyncio_default_fixture_loop_scope = \"session\"\nasyncio_default_test_loop_scope = \"session\"\naddopts = \"-q --strict-markers\"\n","toml","",[18,351,352,360,385,395,404,412],{"__ignoreMap":349},[353,354,356],"span",{"class":140,"line":355},1,[353,357,359],{"class":358},"sJ8bj","# pyproject.toml\n",[353,361,363,367,371,374,377,379,382],{"class":140,"line":362},2,[353,364,366],{"class":365},"sVt8B","[",[353,368,370],{"class":369},"sScJk","tool",[353,372,373],{"class":365},".",[353,375,376],{"class":369},"pytest",[353,378,373],{"class":365},[353,380,381],{"class":369},"ini_options",[353,383,384],{"class":365},"]\n",[353,386,388,391],{"class":140,"line":387},3,[353,389,390],{"class":365},"asyncio_mode = ",[353,392,394],{"class":393},"sZZnC","\"auto\"\n",[353,396,398,401],{"class":140,"line":397},4,[353,399,400],{"class":365},"asyncio_default_fixture_loop_scope = ",[353,402,403],{"class":393},"\"session\"\n",[353,405,407,410],{"class":140,"line":406},5,[353,408,409],{"class":365},"asyncio_default_test_loop_scope = ",[353,411,403],{"class":393},[353,413,415,418],{"class":140,"line":414},6,[353,416,417],{"class":365},"addopts = ",[353,419,420],{"class":393},"\"-q --strict-markers\"\n",[14,422,423,426,427,430,431,434,435,438],{},[18,424,425],{},"asyncio_mode = \"auto\""," means every ",[18,428,429],{},"async def test_*"," runs without an explicit ",[18,432,433],{},"@pytest.mark.asyncio"," decorator — worth it purely for the class of bug it eliminates, because an un-marked async test is silently collected, never awaited, and reported as passing. The two loop-scope settings (pytest-asyncio 0.26 and later) put session fixtures and test functions on one shared loop; on older versions, decorate tests with ",[18,436,437],{},"@pytest.mark.asyncio(loop_scope=\"session\")"," instead.",[344,440,444],{"className":441,"code":442,"language":443,"meta":349,"style":349},"language-python shiki shiki-themes github-light github-dark","# tests\u002Fconftest.py\nimport os\nfrom collections.abc import AsyncIterator\n\nimport httpx\nimport pytest\nfrom asgi_lifespan import LifespanManager\n\nfrom app.main import app\n\n\n@pytest.fixture(scope=\"session\")\nasync def lifespan_app() -> AsyncIterator:\n    \"\"\"Run startup\u002Fshutdown once per suite, not once per test.\"\"\"\n    async with LifespanManager(app) as manager:\n        yield manager.app\n\n\n@pytest.fixture\nasync def client(lifespan_app) -> AsyncIterator[httpx.AsyncClient]:\n    transport = httpx.ASGITransport(app=lifespan_app)\n    base_url = os.getenv(\"TEST_BASE_URL\", \"http:\u002F\u002Ftestserver\")\n    async with httpx.AsyncClient(transport=transport, base_url=base_url) as ac:\n        yield ac\n\n\nasync def test_usage_endpoint_returns_current_period(client: httpx.AsyncClient):\n    response = await client.get(\"\u002Fv1\u002Fusage\")\n    assert response.status_code == 200\n    assert response.json()[\"period\"] == \"current\"\n","python",[18,445,446,451,460,473,479,486,493,506,511,524,529,534,556,571,577,595,604,609,614,620,633,652,674,705,713,718,723,736,755,771],{"__ignoreMap":349},[353,447,448],{"class":140,"line":355},[353,449,450],{"class":358},"# tests\u002Fconftest.py\n",[353,452,453,457],{"class":140,"line":362},[353,454,456],{"class":455},"szBVR","import",[353,458,459],{"class":365}," os\n",[353,461,462,465,468,470],{"class":140,"line":387},[353,463,464],{"class":455},"from",[353,466,467],{"class":365}," collections.abc ",[353,469,456],{"class":455},[353,471,472],{"class":365}," AsyncIterator\n",[353,474,475],{"class":140,"line":397},[353,476,478],{"emptyLinePlaceholder":477},true,"\n",[353,480,481,483],{"class":140,"line":406},[353,482,456],{"class":455},[353,484,485],{"class":365}," httpx\n",[353,487,488,490],{"class":140,"line":414},[353,489,456],{"class":455},[353,491,492],{"class":365}," pytest\n",[353,494,496,498,501,503],{"class":140,"line":495},7,[353,497,464],{"class":455},[353,499,500],{"class":365}," asgi_lifespan ",[353,502,456],{"class":455},[353,504,505],{"class":365}," LifespanManager\n",[353,507,509],{"class":140,"line":508},8,[353,510,478],{"emptyLinePlaceholder":477},[353,512,514,516,519,521],{"class":140,"line":513},9,[353,515,464],{"class":455},[353,517,518],{"class":365}," app.main ",[353,520,456],{"class":455},[353,522,523],{"class":365}," app\n",[353,525,527],{"class":140,"line":526},10,[353,528,478],{"emptyLinePlaceholder":477},[353,530,532],{"class":140,"line":531},11,[353,533,478],{"emptyLinePlaceholder":477},[353,535,537,540,543,547,550,553],{"class":140,"line":536},12,[353,538,539],{"class":369},"@pytest.fixture",[353,541,542],{"class":365},"(",[353,544,546],{"class":545},"s4XuR","scope",[353,548,549],{"class":455},"=",[353,551,552],{"class":393},"\"session\"",[353,554,555],{"class":365},")\n",[353,557,559,562,565,568],{"class":140,"line":558},13,[353,560,561],{"class":455},"async",[353,563,564],{"class":455}," def",[353,566,567],{"class":369}," lifespan_app",[353,569,570],{"class":365},"() -> AsyncIterator:\n",[353,572,574],{"class":140,"line":573},14,[353,575,576],{"class":393},"    \"\"\"Run startup\u002Fshutdown once per suite, not once per test.\"\"\"\n",[353,578,580,583,586,589,592],{"class":140,"line":579},15,[353,581,582],{"class":455},"    async",[353,584,585],{"class":455}," with",[353,587,588],{"class":365}," LifespanManager(app) ",[353,590,591],{"class":455},"as",[353,593,594],{"class":365}," manager:\n",[353,596,598,601],{"class":140,"line":597},16,[353,599,600],{"class":455},"        yield",[353,602,603],{"class":365}," manager.app\n",[353,605,607],{"class":140,"line":606},17,[353,608,478],{"emptyLinePlaceholder":477},[353,610,612],{"class":140,"line":611},18,[353,613,478],{"emptyLinePlaceholder":477},[353,615,617],{"class":140,"line":616},19,[353,618,619],{"class":369},"@pytest.fixture\n",[353,621,623,625,627,630],{"class":140,"line":622},20,[353,624,561],{"class":455},[353,626,564],{"class":455},[353,628,629],{"class":369}," client",[353,631,632],{"class":365},"(lifespan_app) -> AsyncIterator[httpx.AsyncClient]:\n",[353,634,636,639,641,644,647,649],{"class":140,"line":635},21,[353,637,638],{"class":365},"    transport ",[353,640,549],{"class":455},[353,642,643],{"class":365}," httpx.ASGITransport(",[353,645,646],{"class":545},"app",[353,648,549],{"class":455},[353,650,651],{"class":365},"lifespan_app)\n",[353,653,655,658,660,663,666,669,672],{"class":140,"line":654},22,[353,656,657],{"class":365},"    base_url ",[353,659,549],{"class":455},[353,661,662],{"class":365}," os.getenv(",[353,664,665],{"class":393},"\"TEST_BASE_URL\"",[353,667,668],{"class":365},", ",[353,670,671],{"class":393},"\"http:\u002F\u002Ftestserver\"",[353,673,555],{"class":365},[353,675,677,679,681,684,687,689,692,695,697,700,702],{"class":140,"line":676},23,[353,678,582],{"class":455},[353,680,585],{"class":455},[353,682,683],{"class":365}," httpx.AsyncClient(",[353,685,686],{"class":545},"transport",[353,688,549],{"class":455},[353,690,691],{"class":365},"transport, ",[353,693,694],{"class":545},"base_url",[353,696,549],{"class":455},[353,698,699],{"class":365},"base_url) ",[353,701,591],{"class":455},[353,703,704],{"class":365}," ac:\n",[353,706,708,710],{"class":140,"line":707},24,[353,709,600],{"class":455},[353,711,712],{"class":365}," ac\n",[353,714,716],{"class":140,"line":715},25,[353,717,478],{"emptyLinePlaceholder":477},[353,719,721],{"class":140,"line":720},26,[353,722,478],{"emptyLinePlaceholder":477},[353,724,726,728,730,733],{"class":140,"line":725},27,[353,727,561],{"class":455},[353,729,564],{"class":455},[353,731,732],{"class":369}," test_usage_endpoint_returns_current_period",[353,734,735],{"class":365},"(client: httpx.AsyncClient):\n",[353,737,739,742,744,747,750,753],{"class":140,"line":738},28,[353,740,741],{"class":365},"    response ",[353,743,549],{"class":455},[353,745,746],{"class":455}," await",[353,748,749],{"class":365}," client.get(",[353,751,752],{"class":393},"\"\u002Fv1\u002Fusage\"",[353,754,555],{"class":365},[353,756,758,761,764,767],{"class":140,"line":757},29,[353,759,760],{"class":455},"    assert",[353,762,763],{"class":365}," response.status_code ",[353,765,766],{"class":455},"==",[353,768,770],{"class":769},"sj4cs"," 200\n",[353,772,774,776,779,782,785,787],{"class":140,"line":773},30,[353,775,760],{"class":455},[353,777,778],{"class":365}," response.json()[",[353,780,781],{"class":393},"\"period\"",[353,783,784],{"class":365},"] ",[353,786,766],{"class":455},[353,788,789],{"class":393}," \"current\"\n",[14,791,792,794,795,798,799,802,803,805],{},[18,793,28],{}," does not run the ASGI lifespan protocol, so anything you build in a ",[18,796,797],{},"lifespan"," handler — a connection pool, a Redis client, a warm cache — stays ",[18,800,801],{},"None"," unless you wrap the app. ",[18,804,313],{}," is a two-line dependency that fixes it, and doing it at session scope means you pay startup once for the whole run rather than once per test. If you have moved connection setup out of lifespan and into module-level globals, do the opposite: put it back in lifespan. Globals initialised at import time bind to whatever loop imported them, which is exactly the trap the next section is about.",[73,807,82,812,82,815,82,818,82,821,82,826,82,832,82,838,82,842,82,846,82,850,82,853,82,857,82,860,82,864,82,868,82,872,82,875,82,879,82,882,82,884,82,886,82,889,82,891,82,894,82,896,82,899,82,902],{"viewBox":808,"role":76,"ariaLabelledBy":809,"xmlns":80,"style":81},"0 0 720 340",[810,811],"async-httpx-loop-t","async-httpx-loop-d",[84,813,814],{"id":810},"Mismatched versus matched event loop scope",[88,816,817],{"id":811},"On the left, a session-scoped engine lives on loop A while each test runs on its own loop, so connections fail. On the right, engine and tests share one session loop and every test succeeds.",[92,819],{"x":94,"y":94,"width":95,"height":820,"fill":97},"340",[92,822],{"x":823,"y":823,"width":96,"height":824,"rx":825,"fill":149,"stroke":150,"style":132},"16","308","10",[118,827,831],{"x":828,"y":829,"fill":122,"style":830},"181","44","text-anchor:middle;font-size:13;font-family:var(--font-sans);","fixture loop = function",[92,833],{"x":834,"y":835,"width":836,"height":837,"rx":130,"fill":97,"stroke":186,"style":132},"40","60","282","42",[118,839,841],{"x":828,"y":840,"fill":122,"style":137},"86","engine + pool created on loop A",[92,843],{"x":834,"y":844,"width":836,"height":845,"rx":130,"fill":97,"stroke":186,"style":132},"116","38",[118,847,849],{"x":828,"y":848,"fill":122,"style":137},"140","test_one runs on loop B",[92,851],{"x":834,"y":852,"width":836,"height":845,"rx":130,"fill":97,"stroke":186,"style":132},"164",[118,854,856],{"x":828,"y":855,"fill":122,"style":137},"188","test_two runs on loop C",[92,858],{"x":834,"y":859,"width":836,"height":845,"rx":130,"fill":97,"stroke":186,"style":132},"212",[118,861,863],{"x":828,"y":862,"fill":122,"style":137},"236","test_three runs on loop D",[118,865,867],{"x":828,"y":866,"fill":122,"style":137},"278","Result: future attached to a different loop",[118,869,871],{"x":828,"y":870,"fill":116,"style":137},"300","or a test that never returns.",[92,873],{"x":874,"y":823,"width":96,"height":824,"rx":825,"fill":149,"stroke":208,"style":132},"374",[118,876,878],{"x":877,"y":829,"fill":122,"style":830},"539","fixture loop = session",[92,880],{"x":881,"y":835,"width":836,"height":837,"rx":130,"fill":97,"stroke":186,"style":132},"398",[118,883,841],{"x":877,"y":840,"fill":122,"style":137},[92,885],{"x":881,"y":844,"width":836,"height":845,"rx":130,"fill":97,"stroke":208,"style":132},[118,887,888],{"x":877,"y":848,"fill":122,"style":137},"test_one runs on loop A",[92,890],{"x":881,"y":852,"width":836,"height":845,"rx":130,"fill":97,"stroke":208,"style":132},[118,892,893],{"x":877,"y":855,"fill":122,"style":137},"test_two runs on loop A",[92,895],{"x":881,"y":859,"width":836,"height":845,"rx":130,"fill":97,"stroke":208,"style":132},[118,897,898],{"x":877,"y":862,"fill":122,"style":137},"test_three runs on loop A",[118,900,901],{"x":877,"y":866,"fill":122,"style":137},"Result: one pool, reused, closed once",[118,903,904],{"x":877,"y":870,"fill":116,"style":137},"at the end of the run.",[40,906,908],{"id":907},"overriding-auth-and-the-database","Overriding auth and the database",[14,910,911,914],{},[18,912,913],{},"app.dependency_overrides"," is a plain dict keyed by the dependency callable. FastAPI checks it before resolving anything, so replacing a dependency is a dict assignment — no monkeypatching, no import juggling. The two you will override on nearly every test are the database session and the current user.",[14,916,917,918,922,923,373],{},"For the database, the pattern that gives you both realism and isolation is a transaction the test owns: open a connection, begin a transaction, bind a session to that connection, hand the same session to the endpoint, and roll back afterwards. Every test sees a real Postgres with real constraints, and no test sees another test's rows. This builds on ",[31,919,921],{"href":920},"\u002Fscaling-and-operating-production-python-apis\u002Fasync-database-access-with-sqlalchemy\u002F","async database access with SQLAlchemy",", and the driver choice matters here too — see ",[31,924,926],{"href":925},"\u002Fscaling-and-operating-production-python-apis\u002Fasync-database-access-with-sqlalchemy\u002Fasyncpg-vs-psycopg3-for-fastapi\u002F","asyncpg vs psycopg3 for FastAPI",[344,928,930],{"className":441,"code":929,"language":443,"meta":349,"style":349},"# tests\u002Fconftest.py (continued)\nimport os\nfrom collections.abc import AsyncIterator\n\nimport pytest\nfrom sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine\nfrom sqlalchemy.pool import NullPool\n\nfrom app.auth import get_current_user\nfrom app.db import get_session\nfrom app.main import app\nfrom app.models import User\n\n\n@pytest.fixture(scope=\"session\")\nasync def engine():\n    url = os.getenv(\"TEST_DATABASE_URL\")\n    if not url:\n        pytest.skip(\"TEST_DATABASE_URL is not set\")\n    eng = create_async_engine(url, poolclass=NullPool)\n    yield eng\n    await eng.dispose()\n\n\n@pytest.fixture\nasync def session(engine) -> AsyncIterator[AsyncSession]:\n    connection = await engine.connect()\n    transaction = await connection.begin()\n    maker = async_sessionmaker(bind=connection, expire_on_commit=False)\n    async with maker() as db_session:\n        yield db_session\n    await transaction.rollback()\n    await connection.close()\n\n\n@pytest.fixture\ndef overrides(session: AsyncSession) -> AsyncIterator[None]:\n    async def _session_override() -> AsyncIterator[AsyncSession]:\n        yield session\n\n    def _user_override() -> User:\n        return User(id=int(os.getenv(\"TEST_USER_ID\", \"1\")), plan=\"pro\")\n\n    app.dependency_overrides[get_session] = _session_override\n    app.dependency_overrides[get_current_user] = _user_override\n    yield\n    app.dependency_overrides.clear()\n",[18,931,932,937,943,953,957,963,975,987,991,1003,1015,1025,1037,1041,1045,1059,1071,1085,1096,1106,1124,1132,1140,1144,1148,1152,1164,1176,1188,1216,1230,1238,1246,1254,1259,1264,1269,1286,1299,1307,1312,1324,1365,1370,1381,1392,1398],{"__ignoreMap":349},[353,933,934],{"class":140,"line":355},[353,935,936],{"class":358},"# tests\u002Fconftest.py (continued)\n",[353,938,939,941],{"class":140,"line":362},[353,940,456],{"class":455},[353,942,459],{"class":365},[353,944,945,947,949,951],{"class":140,"line":387},[353,946,464],{"class":455},[353,948,467],{"class":365},[353,950,456],{"class":455},[353,952,472],{"class":365},[353,954,955],{"class":140,"line":397},[353,956,478],{"emptyLinePlaceholder":477},[353,958,959,961],{"class":140,"line":406},[353,960,456],{"class":455},[353,962,492],{"class":365},[353,964,965,967,970,972],{"class":140,"line":414},[353,966,464],{"class":455},[353,968,969],{"class":365}," sqlalchemy.ext.asyncio ",[353,971,456],{"class":455},[353,973,974],{"class":365}," AsyncSession, async_sessionmaker, create_async_engine\n",[353,976,977,979,982,984],{"class":140,"line":495},[353,978,464],{"class":455},[353,980,981],{"class":365}," sqlalchemy.pool ",[353,983,456],{"class":455},[353,985,986],{"class":365}," NullPool\n",[353,988,989],{"class":140,"line":508},[353,990,478],{"emptyLinePlaceholder":477},[353,992,993,995,998,1000],{"class":140,"line":513},[353,994,464],{"class":455},[353,996,997],{"class":365}," app.auth ",[353,999,456],{"class":455},[353,1001,1002],{"class":365}," get_current_user\n",[353,1004,1005,1007,1010,1012],{"class":140,"line":526},[353,1006,464],{"class":455},[353,1008,1009],{"class":365}," app.db ",[353,1011,456],{"class":455},[353,1013,1014],{"class":365}," get_session\n",[353,1016,1017,1019,1021,1023],{"class":140,"line":531},[353,1018,464],{"class":455},[353,1020,518],{"class":365},[353,1022,456],{"class":455},[353,1024,523],{"class":365},[353,1026,1027,1029,1032,1034],{"class":140,"line":536},[353,1028,464],{"class":455},[353,1030,1031],{"class":365}," app.models ",[353,1033,456],{"class":455},[353,1035,1036],{"class":365}," User\n",[353,1038,1039],{"class":140,"line":558},[353,1040,478],{"emptyLinePlaceholder":477},[353,1042,1043],{"class":140,"line":573},[353,1044,478],{"emptyLinePlaceholder":477},[353,1046,1047,1049,1051,1053,1055,1057],{"class":140,"line":579},[353,1048,539],{"class":369},[353,1050,542],{"class":365},[353,1052,546],{"class":545},[353,1054,549],{"class":455},[353,1056,552],{"class":393},[353,1058,555],{"class":365},[353,1060,1061,1063,1065,1068],{"class":140,"line":597},[353,1062,561],{"class":455},[353,1064,564],{"class":455},[353,1066,1067],{"class":369}," engine",[353,1069,1070],{"class":365},"():\n",[353,1072,1073,1076,1078,1080,1083],{"class":140,"line":606},[353,1074,1075],{"class":365},"    url ",[353,1077,549],{"class":455},[353,1079,662],{"class":365},[353,1081,1082],{"class":393},"\"TEST_DATABASE_URL\"",[353,1084,555],{"class":365},[353,1086,1087,1090,1093],{"class":140,"line":611},[353,1088,1089],{"class":455},"    if",[353,1091,1092],{"class":455}," not",[353,1094,1095],{"class":365}," url:\n",[353,1097,1098,1101,1104],{"class":140,"line":616},[353,1099,1100],{"class":365},"        pytest.skip(",[353,1102,1103],{"class":393},"\"TEST_DATABASE_URL is not set\"",[353,1105,555],{"class":365},[353,1107,1108,1111,1113,1116,1119,1121],{"class":140,"line":622},[353,1109,1110],{"class":365},"    eng ",[353,1112,549],{"class":455},[353,1114,1115],{"class":365}," create_async_engine(url, ",[353,1117,1118],{"class":545},"poolclass",[353,1120,549],{"class":455},[353,1122,1123],{"class":365},"NullPool)\n",[353,1125,1126,1129],{"class":140,"line":635},[353,1127,1128],{"class":455},"    yield",[353,1130,1131],{"class":365}," eng\n",[353,1133,1134,1137],{"class":140,"line":654},[353,1135,1136],{"class":455},"    await",[353,1138,1139],{"class":365}," eng.dispose()\n",[353,1141,1142],{"class":140,"line":676},[353,1143,478],{"emptyLinePlaceholder":477},[353,1145,1146],{"class":140,"line":707},[353,1147,478],{"emptyLinePlaceholder":477},[353,1149,1150],{"class":140,"line":715},[353,1151,619],{"class":369},[353,1153,1154,1156,1158,1161],{"class":140,"line":720},[353,1155,561],{"class":455},[353,1157,564],{"class":455},[353,1159,1160],{"class":369}," session",[353,1162,1163],{"class":365},"(engine) -> AsyncIterator[AsyncSession]:\n",[353,1165,1166,1169,1171,1173],{"class":140,"line":725},[353,1167,1168],{"class":365},"    connection ",[353,1170,549],{"class":455},[353,1172,746],{"class":455},[353,1174,1175],{"class":365}," engine.connect()\n",[353,1177,1178,1181,1183,1185],{"class":140,"line":738},[353,1179,1180],{"class":365},"    transaction ",[353,1182,549],{"class":455},[353,1184,746],{"class":455},[353,1186,1187],{"class":365}," connection.begin()\n",[353,1189,1190,1193,1195,1198,1201,1203,1206,1209,1211,1214],{"class":140,"line":757},[353,1191,1192],{"class":365},"    maker ",[353,1194,549],{"class":455},[353,1196,1197],{"class":365}," async_sessionmaker(",[353,1199,1200],{"class":545},"bind",[353,1202,549],{"class":455},[353,1204,1205],{"class":365},"connection, ",[353,1207,1208],{"class":545},"expire_on_commit",[353,1210,549],{"class":455},[353,1212,1213],{"class":769},"False",[353,1215,555],{"class":365},[353,1217,1218,1220,1222,1225,1227],{"class":140,"line":773},[353,1219,582],{"class":455},[353,1221,585],{"class":455},[353,1223,1224],{"class":365}," maker() ",[353,1226,591],{"class":455},[353,1228,1229],{"class":365}," db_session:\n",[353,1231,1233,1235],{"class":140,"line":1232},31,[353,1234,600],{"class":455},[353,1236,1237],{"class":365}," db_session\n",[353,1239,1241,1243],{"class":140,"line":1240},32,[353,1242,1136],{"class":455},[353,1244,1245],{"class":365}," transaction.rollback()\n",[353,1247,1249,1251],{"class":140,"line":1248},33,[353,1250,1136],{"class":455},[353,1252,1253],{"class":365}," connection.close()\n",[353,1255,1257],{"class":140,"line":1256},34,[353,1258,478],{"emptyLinePlaceholder":477},[353,1260,1262],{"class":140,"line":1261},35,[353,1263,478],{"emptyLinePlaceholder":477},[353,1265,1267],{"class":140,"line":1266},36,[353,1268,619],{"class":369},[353,1270,1272,1275,1278,1281,1283],{"class":140,"line":1271},37,[353,1273,1274],{"class":455},"def",[353,1276,1277],{"class":369}," overrides",[353,1279,1280],{"class":365},"(session: AsyncSession) -> AsyncIterator[",[353,1282,801],{"class":769},[353,1284,1285],{"class":365},"]:\n",[353,1287,1289,1291,1293,1296],{"class":140,"line":1288},38,[353,1290,582],{"class":455},[353,1292,564],{"class":455},[353,1294,1295],{"class":369}," _session_override",[353,1297,1298],{"class":365},"() -> AsyncIterator[AsyncSession]:\n",[353,1300,1302,1304],{"class":140,"line":1301},39,[353,1303,600],{"class":455},[353,1305,1306],{"class":365}," session\n",[353,1308,1310],{"class":140,"line":1309},40,[353,1311,478],{"emptyLinePlaceholder":477},[353,1313,1315,1318,1321],{"class":140,"line":1314},41,[353,1316,1317],{"class":455},"    def",[353,1319,1320],{"class":369}," _user_override",[353,1322,1323],{"class":365},"() -> User:\n",[353,1325,1327,1330,1333,1336,1338,1341,1344,1347,1349,1352,1355,1358,1360,1363],{"class":140,"line":1326},42,[353,1328,1329],{"class":455},"        return",[353,1331,1332],{"class":365}," User(",[353,1334,1335],{"class":545},"id",[353,1337,549],{"class":455},[353,1339,1340],{"class":769},"int",[353,1342,1343],{"class":365},"(os.getenv(",[353,1345,1346],{"class":393},"\"TEST_USER_ID\"",[353,1348,668],{"class":365},[353,1350,1351],{"class":393},"\"1\"",[353,1353,1354],{"class":365},")), ",[353,1356,1357],{"class":545},"plan",[353,1359,549],{"class":455},[353,1361,1362],{"class":393},"\"pro\"",[353,1364,555],{"class":365},[353,1366,1368],{"class":140,"line":1367},43,[353,1369,478],{"emptyLinePlaceholder":477},[353,1371,1373,1376,1378],{"class":140,"line":1372},44,[353,1374,1375],{"class":365},"    app.dependency_overrides[get_session] ",[353,1377,549],{"class":455},[353,1379,1380],{"class":365}," _session_override\n",[353,1382,1384,1387,1389],{"class":140,"line":1383},45,[353,1385,1386],{"class":365},"    app.dependency_overrides[get_current_user] ",[353,1388,549],{"class":455},[353,1390,1391],{"class":365}," _user_override\n",[353,1393,1395],{"class":140,"line":1394},46,[353,1396,1397],{"class":455},"    yield\n",[353,1399,1401],{"class":140,"line":1400},47,[353,1402,1403],{"class":365},"    app.dependency_overrides.clear()\n",[14,1405,1406,1409,1410,1414],{},[18,1407,1408],{},"NullPool"," is deliberate: a pooled engine holds connections between tests, and if any fixture ends up on a different loop those pooled connections become the source of the hangs described in ",[31,1411,1413],{"href":1412},"\u002Fscaling-and-operating-production-python-apis\u002Fasync-database-access-with-sqlalchemy\u002Ffixing-connection-pool-exhaustion\u002F","fixing connection pool exhaustion",". In tests you want a fresh connection and a clean close.",[14,1416,1417,1418,1422,1423,1426,1427,1430],{},"Overriding the auth dependency rather than minting real tokens is the right call for the ninety percent case — you are testing the endpoint's behaviour, not the signature algorithm. Keep a small number of tests that hit the real dependency with a genuine token so token verification itself stays covered; the mechanics of that live in ",[31,1419,1421],{"href":1420},"\u002Fgetting-started-with-python-apis-for-builders\u002Fhandling-api-authentication-in-python\u002F","handling API authentication in Python",". Note the ",[18,1424,1425],{},"clear()"," after the ",[18,1428,1429],{},"yield",": overrides live on the app object, which is module-level and shared, so forgetting this line leaks a fake user into every test that runs after it and produces failures that only appear when the suite runs in a particular order.",[73,1432,82,1437,82,1440,82,1443,82,1445,82,1449,82,1453,82,1457,82,1460,82,1464,82,1469,82,1472,82,1476,82,1479,82,1483,82,1486,82,1490,82,1492,82,1495,82,1497,82,1500,82,1502,82,1506,82,1508,82,1511,82,1514,82,1517],{"viewBox":1433,"role":76,"ariaLabelledBy":1434,"xmlns":80,"style":81},"0 0 720 300",[1435,1436],"async-httpx-dep-t","async-httpx-dep-d",[84,1438,1439],{"id":1435},"Dependency override matrix for tests",[88,1441,1442],{"id":1436},"A three-row matrix mapping each production dependency to its test replacement: session becomes a rolled-back transaction, current user becomes a fixed pro-plan user, and the billing client becomes a recording stub.",[92,1444],{"x":94,"y":94,"width":95,"height":870,"fill":97},[118,1446,1448],{"x":1447,"y":845,"fill":122,"style":123},"30","Dependency",[118,1450,1452],{"x":1451,"y":845,"fill":122,"style":123},"266","Production",[118,1454,1456],{"x":1455,"y":845,"fill":122,"style":123},"486","Test override",[140,1458],{"x1":120,"y1":1459,"x2":185,"y2":1459,"stroke":186,"style":132},"50",[92,1461],{"x":120,"y":1462,"width":1463,"height":129,"rx":130,"fill":149,"stroke":131,"style":132},"62","216",[118,1465,1468],{"x":1466,"y":1467,"fill":122,"style":137},"132","93","get_session",[92,1470],{"x":1471,"y":1462,"width":144,"height":129,"rx":130,"fill":97,"stroke":186,"style":132},"260",[118,1473,1475],{"x":1474,"y":1467,"fill":122,"style":137},"360","pooled engine",[92,1477],{"x":1478,"y":1462,"width":1463,"height":129,"rx":130,"fill":97,"stroke":208,"style":132},"480",[118,1480,1482],{"x":1481,"y":1467,"fill":122,"style":137},"588","rolled-back transaction",[92,1484],{"x":120,"y":1485,"width":1463,"height":129,"rx":130,"fill":149,"stroke":131,"style":132},"124",[118,1487,1489],{"x":1466,"y":1488,"fill":122,"style":137},"155","get_current_user",[92,1491],{"x":1471,"y":1485,"width":144,"height":129,"rx":130,"fill":97,"stroke":186,"style":132},[118,1493,1494],{"x":1474,"y":1488,"fill":122,"style":137},"JWT verify",[92,1496],{"x":1478,"y":1485,"width":1463,"height":129,"rx":130,"fill":97,"stroke":208,"style":132},[118,1498,1499],{"x":1481,"y":1488,"fill":122,"style":137},"fixed pro-plan user",[92,1501],{"x":120,"y":190,"width":1463,"height":129,"rx":130,"fill":149,"stroke":131,"style":132},[118,1503,1505],{"x":1466,"y":1504,"fill":122,"style":137},"217","get_billing_client",[92,1507],{"x":1471,"y":190,"width":144,"height":129,"rx":130,"fill":97,"stroke":186,"style":132},[118,1509,1510],{"x":1474,"y":1504,"fill":122,"style":137},"live Stripe call",[92,1512],{"x":1478,"y":190,"width":1463,"height":129,"rx":130,"fill":97,"stroke":1513,"style":132},"var(--c-yellow)",[118,1515,1516],{"x":1481,"y":1504,"fill":122,"style":137},"recording stub",[118,1518,1519],{"x":120,"y":153,"fill":116,"style":180},"Every override is registered in a fixture and removed by dependency_overrides.clear().",[40,1521,1523],{"id":1522},"why-async-tests-hang-or-leak-state","Why async tests hang or leak state",[14,1525,1526],{},"Four failure modes account for almost every \"my async tests are broken\" issue, and none of them produce a helpful error message.",[14,1528,1529,1533,1534,1537],{},[1530,1531,1532],"strong",{},"Mismatched loop scope."," A session-scoped fixture that creates a connection pool on one loop, used by function-scoped tests each on a fresh loop, either raises ",[18,1535,1536],{},"got Future attached to a different loop"," or simply waits forever on a lock that no running loop will ever release. Fix the scopes so they match, as in the configuration above.",[14,1539,1540,1543,1544,1546,1547,1551],{},[1530,1541,1542],{},"An unmocked outbound call."," With ",[18,1545,28],{}," there is no network isolation — if your endpoint calls a payment provider, the test will actually call it, and if that host is unreachable in CI the test blocks until the httpx timeout fires. Set an explicit timeout on every outbound client and mock the calls; ",[31,1548,1550],{"href":1549},"\u002Fscaling-and-operating-production-python-apis\u002Ftesting-python-apis-with-pytest\u002Fmocking-external-apis-with-respx\u002F","mocking external APIs with respx"," is the sibling page covering exactly that, and it plugs into the same transport layer.",[14,1553,1554,1557,1558,1561,1562,1564],{},[1530,1555,1556],{},"Blocking code in an async endpoint."," A synchronous database driver, ",[18,1559,1560],{},"time.sleep",", or a heavy CPU loop inside ",[18,1563,20],{}," blocks the one loop your test also lives on. In production a blocked loop shows up as latency; in a test it looks like a freeze.",[14,1566,1567,1570,1571,1574,1575,1578],{},[1530,1568,1569],{},"Leaked global state."," ",[18,1572,1573],{},"dependency_overrides"," left populated, an in-process cache that survives between tests, a module-level httpx client that was never closed. These produce order-dependent failures: green locally, red in CI when pytest happens to shard differently. Run ",[18,1576,1577],{},"pytest -p no:randomly -x"," versus a randomised order occasionally to catch them early.",[40,1580,1582],{"id":1581},"when-this-approach-wins-and-when-it-does-not","When this approach wins, and when it does not",[14,1584,1585,1586,1588],{},"Use ",[18,1587,28],{}," for essentially all endpoint tests: request validation, auth rules, error mapping, database behaviour, pagination, quota enforcement. It is the cheapest possible way to get real coverage, and at CI-minute prices the difference is not trivial. The chart below is a 400-test suite on a 2-vCPU runner; your absolute numbers will differ but the ratios hold.",[73,1590,82,1595,82,1598,82,1601,82,1603,82,1606,82,1609,82,1614,82,1619,82,1623,82,1626,82,1631,82,1635,82,1639,82,1644,82,1647,82,1649,82,1652,82,1656],{"viewBox":1591,"role":76,"ariaLabelledBy":1592,"xmlns":80,"style":81},"0 0 720 280",[1593,1594],"async-httpx-chart-t","async-httpx-chart-d",[84,1596,1597],{"id":1593},"Suite runtime by test client strategy",[88,1599,1600],{"id":1594},"Horizontal bars for a 400-test suite: ASGITransport eleven seconds, synchronous TestClient forty-two seconds, live uvicorn server over real HTTP ninety-six seconds.",[92,1602],{"x":94,"y":94,"width":95,"height":227,"fill":97},[118,1604,1605],{"x":120,"y":121,"fill":122,"style":123},"400-test suite, wall clock seconds",[118,1607,28],{"x":120,"y":1608,"fill":122,"style":180},"70",[92,1610],{"x":207,"y":1611,"width":1612,"height":120,"rx":1613,"fill":208},"56","55","4",[118,1615,1618],{"x":1616,"y":1617,"fill":122,"style":180},"247","73","11 s",[118,1620,1622],{"x":120,"y":1621,"fill":122,"style":180},"130","TestClient (sync)",[92,1624],{"x":207,"y":844,"width":1625,"height":120,"rx":1613,"fill":131},"210",[118,1627,1630],{"x":1628,"y":1629,"fill":122,"style":180},"402","133","42 s",[118,1632,1634],{"x":120,"y":1633,"fill":122,"style":180},"190","Live uvicorn",[92,1636],{"x":207,"y":1637,"width":1638,"height":120,"rx":1613,"fill":150},"176","440",[118,1640,1643],{"x":1641,"y":1642,"fill":122,"style":180},"632","193","96 s",[140,1645],{"x1":207,"y1":1463,"x2":1646,"y2":1463,"stroke":186,"style":132},"620",[118,1648,94],{"x":207,"y":220,"fill":116,"style":180},[118,1650,127],{"x":1651,"y":220,"fill":116,"style":180},"392",[118,1653,1655],{"x":1654,"y":220,"fill":116,"style":180},"610","96",[118,1657,1659],{"x":120,"y":1658,"fill":116,"style":180},"264","Measured on a 2-vCPU runner; ratios matter more than absolutes.",[14,1661,1662,1663,1665,1666,1670],{},"Avoid it for three things. Anything that depends on real HTTP semantics — keep-alive, streaming chunk boundaries, proxy headers, HTTP\u002F2 — needs a real server, because ",[18,1664,28],{}," reconstructs the response from ASGI messages rather than parsing wire bytes. Anything that depends on your process manager, such as worker recycling or graceful shutdown behaviour during ",[31,1667,1669],{"href":1668},"\u002Fbuilding-monetizing-api-driven-micro-saas\u002Fdeploying-apis-to-render-or-vercel\u002Fzero-downtime-deploys-for-python-apis\u002F","zero-downtime deploys",". And post-deploy smoke checks, which by definition must exercise the deployed URL. Keep a handful of tests in each of those categories and let the in-process suite carry the other ninety-five percent.",[40,1672,1674],{"id":1673},"migrating-an-existing-sync-suite","Migrating an existing sync suite",[14,1676,1677],{},"The switch takes an afternoon on a mid-sized suite, and you can do it incrementally because both clients can coexist during the transition.",[1679,1680,1681,1695,1709,1728,1731],"ol",{},[1682,1683,1684,1685,668,1688,1690,1691,1694],"li",{},"Add ",[18,1686,1687],{},"pytest-asyncio",[18,1689,313],{},", and the ",[18,1692,1693],{},"pyproject.toml"," block above. Existing sync tests keep passing.",[1682,1696,1697,1698,1701,1702,1705,1706,1708],{},"Add the ",[18,1699,1700],{},"lifespan_app"," and ",[18,1703,1704],{},"client"," fixtures alongside your current ",[18,1707,253],{}," fixture — do not delete anything yet.",[1682,1710,1711,1712,1715,1716,1719,1720,1723,1724,1727],{},"Convert one module: ",[18,1713,1714],{},"def test_x(client)"," becomes ",[18,1717,1718],{},"async def test_x(client)",", and every ",[18,1721,1722],{},"client.get(...)"," gains an ",[18,1725,1726],{},"await",". Fix the 307s that appear.",[1682,1729,1730],{},"Move database fixtures to the transaction-per-test pattern and delete any truncate-between-tests helper you were using. This is usually where the biggest speed win comes from.",[1682,1732,1733,1734,1736],{},"Convert the rest module by module, then delete the sync fixture and remove ",[18,1735,334],{}," if you added it as a crutch.",[14,1738,1739],{},"One helper makes the same tests run against a deployed environment for smoke checks, so you are not maintaining two suites:",[344,1741,1743],{"className":441,"code":1742,"language":443,"meta":349,"style":349},"# tests\u002Ftransport.py\nimport os\n\nimport httpx\n\nfrom app.main import app\n\n\ndef build_client() -> httpx.AsyncClient:\n    mode = os.getenv(\"TEST_MODE\", \"asgi\")\n    timeout = httpx.Timeout(float(os.getenv(\"TEST_TIMEOUT_SECONDS\", \"5\")))\n    match mode:\n        case \"asgi\":\n            return httpx.AsyncClient(\n                transport=httpx.ASGITransport(app=app),\n                base_url=os.getenv(\"TEST_BASE_URL\", \"http:\u002F\u002Ftestserver\"),\n                timeout=timeout,\n            )\n        case \"live\":\n            return httpx.AsyncClient(\n                base_url=os.environ[\"SMOKE_BASE_URL\"],\n                timeout=timeout,\n            )\n        case _:\n            raise ValueError(f\"unsupported TEST_MODE: {mode}\")\n",[18,1744,1745,1750,1756,1760,1766,1770,1780,1784,1788,1798,1817,1843,1851,1862,1870,1887,1906,1916,1921,1930,1936,1951,1959,1963,1970],{"__ignoreMap":349},[353,1746,1747],{"class":140,"line":355},[353,1748,1749],{"class":358},"# tests\u002Ftransport.py\n",[353,1751,1752,1754],{"class":140,"line":362},[353,1753,456],{"class":455},[353,1755,459],{"class":365},[353,1757,1758],{"class":140,"line":387},[353,1759,478],{"emptyLinePlaceholder":477},[353,1761,1762,1764],{"class":140,"line":397},[353,1763,456],{"class":455},[353,1765,485],{"class":365},[353,1767,1768],{"class":140,"line":406},[353,1769,478],{"emptyLinePlaceholder":477},[353,1771,1772,1774,1776,1778],{"class":140,"line":414},[353,1773,464],{"class":455},[353,1775,518],{"class":365},[353,1777,456],{"class":455},[353,1779,523],{"class":365},[353,1781,1782],{"class":140,"line":495},[353,1783,478],{"emptyLinePlaceholder":477},[353,1785,1786],{"class":140,"line":508},[353,1787,478],{"emptyLinePlaceholder":477},[353,1789,1790,1792,1795],{"class":140,"line":513},[353,1791,1274],{"class":455},[353,1793,1794],{"class":369}," build_client",[353,1796,1797],{"class":365},"() -> httpx.AsyncClient:\n",[353,1799,1800,1803,1805,1807,1810,1812,1815],{"class":140,"line":526},[353,1801,1802],{"class":365},"    mode ",[353,1804,549],{"class":455},[353,1806,662],{"class":365},[353,1808,1809],{"class":393},"\"TEST_MODE\"",[353,1811,668],{"class":365},[353,1813,1814],{"class":393},"\"asgi\"",[353,1816,555],{"class":365},[353,1818,1819,1822,1824,1827,1830,1832,1835,1837,1840],{"class":140,"line":531},[353,1820,1821],{"class":365},"    timeout ",[353,1823,549],{"class":455},[353,1825,1826],{"class":365}," httpx.Timeout(",[353,1828,1829],{"class":769},"float",[353,1831,1343],{"class":365},[353,1833,1834],{"class":393},"\"TEST_TIMEOUT_SECONDS\"",[353,1836,668],{"class":365},[353,1838,1839],{"class":393},"\"5\"",[353,1841,1842],{"class":365},")))\n",[353,1844,1845,1848],{"class":140,"line":536},[353,1846,1847],{"class":455},"    match",[353,1849,1850],{"class":365}," mode:\n",[353,1852,1853,1856,1859],{"class":140,"line":558},[353,1854,1855],{"class":455},"        case",[353,1857,1858],{"class":393}," \"asgi\"",[353,1860,1861],{"class":365},":\n",[353,1863,1864,1867],{"class":140,"line":573},[353,1865,1866],{"class":455},"            return",[353,1868,1869],{"class":365}," httpx.AsyncClient(\n",[353,1871,1872,1875,1877,1880,1882,1884],{"class":140,"line":579},[353,1873,1874],{"class":545},"                transport",[353,1876,549],{"class":455},[353,1878,1879],{"class":365},"httpx.ASGITransport(",[353,1881,646],{"class":545},[353,1883,549],{"class":455},[353,1885,1886],{"class":365},"app),\n",[353,1888,1889,1892,1894,1897,1899,1901,1903],{"class":140,"line":597},[353,1890,1891],{"class":545},"                base_url",[353,1893,549],{"class":455},[353,1895,1896],{"class":365},"os.getenv(",[353,1898,665],{"class":393},[353,1900,668],{"class":365},[353,1902,671],{"class":393},[353,1904,1905],{"class":365},"),\n",[353,1907,1908,1911,1913],{"class":140,"line":606},[353,1909,1910],{"class":545},"                timeout",[353,1912,549],{"class":455},[353,1914,1915],{"class":365},"timeout,\n",[353,1917,1918],{"class":140,"line":611},[353,1919,1920],{"class":365},"            )\n",[353,1922,1923,1925,1928],{"class":140,"line":616},[353,1924,1855],{"class":455},[353,1926,1927],{"class":393}," \"live\"",[353,1929,1861],{"class":365},[353,1931,1932,1934],{"class":140,"line":622},[353,1933,1866],{"class":455},[353,1935,1869],{"class":365},[353,1937,1938,1940,1942,1945,1948],{"class":140,"line":635},[353,1939,1891],{"class":545},[353,1941,549],{"class":455},[353,1943,1944],{"class":365},"os.environ[",[353,1946,1947],{"class":393},"\"SMOKE_BASE_URL\"",[353,1949,1950],{"class":365},"],\n",[353,1952,1953,1955,1957],{"class":140,"line":654},[353,1954,1910],{"class":545},[353,1956,549],{"class":455},[353,1958,1915],{"class":365},[353,1960,1961],{"class":140,"line":676},[353,1962,1920],{"class":365},[353,1964,1965,1967],{"class":140,"line":707},[353,1966,1855],{"class":455},[353,1968,1969],{"class":365}," _:\n",[353,1971,1972,1975,1978,1980,1983,1986,1989,1992,1995,1998],{"class":140,"line":715},[353,1973,1974],{"class":455},"            raise",[353,1976,1977],{"class":769}," ValueError",[353,1979,542],{"class":365},[353,1981,1982],{"class":455},"f",[353,1984,1985],{"class":393},"\"unsupported TEST_MODE: ",[353,1987,1988],{"class":769},"{",[353,1990,1991],{"class":365},"mode",[353,1993,1994],{"class":769},"}",[353,1996,1997],{"class":393},"\"",[353,1999,555],{"class":365},[40,2001,2003],{"id":2002},"builder-verdict","Builder verdict",[14,2005,2006,2008,2009,2011,2012,2014,2015,2017,2018,2022],{},[18,2007,24],{}," with ",[18,2010,28],{}," wins, and it is not close. It is faster than every alternative, it is the only option that lets a fixture and an endpoint share an async database transaction, and it removes an entire category of loop-related flakiness rather than working around it. The synchronous ",[18,2013,253],{}," earns its place in exactly one scenario: a codebase with mostly ",[18,2016,1274],{}," endpoints where nobody wants to touch pytest configuration. If you are shipping async endpoints on an async driver — and if you have read this far you are — the async client costs one dependency and one config block, and pays it back in CI minutes within a week. Spend the saved time on the tests that actually protect revenue — quota enforcement, billing webhooks, the auth boundary — because those are the failures that cost money. Pair this with the ",[31,2019,2021],{"href":2020},"\u002Fgetting-started-with-python-apis-for-builders\u002Fmaking-http-requests-with-requests-library\u002Fhttpx-vs-requests-for-async\u002F","httpx over requests"," choice on the outbound side and you have one HTTP library across your app and your tests, which is one less thing to learn twice.",[40,2024,2026],{"id":2025},"faq","FAQ",[14,2028,2029,2032],{},[1530,2030,2031],{},"Does an in-process test suite actually save meaningful money?","\nOn the runner itself, modestly: dropping a suite from 96 to 11 seconds across 40 CI runs a day saves roughly an hour of billed compute a month, which is single-digit dollars. The real saving is behavioural. A suite that finishes before you switch windows gets run on every change, so bugs get caught at the commit that caused them rather than three days later during a customer incident.",[14,2034,2035,2038,2039,2041,2042,2044,2045,2047,2048,373],{},[1530,2036,2037],{},"Can I keep using the synchronous TestClient for some tests?","\nYes, and both can live in the same suite during a migration. What you cannot do is call ",[18,2040,253],{}," from inside an ",[18,2043,20],{}," test — it starts its own loop and will deadlock against the one you are already on. Keep sync tests as ",[18,2046,1274],{}," functions and async tests as ",[18,2049,20],{},[14,2051,2052,2055],{},[1530,2053,2054],{},"How do I test rate limiting and quota enforcement this way?","\nOverride the store your limiter reads (Redis or in-memory) with a fixture-controlled instance, then fire requests in a loop and assert the 429 arrives on the expected call. Because there is no network, a thousand-request quota test runs in well under a second — which makes it practical to test every tier boundary you actually bill on.",[14,2057,2058,2061,2062,2065,2066,2069,2070,2073],{},[1530,2059,2060],{},"What breaks if I upgrade httpx?","\nThe ",[18,2063,2064],{},"AsyncClient(app=...)"," shortcut was deprecated and then removed; the supported form is ",[18,2067,2068],{},"AsyncClient(transport=ASGITransport(app=app))",". Pin httpx in your lockfile and centralise client construction in one helper like ",[18,2071,2072],{},"build_client()"," above, so a future signature change is a one-line fix rather than a sweep through 400 test files.",[14,2075,2076,2079],{},[1530,2077,2078],{},"Should smoke tests against production reuse these tests?","\nReuse the request-shaping code, not the assertions. Read-only checks — health, version, an authenticated GET — port cleanly by swapping the transport. Anything that writes should not run against production data; point it at a staging deployment with its own database and its own keys.",[40,2081,2083],{"id":2082},"related","Related",[2085,2086,2087,2092,2098,2104,2110],"ul",{},[1682,2088,2089,2091],{},[31,2090,34],{"href":33}," — the parent guide covering suite layout, fixtures, and what to cover first.",[1682,2093,2094,2097],{},[31,2095,2096],{"href":1549},"Mocking External APIs with respx"," — stop outbound calls leaving your test process and hanging the suite.",[1682,2099,2100,2103],{},[31,2101,2102],{"href":920},"Async Database Access with SQLAlchemy"," — the engine and session setup the transaction-per-test fixture depends on.",[1682,2105,2106,2109],{},[31,2107,2108],{"href":1412},"Fixing Connection Pool Exhaustion"," — why pooled connections and mismatched loops cause the same symptom.",[1682,2111,2112,2116],{},[31,2113,2115],{"href":2114},"\u002Fgetting-started-with-python-apis-for-builders\u002Fsetting-up-fastapi\u002F","Setting Up FastAPI"," — the application and lifespan structure these tests assume.",[2118,2119,2120],"style",{},"html pre.shiki code .sJ8bj, html code.shiki .sJ8bj{--shiki-default:#6A737D;--shiki-dark:#6A737D}html pre.shiki code .sVt8B, html code.shiki .sVt8B{--shiki-default:#24292E;--shiki-dark:#E1E4E8}html pre.shiki code .sScJk, html code.shiki .sScJk{--shiki-default:#6F42C1;--shiki-dark:#B392F0}html pre.shiki code .sZZnC, html code.shiki .sZZnC{--shiki-default:#032F62;--shiki-dark:#9ECBFF}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .szBVR, html code.shiki .szBVR{--shiki-default:#D73A49;--shiki-dark:#F97583}html pre.shiki code .s4XuR, html code.shiki .s4XuR{--shiki-default:#E36209;--shiki-dark:#FFAB70}html pre.shiki code .sj4cs, html code.shiki .sj4cs{--shiki-default:#005CC5;--shiki-dark:#79B8FF}",{"title":349,"searchDepth":362,"depth":362,"links":2122},[2123,2124,2125,2126,2127,2128,2129,2130,2131],{"id":42,"depth":362,"text":43},{"id":338,"depth":362,"text":339},{"id":907,"depth":362,"text":908},{"id":1522,"depth":362,"text":1523},{"id":1581,"depth":362,"text":1582},{"id":1673,"depth":362,"text":1674},{"id":2002,"depth":362,"text":2003},{"id":2025,"depth":362,"text":2026},{"id":2082,"depth":362,"text":2083},"Use httpx ASGITransport to test async FastAPI endpoints in-process: async fixtures, event-loop scope, dependency overrides, and the mistakes that hang tests.","md",{"pageTitle":2135,"type":2136,"datePublished":2137,"dateModified":2137},"Testing Async FastAPI Endpoints with httpx and pytest","article","2026-07-23","\u002Fscaling-and-operating-production-python-apis\u002Ftesting-python-apis-with-pytest\u002Ftesting-async-fastapi-endpoints-with-httpx",{"title":5,"description":2132},"scaling-and-operating-production-python-apis\u002Ftesting-python-apis-with-pytest\u002Ftesting-async-fastapi-endpoints-with-httpx\u002Findex","yX7fN-ymbCvCoC2x8XRjqwA12eM74zNRsnqNttYK7n0",{"@context":2143,"@type":2144,"mainEntity":2145},"https:\u002F\u002Fschema.org","FAQPage",[2146,2151,2154,2157,2160],{"@type":2147,"name":2031,"acceptedAnswer":2148},"Question",{"@type":2149,"text":2150},"Answer","On the runner itself, modestly: dropping a suite from 96 to 11 seconds across 40 CI runs a day saves roughly an hour of billed compute a month, which is single-digit dollars. The real saving is behavioural. A suite that finishes before you switch windows gets run on every change, so bugs get caught at the commit that caused them rather than three days later during a customer incident.",{"@type":2147,"name":2037,"acceptedAnswer":2152},{"@type":2149,"text":2153},"Yes, and both can live in the same suite during a migration. What you cannot do is call TestClient from inside an async def test — it starts its own loop and will deadlock against the one you are already on. Keep sync tests as def functions and async tests as async def.",{"@type":2147,"name":2054,"acceptedAnswer":2155},{"@type":2149,"text":2156},"Override the store your limiter reads (Redis or in-memory) with a fixture-controlled instance, then fire requests in a loop and assert the 429 arrives on the expected call. Because there is no network, a thousand-request quota test runs in well under a second — which makes it practical to test every tier boundary you actually bill on.",{"@type":2147,"name":2060,"acceptedAnswer":2158},{"@type":2149,"text":2159},"The AsyncClient(app=...) shortcut was deprecated and then removed; the supported form is AsyncClient(transport=ASGITransport(app=app)). Pin httpx in your lockfile and centralise client construction in one helper like build_client() above, so a future signature change is a one-line fix rather than a sweep through 400 test files.",{"@type":2147,"name":2078,"acceptedAnswer":2161},{"@type":2149,"text":2162},"Reuse the request-shaping code, not the assertions. Read-only checks — health, version, an authenticated GET — port cleanly by swapping the transport. Anything that writes should not run against production data; point it at a staging deployment with its own database and its own keys.",1784887028459]