[{"data":1,"prerenderedAt":1695},["ShallowReactive",2],{"page-\u002Fintegration-database-and-service-testing\u002Fspinning-up-services-with-testcontainers\u002F":3},{"id":4,"title":5,"body":6,"description":1654,"extension":1655,"meta":1656,"navigation":271,"path":1691,"seo":1692,"stem":1693,"__hash__":1694},"content\u002Fintegration-database-and-service-testing\u002Fspinning-up-services-with-testcontainers\u002Findex.md","Spinning Up Services with Testcontainers",{"type":7,"value":8,"toc":1634},"minimark",[9,13,18,57,61,69,235,239,244,341,350,354,402,409,413,471,483,487,562,568,572,633,640,644,647,711,718,753,756,760,884,888,902,905,1031,1042,1046,1049,1067,1077,1087,1103,1107,1110,1254,1275,1281,1285,1288,1299,1305,1311,1419,1423,1426,1504,1515,1518,1536,1539,1543,1556,1562,1571,1580,1586,1590,1624,1630],[10,11,12],"p",{},"A test that runs against a real Postgres finds constraint violations, type coercion surprises and isolation-level behaviour that no in-memory substitute reproduces. The obstacle has always been operational: somebody has to install the service, keep its version aligned across every laptop and every CI runner, and clean it between runs. Testcontainers removes that obstacle by making the service a value in the test code — started by a fixture, addressed by a URL the fixture returns, destroyed when the run ends.",[14,15,17],"h2",{"id":16},"prerequisites","Prerequisites",[19,20,21,25,40,50],"ul",{},[22,23,24],"li",{},"A working Docker daemon reachable from the test process. Podman works with the Docker-compatible socket enabled.",[22,26,27,31,32,35,36,39],{},[28,29,30],"code",{},"testcontainers >= 4.0",", which reorganised the package into per-service modules (",[28,33,34],{},"testcontainers.postgres",", ",[28,37,38],{},"testcontainers.redis",", and so on).",[22,41,42,45,46,49],{},[28,43,44],{},"pytest >= 8.0",", and ",[28,47,48],{},"pytest-xdist"," if the suite runs in parallel.",[22,51,52,53,56],{},"Enough disk on the CI runner for the image cache; a cold pull of ",[28,54,55],{},"postgres:16-alpine"," is roughly 80 MB and the difference between a two-second and a forty-second startup.",[14,58,60],{"id":59},"core-concept-the-container-is-a-fixture-with-a-url","Core concept: the container is a fixture with a URL",[10,62,63,64,68],{},"Everything Testcontainers does reduces to three operations: start an image, wait until it is genuinely usable, and expose the address it ended up on. The third is not a formality — the library deliberately maps container ports to ",[65,66,67],"em",{},"ephemeral"," host ports so that two runs on the same machine never collide, which means the connection string is discovered at runtime and can never be hardcoded.",[70,71,74,231],"figure",{"className":72},[73],"diagram",[75,76,83,84,83,88,83,92,83,110,83,118,83,127,83,136,83,142,83,146,83,150,83,157,83,163,83,167,83,170,83,174,83,178,83,183,83,187,83,190,83,194,83,198,83,202,83,208,83,212,83,221,83,225,83,228],"svg",{"viewBox":77,"role":78,"ariaLabelledBy":79,"xmlns":82},"0 0 840 276","img",[80,81],"tc-t","tc-d","http:\u002F\u002Fwww.w3.org\u002F2000\u002Fsvg","\n  ",[85,86,87],"title",{"id":80},"From fixture to a live service address",[89,90,91],"desc",{"id":81},"A session-scoped fixture asks the Docker daemon to start a pinned image. The daemon assigns an ephemeral host port. A wait strategy polls a real readiness signal until the service accepts work. The fixture then returns a connection URL built from the mapped port, which every test uses.",[93,94,95,96,83],"defs",{},"\n    ",[97,98,105],"marker",{"id":99,"viewBox":100,"refX":101,"refY":102,"markerWidth":103,"markerHeight":103,"orient":104},"tc-a","0 0 10 10","9","5","7","auto-start-reverse",[106,107],"path",{"d":108,"fill":109},"M0 0 L10 5 L0 10 z","#3d405b",[111,112],"rect",{"x":113,"y":113,"width":114,"height":115,"rx":116,"fill":117},"0","840","276","14","#fffdf8",[119,120,126],"text",{"x":121,"y":122,"textAnchor":123,"fontSize":124,"fontWeight":125,"fill":109},"420","28","middle","16","700","Start, wait, then publish the address",[111,128],{"x":129,"y":130,"width":131,"height":132,"rx":133,"fill":134,"stroke":109,"strokeWidth":135},"26","56","176","76","11","#f4f1de","1.6",[119,137,141],{"x":138,"y":139,"textAnchor":123,"fontSize":140,"fontWeight":125,"fill":109},"114","82","12.5","session fixture",[119,143,145],{"x":138,"y":144,"textAnchor":123,"fontSize":133,"fill":109},"102","PostgresContainer",[119,147,149],{"x":138,"y":148,"textAnchor":123,"fontSize":133,"fill":109},"120","(\"postgres:16-alpine\")",[151,152],"line",{"x1":153,"y1":154,"x2":155,"y2":154,"stroke":109,"strokeWidth":135,"markerEnd":156},"206","94","240","url(#tc-a)",[111,158],{"x":159,"y":130,"width":131,"height":132,"rx":133,"fill":160,"stroke":161,"strokeWidth":162},"246","#f7f0da","#f2cc8f","2",[119,164,166],{"x":165,"y":139,"textAnchor":123,"fontSize":140,"fontWeight":125,"fill":109},"334","Docker daemon",[119,168,169],{"x":165,"y":144,"textAnchor":123,"fontSize":133,"fill":109},"5432\u002Ftcp → 49517",[119,171,173],{"x":165,"y":148,"textAnchor":123,"fontSize":133,"fill":172},"#8a5a00","ephemeral host port",[151,175],{"x1":176,"y1":154,"x2":177,"y2":154,"stroke":109,"strokeWidth":135,"markerEnd":156},"426","460",[111,179],{"x":180,"y":130,"width":131,"height":132,"rx":133,"fill":181,"stroke":182,"strokeWidth":162},"466","#e6f0ea","#81b29a",[119,184,186],{"x":185,"y":139,"textAnchor":123,"fontSize":140,"fontWeight":125,"fill":109},"554","wait strategy",[119,188,189],{"x":185,"y":144,"textAnchor":123,"fontSize":133,"fill":109},"poll a real signal",[119,191,193],{"x":185,"y":148,"textAnchor":123,"fontSize":133,"fill":192},"#2a5f49","never a sleep",[151,195],{"x1":196,"y1":154,"x2":197,"y2":154,"stroke":109,"strokeWidth":135,"markerEnd":156},"646","680",[111,199],{"x":200,"y":130,"width":201,"height":132,"rx":133,"fill":117,"stroke":109,"strokeWidth":135},"686","128",[119,203,207],{"x":204,"y":205,"textAnchor":123,"fontSize":206,"fontWeight":125,"fill":109},"750","88","12","connection",[119,209,211],{"x":204,"y":210,"textAnchor":123,"fontSize":206,"fontWeight":125,"fill":109},"108","URL",[111,213],{"x":129,"y":214,"width":215,"height":216,"rx":217,"fill":218,"stroke":219,"strokeWidth":220},"154","788","46","10","#fbe9e3","#e07a5f","1.8",[119,222,224],{"x":121,"y":223,"textAnchor":123,"fontSize":206,"fill":109},"182","Hardcoding 5432 works until two runs share a machine, then fails as \"address already in use\".",[111,226],{"x":129,"y":227,"width":215,"height":216,"rx":217,"fill":181,"stroke":182,"strokeWidth":220},"212",[119,229,230],{"x":121,"y":155,"textAnchor":123,"fontSize":206,"fill":109},"Reading get_connection_url() is correct on every machine, in parallel, forever.",[232,233,234],"figcaption",{},"The ephemeral port is not an inconvenience to work around — it is what makes parallel and concurrent runs safe on a shared machine.",[14,236,238],{"id":237},"step-by-step-implementation","Step-by-step implementation",[240,241,243],"h3",{"id":242},"_1-one-container-for-the-whole-session","1. One container for the whole session",[245,246,251],"pre",{"className":247,"code":248,"language":249,"meta":250,"style":250},"language-python shiki shiki-themes github-light github-dark","import pytest\nfrom testcontainers.postgres import PostgresContainer\n\n\n@pytest.fixture(scope=\"session\")\ndef postgres():\n    # Context manager: __exit__ stops and removes the container even on failure.\n    with PostgresContainer(\"postgres:16-alpine\") as container:\n        yield container\n\n\n@pytest.fixture(scope=\"session\")\ndef postgres_dsn(postgres):\n    # Never hardcode the port; the daemon chose it at start-up.\n    return postgres.get_connection_url()\n","python","",[28,252,253,260,266,273,278,284,290,296,302,308,313,318,323,329,335],{"__ignoreMap":250},[254,255,257],"span",{"class":151,"line":256},1,[254,258,259],{},"import pytest\n",[254,261,263],{"class":151,"line":262},2,[254,264,265],{},"from testcontainers.postgres import PostgresContainer\n",[254,267,269],{"class":151,"line":268},3,[254,270,272],{"emptyLinePlaceholder":271},true,"\n",[254,274,276],{"class":151,"line":275},4,[254,277,272],{"emptyLinePlaceholder":271},[254,279,281],{"class":151,"line":280},5,[254,282,283],{},"@pytest.fixture(scope=\"session\")\n",[254,285,287],{"class":151,"line":286},6,[254,288,289],{},"def postgres():\n",[254,291,293],{"class":151,"line":292},7,[254,294,295],{},"    # Context manager: __exit__ stops and removes the container even on failure.\n",[254,297,299],{"class":151,"line":298},8,[254,300,301],{},"    with PostgresContainer(\"postgres:16-alpine\") as container:\n",[254,303,305],{"class":151,"line":304},9,[254,306,307],{},"        yield container\n",[254,309,311],{"class":151,"line":310},10,[254,312,272],{"emptyLinePlaceholder":271},[254,314,316],{"class":151,"line":315},11,[254,317,272],{"emptyLinePlaceholder":271},[254,319,321],{"class":151,"line":320},12,[254,322,283],{},[254,324,326],{"class":151,"line":325},13,[254,327,328],{},"def postgres_dsn(postgres):\n",[254,330,332],{"class":151,"line":331},14,[254,333,334],{},"    # Never hardcode the port; the daemon chose it at start-up.\n",[254,336,338],{"class":151,"line":337},15,[254,339,340],{},"    return postgres.get_connection_url()\n",[10,342,343,344,349],{},"Scope is the single most consequential setting here. At function scope this fixture adds three seconds to every test; at session scope it adds three seconds to the run. Per-test isolation comes from ",[345,346,348],"a",{"href":347},"\u002Fintegration-database-and-service-testing\u002Fdatabase-fixtures-and-transactional-tests\u002F","the transactional fixture"," layered on top, not from restarting the service.",[240,351,353],{"id":352},"_2-configure-the-image-rather-than-the-test","2. Configure the image rather than the test",[245,355,357],{"className":247,"code":356,"language":249,"meta":250,"style":250},"from testcontainers.postgres import PostgresContainer\n\ncontainer = (\n    PostgresContainer(\"postgres:16-alpine\")\n    .with_env(\"POSTGRES_INITDB_ARGS\", \"--data-checksums\")\n    # Tuned for a throwaway instance: durability is worthless here and fsync\n    # dominates insert-heavy test suites.\n    .with_command(\"postgres -c fsync=off -c full_page_writes=off -c synchronous_commit=off\")\n)\n",[28,358,359,363,367,372,377,382,387,392,397],{"__ignoreMap":250},[254,360,361],{"class":151,"line":256},[254,362,265],{},[254,364,365],{"class":151,"line":262},[254,366,272],{"emptyLinePlaceholder":271},[254,368,369],{"class":151,"line":268},[254,370,371],{},"container = (\n",[254,373,374],{"class":151,"line":275},[254,375,376],{},"    PostgresContainer(\"postgres:16-alpine\")\n",[254,378,379],{"class":151,"line":280},[254,380,381],{},"    .with_env(\"POSTGRES_INITDB_ARGS\", \"--data-checksums\")\n",[254,383,384],{"class":151,"line":286},[254,385,386],{},"    # Tuned for a throwaway instance: durability is worthless here and fsync\n",[254,388,389],{"class":151,"line":292},[254,390,391],{},"    # dominates insert-heavy test suites.\n",[254,393,394],{"class":151,"line":298},[254,395,396],{},"    .with_command(\"postgres -c fsync=off -c full_page_writes=off -c synchronous_commit=off\")\n",[254,398,399],{"class":151,"line":304},[254,400,401],{},")\n",[10,403,404,405,408],{},"Turning off ",[28,406,407],{},"fsync"," for a container that is deleted minutes later typically halves the runtime of an insert-heavy suite. It is safe precisely because the data has no value — the same setting in production would be negligence.",[240,410,412],{"id":411},"_3-wait-on-something-real","3. Wait on something real",[245,414,416],{"className":247,"code":415,"language":249,"meta":250,"style":250},"from testcontainers.core.waiting_utils import wait_for_logs\nfrom testcontainers.kafka import KafkaContainer\n\n\ndef start_kafka():\n    container = KafkaContainer(\"confluentinc\u002Fcp-kafka:7.6.0\")\n    container.start()\n    # Kafka is \"running\" long before it is usable; wait for the broker's own\n    # readiness line rather than guessing at a duration.\n    wait_for_logs(container, r\"\\[KafkaServer id=\\d+\\] started\", timeout=60)\n    return container\n",[28,417,418,423,428,432,436,441,446,451,456,461,466],{"__ignoreMap":250},[254,419,420],{"class":151,"line":256},[254,421,422],{},"from testcontainers.core.waiting_utils import wait_for_logs\n",[254,424,425],{"class":151,"line":262},[254,426,427],{},"from testcontainers.kafka import KafkaContainer\n",[254,429,430],{"class":151,"line":268},[254,431,272],{"emptyLinePlaceholder":271},[254,433,434],{"class":151,"line":275},[254,435,272],{"emptyLinePlaceholder":271},[254,437,438],{"class":151,"line":280},[254,439,440],{},"def start_kafka():\n",[254,442,443],{"class":151,"line":286},[254,444,445],{},"    container = KafkaContainer(\"confluentinc\u002Fcp-kafka:7.6.0\")\n",[254,447,448],{"class":151,"line":292},[254,449,450],{},"    container.start()\n",[254,452,453],{"class":151,"line":298},[254,454,455],{},"    # Kafka is \"running\" long before it is usable; wait for the broker's own\n",[254,457,458],{"class":151,"line":304},[254,459,460],{},"    # readiness line rather than guessing at a duration.\n",[254,462,463],{"class":151,"line":310},[254,464,465],{},"    wait_for_logs(container, r\"\\[KafkaServer id=\\d+\\] started\", timeout=60)\n",[254,467,468],{"class":151,"line":315},[254,469,470],{},"    return container\n",[10,472,473,474,477,478,482],{},"Every official module ships a default wait strategy, and the bundled ones are usually right. It is the services without a module — an internal image, a niche broker — where teams reach for ",[28,475,476],{},"time.sleep(10)",", and where the readiness poll pays for itself immediately. The options and their failure modes are covered in ",[345,479,481],{"href":480},"\u002Fintegration-database-and-service-testing\u002Fspinning-up-services-with-testcontainers\u002Fwaiting-for-container-readiness-without-sleep\u002F","waiting for container readiness without sleep",".",[240,484,486],{"id":485},"_4-isolate-parallel-workers","4. Isolate parallel workers",[245,488,490],{"className":247,"code":489,"language":249,"meta":250,"style":250},"import os\n\nimport pytest\nfrom testcontainers.redis import RedisContainer\n\n\n@pytest.fixture(scope=\"session\")\ndef redis_client():\n    with RedisContainer(\"redis:7-alpine\") as container:\n        client = container.get_client()\n        # One logical database per worker: cheaper than one container each,\n        # and Redis gives 16 of them for free.\n        worker = os.environ.get(\"PYTEST_XDIST_WORKER\", \"gw0\")\n        client.select(int(worker.removeprefix(\"gw\")) % 16)\n        yield client\n",[28,491,492,497,501,505,510,514,518,522,527,532,537,542,547,552,557],{"__ignoreMap":250},[254,493,494],{"class":151,"line":256},[254,495,496],{},"import os\n",[254,498,499],{"class":151,"line":262},[254,500,272],{"emptyLinePlaceholder":271},[254,502,503],{"class":151,"line":268},[254,504,259],{},[254,506,507],{"class":151,"line":275},[254,508,509],{},"from testcontainers.redis import RedisContainer\n",[254,511,512],{"class":151,"line":280},[254,513,272],{"emptyLinePlaceholder":271},[254,515,516],{"class":151,"line":286},[254,517,272],{"emptyLinePlaceholder":271},[254,519,520],{"class":151,"line":292},[254,521,283],{},[254,523,524],{"class":151,"line":298},[254,525,526],{},"def redis_client():\n",[254,528,529],{"class":151,"line":304},[254,530,531],{},"    with RedisContainer(\"redis:7-alpine\") as container:\n",[254,533,534],{"class":151,"line":310},[254,535,536],{},"        client = container.get_client()\n",[254,538,539],{"class":151,"line":315},[254,540,541],{},"        # One logical database per worker: cheaper than one container each,\n",[254,543,544],{"class":151,"line":320},[254,545,546],{},"        # and Redis gives 16 of them for free.\n",[254,548,549],{"class":151,"line":325},[254,550,551],{},"        worker = os.environ.get(\"PYTEST_XDIST_WORKER\", \"gw0\")\n",[254,553,554],{"class":151,"line":331},[254,555,556],{},"        client.select(int(worker.removeprefix(\"gw\")) % 16)\n",[254,558,559],{"class":151,"line":337},[254,560,561],{},"        yield client\n",[10,563,564,565,567],{},"Under ",[28,566,48],{}," each worker is a separate process running its own session fixtures, so a naive setup starts one container per worker — eight Postgres instances on an eight-core runner. Sometimes that is exactly right, because it gives complete isolation. More often the cheaper answer is one container with per-worker databases or per-worker Redis indices, which is a second of setup instead of thirty.",[240,569,571],{"id":570},"_5-compose-several-services","5. Compose several services",[245,573,575],{"className":247,"code":574,"language":249,"meta":250,"style":250},"import pytest\nfrom testcontainers.compose import DockerCompose\n\n\n@pytest.fixture(scope=\"session\")\ndef stack():\n    # When services must see each other by name, one compose file beats\n    # wiring three containers together by hand.\n    with DockerCompose(\"tests\u002Ffixtures\", compose_file_name=\"docker-compose.test.yml\",\n                       pull=True) as compose:\n        compose.wait_for(\"http:\u002F\u002Flocalhost:8080\u002Fhealth\")\n        yield compose\n",[28,576,577,581,586,590,594,598,603,608,613,618,623,628],{"__ignoreMap":250},[254,578,579],{"class":151,"line":256},[254,580,259],{},[254,582,583],{"class":151,"line":262},[254,584,585],{},"from testcontainers.compose import DockerCompose\n",[254,587,588],{"class":151,"line":268},[254,589,272],{"emptyLinePlaceholder":271},[254,591,592],{"class":151,"line":275},[254,593,272],{"emptyLinePlaceholder":271},[254,595,596],{"class":151,"line":280},[254,597,283],{},[254,599,600],{"class":151,"line":286},[254,601,602],{},"def stack():\n",[254,604,605],{"class":151,"line":292},[254,606,607],{},"    # When services must see each other by name, one compose file beats\n",[254,609,610],{"class":151,"line":298},[254,611,612],{},"    # wiring three containers together by hand.\n",[254,614,615],{"class":151,"line":304},[254,616,617],{},"    with DockerCompose(\"tests\u002Ffixtures\", compose_file_name=\"docker-compose.test.yml\",\n",[254,619,620],{"class":151,"line":310},[254,621,622],{},"                       pull=True) as compose:\n",[254,624,625],{"class":151,"line":315},[254,626,627],{},"        compose.wait_for(\"http:\u002F\u002Flocalhost:8080\u002Fhealth\")\n",[254,629,630],{"class":151,"line":320},[254,631,632],{},"        yield compose\n",[10,634,635,636,639],{},"Individual containers are the right unit when services are independent. As soon as one service needs to reach another by hostname — an application container talking to a database container — a compose file expresses the network in one place instead of scattering ",[28,637,638],{},"with_network"," calls through fixtures.",[14,641,643],{"id":642},"verification","Verification",[10,645,646],{},"Prove the lifecycle is correct before relying on it, with two checks that take a minute.",[245,648,652],{"className":649,"code":650,"language":651,"meta":250,"style":250},"language-bash shiki shiki-themes github-light github-dark","# 1. The suite starts exactly the containers you expect, and removes them.\ndocker ps --filter \"label=org.testcontainers=true\" --format \"{{.Image}}\\t{{.Status}}\"\npytest tests\u002Fintegration -q\ndocker ps -a --filter \"label=org.testcontainers=true\" --format \"{{.Image}}\\t{{.Status}}\"\n","bash",[28,653,654,660,683,694],{"__ignoreMap":250},[254,655,656],{"class":151,"line":256},[254,657,659],{"class":658},"sJ8bj","# 1. The suite starts exactly the containers you expect, and removes them.\n",[254,661,662,666,670,674,677,680],{"class":151,"line":262},[254,663,665],{"class":664},"sScJk","docker",[254,667,669],{"class":668},"sZZnC"," ps",[254,671,673],{"class":672},"sj4cs"," --filter",[254,675,676],{"class":668}," \"label=org.testcontainers=true\"",[254,678,679],{"class":672}," --format",[254,681,682],{"class":668}," \"{{.Image}}\\t{{.Status}}\"\n",[254,684,685,688,691],{"class":151,"line":268},[254,686,687],{"class":664},"pytest",[254,689,690],{"class":668}," tests\u002Fintegration",[254,692,693],{"class":672}," -q\n",[254,695,696,698,700,703,705,707,709],{"class":151,"line":275},[254,697,665],{"class":664},[254,699,669],{"class":668},[254,701,702],{"class":672}," -a",[254,704,673],{"class":672},[254,706,676],{"class":668},[254,708,679],{"class":672},[254,710,682],{"class":668},[245,712,716],{"className":713,"code":715,"language":119,"meta":250},[714],"language-text","# during the run\npostgres:16-alpine     Up 4 seconds (healthy)\ntestcontainers\u002Fryuk    Up 5 seconds\n# after the run — empty output is the correct result\n",[28,717,715],{"__ignoreMap":250},[245,719,721],{"className":649,"code":720,"language":651,"meta":250,"style":250},"# 2. The mapped port really is ephemeral, and the suite copes.\npytest tests\u002Fintegration -q & pytest tests\u002Fintegration -q; wait\n",[28,722,723,728],{"__ignoreMap":250},[254,724,725],{"class":151,"line":256},[254,726,727],{"class":658},"# 2. The mapped port really is ephemeral, and the suite copes.\n",[254,729,730,732,734,737,741,743,745,747,750],{"class":151,"line":262},[254,731,687],{"class":664},[254,733,690],{"class":668},[254,735,736],{"class":672}," -q",[254,738,740],{"class":739},"sVt8B"," & ",[254,742,687],{"class":664},[254,744,690],{"class":668},[254,746,736],{"class":672},[254,748,749],{"class":739},"; ",[254,751,752],{"class":672},"wait\n",[10,754,755],{},"Two concurrent runs of the same suite must both pass. If they collide with \"address already in use\", something is hardcoding a port; if they interfere through shared data, a container is being reused when it should not be.",[14,757,759],{"id":758},"troubleshooting","Troubleshooting",[761,762,763,779],"table",{},[764,765,766],"thead",{},[767,768,769,773,776],"tr",{},[770,771,772],"th",{},"Symptom",[770,774,775],{},"Root cause",[770,777,778],{},"Fix",[780,781,782,800,818,838,852,867],"tbody",{},[767,783,784,791,794],{},[785,786,787,790],"td",{},[28,788,789],{},"ConnectionRefusedError"," on the first query",[785,792,793],{},"Connected before readiness",[785,795,796,797],{},"Use the module's wait strategy or ",[28,798,799],{},"wait_for_logs",[767,801,802,805,815],{},[785,803,804],{},"Containers left running after a crash",[785,806,807,808,811,812],{},"Ryuk disabled, or ",[28,809,810],{},".start()"," without a matching ",[28,813,814],{},".stop()",[785,816,817],{},"Use the context manager; keep Ryuk enabled",[767,819,820,825,828],{},[785,821,822],{},[28,823,824],{},"address already in use",[785,826,827],{},"A hardcoded host port",[785,829,830,831,834,835],{},"Read ",[28,832,833],{},"get_exposed_port()"," \u002F ",[28,836,837],{},"get_connection_url()",[767,839,840,846,849],{},[785,841,842,843],{},"Eight containers under ",[28,844,845],{},"-n 8",[785,847,848],{},"Session fixtures run per worker",[785,850,851],{},"Share one container with per-worker databases",[767,853,854,857,860],{},[785,855,856],{},"Very slow first run in CI",[785,858,859],{},"Cold image cache",[785,861,862,863,866],{},"Cache ",[28,864,865],{},"\u002Fvar\u002Flib\u002Fdocker"," or pre-pull in an earlier step",[767,868,869,872,878],{},[785,870,871],{},"Works locally, fails in CI with a hostname error",[785,873,874,875],{},"Docker-in-Docker; the host is not ",[28,876,877],{},"localhost",[785,879,880,881],{},"Read the host from ",[28,882,883],{},"get_container_host_ip()",[14,885,887],{"id":886},"the-reaper-and-why-containers-still-leak-without-it","The reaper, and why containers still leak without it",[10,889,890,893,894,897,898,901],{},[28,891,892],{},"docker run"," leaves a container behind if the process that started it dies. A test suite is exactly such a process, and ",[28,895,896],{},"SIGKILL",", an OOM kill or a hard ",[28,899,900],{},"ctrl-C"," all bypass Python's cleanup entirely — which on a developer's machine means a slow accumulation of stopped Postgres containers, and on a shared CI runner means eventual disk exhaustion.",[10,903,904],{},"Testcontainers solves this with Ryuk, a small sidecar container started alongside the first real one. The test process opens a TCP connection to Ryuk and holds it for the lifetime of the run. Ryuk's only job is to watch that connection: when it drops — cleanly or otherwise — Ryuk removes every container, network and volume carrying the session's label.",[70,906,908,1028],{"className":907},[73],[75,909,83,914,83,917,83,920,83,927,83,931,83,935,83,941,83,945,83,949,83,954,83,960,83,963,83,967,83,970,83,975,83,980,83,983,83,987,83,995,83,998,83,1001,83,1005,83,1010,83,1017,83,1021,83,1025],{"viewBox":910,"role":78,"ariaLabelledBy":911,"xmlns":82},"0 0 820 264",[912,913],"ryuk-t","ryuk-d",[85,915,916],{"id":912},"How the reaper cleans up after a killed test process",[89,918,919],{"id":913},"The pytest process holds an open connection to the Ryuk sidecar while labelled service containers run. When the process is killed the connection drops, Ryuk observes the drop and removes every container carrying the session label, leaving nothing behind.",[93,921,95,922,83],{},[97,923,925],{"id":924,"viewBox":100,"refX":101,"refY":102,"markerWidth":103,"markerHeight":103,"orient":104},"ryuk-a",[106,926],{"d":108,"fill":219},[111,928],{"x":113,"y":113,"width":929,"height":930,"rx":116,"fill":117},"820","264",[119,932,934],{"x":933,"y":122,"textAnchor":123,"fontSize":124,"fontWeight":125,"fill":109},"410","Cleanup that survives a SIGKILL",[111,936],{"x":937,"y":938,"width":939,"height":940,"rx":133,"fill":134,"stroke":109,"strokeWidth":135},"34","60","200","72",[119,942,944],{"x":943,"y":205,"textAnchor":123,"fontSize":140,"fontWeight":125,"fill":109},"134","pytest process",[119,946,948],{"x":943,"y":947,"textAnchor":123,"fontSize":133,"fill":109},"110","holds a TCP connection",[151,950],{"x1":951,"y1":952,"x2":953,"y2":952,"stroke":182,"strokeWidth":162},"238","96","286",[119,955,959],{"x":956,"y":957,"textAnchor":123,"fontSize":958,"fill":192},"262","86","10.5","open",[111,961],{"x":962,"y":938,"width":131,"height":940,"rx":133,"fill":181,"stroke":182,"strokeWidth":162},"292",[119,964,966],{"x":965,"y":205,"textAnchor":123,"fontSize":140,"fontWeight":125,"fill":109},"380","Ryuk sidecar",[119,968,969],{"x":965,"y":947,"textAnchor":123,"fontSize":133,"fill":109},"watches the socket",[111,971],{"x":972,"y":973,"width":956,"height":974,"rx":217,"fill":160,"stroke":161,"strokeWidth":220},"524","42","48",[119,976,979],{"x":977,"y":940,"textAnchor":123,"fontSize":978,"fill":109},"655","11.5","postgres:16-alpine · labelled",[111,981],{"x":972,"y":982,"width":956,"height":974,"rx":217,"fill":160,"stroke":161,"strokeWidth":220},"100",[119,984,986],{"x":977,"y":985,"textAnchor":123,"fontSize":978,"fill":109},"130","redis:7-alpine · labelled",[151,988],{"x1":989,"y1":990,"x2":991,"y2":992,"stroke":993,"strokeWidth":994},"472","80","518","66","rgba(61,64,91,0.4)","1.4",[151,996],{"x1":989,"y1":947,"x2":991,"y2":997,"stroke":993,"strokeWidth":994},"124",[111,999],{"x":937,"y":1000,"width":939,"height":992,"rx":133,"fill":218,"stroke":219,"strokeWidth":162},"166",[119,1002,1004],{"x":943,"y":1003,"textAnchor":123,"fontSize":206,"fontWeight":125,"fill":109},"192","killed",[119,1006,1009],{"x":943,"y":1007,"textAnchor":123,"fontSize":133,"fill":1008},"214","#8f3d22","no cleanup code runs",[151,1011],{"x1":951,"y1":1012,"x2":1013,"y2":1012,"stroke":219,"strokeWidth":162,"strokeDashArray":1014,"markerEnd":1016},"198","288",[102,1015],"4","url(#ryuk-a)",[111,1018],{"x":1019,"y":1000,"width":1020,"height":992,"rx":133,"fill":181,"stroke":182,"strokeWidth":162},"294","492",[119,1022,1024],{"x":1023,"y":1003,"textAnchor":123,"fontSize":206,"fontWeight":125,"fill":109},"540","connection drops → Ryuk removes every labelled container",[119,1026,1027],{"x":1023,"y":1007,"textAnchor":123,"fontSize":133,"fill":192},"containers, networks and volumes, all gone",[232,1029,1030],{},"Ryuk is why an interrupted run leaves nothing behind. Disabling it — a tempting fix for a restricted CI environment — reintroduces the leak it exists to prevent.",[10,1032,1033,1034,1037,1038,1041],{},"Ryuk is occasionally disabled (",[28,1035,1036],{},"TESTCONTAINERS_RYUK_DISABLED=true",") because a hardened CI environment forbids the privileged socket mount it needs. That is a legitimate reason, but the consequence must be handled: without Ryuk, cleanup depends entirely on the fixture's teardown running, so the job needs an unconditional ",[28,1039,1040],{},"docker rm"," step that runs even on failure.",[14,1043,1045],{"id":1044},"making-it-fast-in-ci","Making it fast in CI",[10,1047,1048],{},"Three costs dominate, and they respond to different remedies.",[10,1050,1051,1055,1056,1059,1060,1062,1063,1066],{},[1052,1053,1054],"strong",{},"The image pull"," is the largest on a cold runner and is fixed by caching. Most CI platforms can restore a Docker layer cache between jobs; failing that, an explicit ",[28,1057,1058],{},"docker pull"," in an earlier step at least moves the cost out of the test timing. Pinning exact tags matters here too: ",[28,1061,55],{}," is cacheable, while ",[28,1064,1065],{},"postgres:latest"," invalidates whenever upstream publishes.",[10,1068,1069,1072,1073,1076],{},[1052,1070,1071],{},"Container start-up"," is fixed by scope and by tuning. One session-scoped container with ",[28,1074,1075],{},"fsync=off"," is typically three seconds; the same container per test, untuned, is minutes.",[10,1078,1079,1082,1083,1086],{},[1052,1080,1081],{},"Readiness waiting"," is fixed by polling a real signal. The default strategies poll on a short interval, so a Postgres that is ready in 1.8 seconds is detected at 1.8 seconds. A ",[28,1084,1085],{},"sleep(5)"," costs 5 seconds every run and still fails on the day the runner is loaded.",[10,1088,1089,1090,1093,1094,1098,1099,1102],{},"What does ",[65,1091,1092],{},"not"," help is running containers on every push. The marker-based split described in ",[345,1095,1097],{"href":1096},"\u002Fintegration-database-and-service-testing\u002F","integration, database and service testing"," keeps the fast suite free of Docker entirely, which matters most for contributors who do not have a daemon running at all — their ",[28,1100,1101],{},"pytest -m \"not integration\""," should pass on a laptop with nothing installed but Python.",[14,1104,1106],{"id":1105},"writing-a-fixture-for-a-service-with-no-module","Writing a fixture for a service with no module",[10,1108,1109],{},"The bundled modules cover Postgres, MySQL, Redis, Kafka, RabbitMQ, Elasticsearch, MongoDB, LocalStack and a couple of dozen more. Everything else — an internal service, a vendor's image, a mock server — needs a fixture built from the generic container, and the pattern is short enough to write from memory.",[245,1111,1113],{"className":247,"code":1112,"language":249,"meta":250,"style":250},"import pytest\nimport requests\nfrom testcontainers.core.container import DockerContainer\nfrom testcontainers.core.waiting_utils import wait_for_logs\n\n\n@pytest.fixture(scope=\"session\")\ndef billing_stub():\n    container = (\n        DockerContainer(\"ghcr.io\u002Facme\u002Fbilling-stub:2.4.1\")   # pinned, always\n        .with_exposed_ports(8080)                            # container-side port\n        .with_env(\"STUB_MODE\", \"deterministic\")\n    )\n    with container:\n        # The image prints this line once its HTTP listener is bound.\n        wait_for_logs(container, \"listening on :8080\", timeout=30)\n\n        host = container.get_container_host_ip()             # not \"localhost\" in DinD\n        port = container.get_exposed_port(8080)              # the ephemeral host port\n        base_url = f\"http:\u002F\u002F{host}:{port}\"\n\n        # Belt and braces: one real request before any test runs, so a wait\n        # strategy that was satisfied too early fails here rather than mid-suite.\n        response = requests.get(f\"{base_url}\u002Fhealth\", timeout=5)\n        response.raise_for_status()\n\n        yield base_url\n",[28,1114,1115,1119,1124,1129,1133,1137,1141,1145,1150,1155,1160,1165,1170,1175,1180,1185,1191,1196,1202,1208,1214,1219,1225,1231,1237,1243,1248],{"__ignoreMap":250},[254,1116,1117],{"class":151,"line":256},[254,1118,259],{},[254,1120,1121],{"class":151,"line":262},[254,1122,1123],{},"import requests\n",[254,1125,1126],{"class":151,"line":268},[254,1127,1128],{},"from testcontainers.core.container import DockerContainer\n",[254,1130,1131],{"class":151,"line":275},[254,1132,422],{},[254,1134,1135],{"class":151,"line":280},[254,1136,272],{"emptyLinePlaceholder":271},[254,1138,1139],{"class":151,"line":286},[254,1140,272],{"emptyLinePlaceholder":271},[254,1142,1143],{"class":151,"line":292},[254,1144,283],{},[254,1146,1147],{"class":151,"line":298},[254,1148,1149],{},"def billing_stub():\n",[254,1151,1152],{"class":151,"line":304},[254,1153,1154],{},"    container = (\n",[254,1156,1157],{"class":151,"line":310},[254,1158,1159],{},"        DockerContainer(\"ghcr.io\u002Facme\u002Fbilling-stub:2.4.1\")   # pinned, always\n",[254,1161,1162],{"class":151,"line":315},[254,1163,1164],{},"        .with_exposed_ports(8080)                            # container-side port\n",[254,1166,1167],{"class":151,"line":320},[254,1168,1169],{},"        .with_env(\"STUB_MODE\", \"deterministic\")\n",[254,1171,1172],{"class":151,"line":325},[254,1173,1174],{},"    )\n",[254,1176,1177],{"class":151,"line":331},[254,1178,1179],{},"    with container:\n",[254,1181,1182],{"class":151,"line":337},[254,1183,1184],{},"        # The image prints this line once its HTTP listener is bound.\n",[254,1186,1188],{"class":151,"line":1187},16,[254,1189,1190],{},"        wait_for_logs(container, \"listening on :8080\", timeout=30)\n",[254,1192,1194],{"class":151,"line":1193},17,[254,1195,272],{"emptyLinePlaceholder":271},[254,1197,1199],{"class":151,"line":1198},18,[254,1200,1201],{},"        host = container.get_container_host_ip()             # not \"localhost\" in DinD\n",[254,1203,1205],{"class":151,"line":1204},19,[254,1206,1207],{},"        port = container.get_exposed_port(8080)              # the ephemeral host port\n",[254,1209,1211],{"class":151,"line":1210},20,[254,1212,1213],{},"        base_url = f\"http:\u002F\u002F{host}:{port}\"\n",[254,1215,1217],{"class":151,"line":1216},21,[254,1218,272],{"emptyLinePlaceholder":271},[254,1220,1222],{"class":151,"line":1221},22,[254,1223,1224],{},"        # Belt and braces: one real request before any test runs, so a wait\n",[254,1226,1228],{"class":151,"line":1227},23,[254,1229,1230],{},"        # strategy that was satisfied too early fails here rather than mid-suite.\n",[254,1232,1234],{"class":151,"line":1233},24,[254,1235,1236],{},"        response = requests.get(f\"{base_url}\u002Fhealth\", timeout=5)\n",[254,1238,1240],{"class":151,"line":1239},25,[254,1241,1242],{},"        response.raise_for_status()\n",[254,1244,1246],{"class":151,"line":1245},26,[254,1247,272],{"emptyLinePlaceholder":271},[254,1249,1251],{"class":151,"line":1250},27,[254,1252,1253],{},"        yield base_url\n",[10,1255,1256,1257,1260,1261,1264,1265,1268,1269,1271,1272,1274],{},"Four things in that fixture are worth copying verbatim. ",[28,1258,1259],{},"with_exposed_ports"," takes the port ",[65,1262,1263],{},"inside"," the container, while ",[28,1266,1267],{},"get_exposed_port"," returns the mapped one outside it — confusing them produces a connection refused that looks like a readiness problem. ",[28,1270,883],{}," rather than a literal ",[28,1273,877],{}," is what makes the fixture work under Docker-in-Docker. The log wait is cheap and specific. And the single real request afterwards converts \"the log line appeared but the service was still initialising\" into a failure during setup, where it is obvious, rather than into a flaky first test.",[10,1276,1277,1278,1280],{},"For images that expose no useful log line, poll the service itself with a bounded loop. ",[28,1279,799],{}," and a health poll are the two primitives; between them they cover every service worth containerising.",[14,1282,1284],{"id":1283},"container-fake-or-shared-instance","Container, fake, or shared instance",[10,1286,1287],{},"Not every external dependency deserves a container, and choosing badly in either direction is expensive. The question is what the tests need to be true.",[10,1289,1290,1291,1294,1295,1298],{},"A ",[1052,1292,1293],{},"fake"," is right when the tests exercise your code's logic and the dependency's behaviour is incidental. An in-memory repository, a ",[28,1296,1297],{},"respx"," route, a stub queue: microseconds per test, no Docker, and the whole suite runs on a laptop with nothing installed.",[10,1300,1290,1301,1304],{},[1052,1302,1303],{},"container"," is right when the dependency's own behaviour is under test — SQL semantics, Redis eviction, Kafka consumer-group rebalancing, an S3 API's exact error codes. These are the behaviours a fake reproduces approximately and therefore wrongly.",[10,1306,1290,1307,1310],{},[1052,1308,1309],{},"shared instance"," — a long-lived database on the developer's machine or a team server — is almost never right. It reintroduces the version drift and cross-run contamination that containers exist to remove, and it makes the suite fail for people who have not performed a setup ritual nobody wrote down.",[70,1312,1314,1416],{"className":1313},[73],[75,1315,83,1320,83,1323,83,1326,83,1328,83,1331,83,1336,83,1341,83,1343,83,1347,83,1350,83,1353,83,1356,83,1359,83,1363,83,1366,83,1369,83,1371,83,1375,83,1378,83,1381,83,1384,83,1387,83,1390,83,1393,83,1396,83,1399,83,1402,83,1404,83,1407,83,1411,83,1414],{"viewBox":1316,"role":78,"ariaLabelledBy":1317,"xmlns":82},"0 0 820 240",[1318,1319],"pick-t","pick-d",[85,1321,1322],{"id":1318},"Choosing between a fake, a container and a shared instance",[89,1324,1325],{"id":1319},"Three columns compare a fake, a container and a shared instance across speed, fidelity, setup cost for a new contributor, and the risk of cross-run contamination. The fake is fastest with the lowest fidelity, the container balances both, and the shared instance is the only one that carries contamination risk.",[111,1327],{"x":113,"y":113,"width":929,"height":155,"rx":116,"fill":117},[119,1329,1330],{"x":933,"y":122,"textAnchor":123,"fontSize":124,"fontWeight":125,"fill":109},"What each option actually buys",[111,1332],{"x":129,"y":974,"width":1333,"height":1334,"rx":101,"fill":134,"stroke":109,"strokeWidth":1335},"180","40","1.5",[119,1337,1340],{"x":1338,"y":1339,"textAnchor":123,"fontSize":206,"fontWeight":125,"fill":109},"116","74","property",[111,1342],{"x":1007,"y":974,"width":1003,"height":1334,"rx":101,"fill":181,"stroke":182,"strokeWidth":162},[119,1344,1346],{"x":1345,"y":1339,"textAnchor":123,"fontSize":206,"fontWeight":125,"fill":109},"310","in-memory fake",[111,1348],{"x":1349,"y":974,"width":1003,"height":1334,"rx":101,"fill":160,"stroke":161,"strokeWidth":162},"414",[119,1351,1303],{"x":1352,"y":1339,"textAnchor":123,"fontSize":206,"fontWeight":125,"fill":109},"510",[111,1354],{"x":1355,"y":974,"width":1333,"height":1334,"rx":101,"fill":218,"stroke":219,"strokeWidth":162},"614",[119,1357,1309],{"x":1358,"y":1339,"textAnchor":123,"fontSize":206,"fontWeight":125,"fill":109},"704",[119,1360,1362],{"x":973,"y":1361,"fontSize":206,"fill":109},"118","time per test",[119,1364,1365],{"x":1345,"y":1361,"textAnchor":123,"fontSize":206,"fill":192},"microseconds",[119,1367,1368],{"x":1352,"y":1361,"textAnchor":123,"fontSize":206,"fill":109},"milliseconds",[119,1370,1368],{"x":1358,"y":1361,"textAnchor":123,"fontSize":206,"fill":109},[119,1372,1374],{"x":973,"y":1373,"fontSize":206,"fill":109},"150","fidelity",[119,1376,1377],{"x":1345,"y":1373,"textAnchor":123,"fontSize":206,"fill":1008},"approximate",[119,1379,1380],{"x":1352,"y":1373,"textAnchor":123,"fontSize":206,"fill":192},"exact",[119,1382,1383],{"x":1358,"y":1373,"textAnchor":123,"fontSize":206,"fill":172},"whatever is installed",[119,1385,1386],{"x":973,"y":223,"fontSize":206,"fill":109},"new contributor",[119,1388,1389],{"x":1345,"y":223,"textAnchor":123,"fontSize":206,"fill":192},"nothing to install",[119,1391,1392],{"x":1352,"y":223,"textAnchor":123,"fontSize":206,"fill":109},"needs Docker",[119,1394,1395],{"x":1358,"y":223,"textAnchor":123,"fontSize":206,"fill":1008},"undocumented ritual",[119,1397,1398],{"x":973,"y":1007,"fontSize":206,"fill":109},"cross-run state",[119,1400,1401],{"x":1345,"y":1007,"textAnchor":123,"fontSize":206,"fill":192},"none",[119,1403,1401],{"x":1352,"y":1007,"textAnchor":123,"fontSize":206,"fill":192},[119,1405,1406],{"x":1358,"y":1007,"textAnchor":123,"fontSize":206,"fill":1008},"accumulates",[151,1408],{"x1":129,"y1":201,"x2":1409,"y2":201,"stroke":1410,"strokeWidth":994},"794","rgba(61,64,91,0.14)",[151,1412],{"x1":129,"y1":1413,"x2":1409,"y2":1413,"stroke":1410,"strokeWidth":994},"160",[151,1415],{"x1":129,"y1":1003,"x2":1409,"y2":1003,"stroke":1410,"strokeWidth":994},[232,1417,1418],{},"Most suites want both of the first two: fakes for the bulk of the tests, one container for the layer where the dependency's own behaviour is the subject.",[14,1420,1422],{"id":1421},"local-reuse-without-ci-contamination","Local reuse without CI contamination",[10,1424,1425],{},"Developers run the integration suite dozens of times a day, and three seconds of container startup on every run is a tax worth removing — for them specifically, and never for the pipeline.",[245,1427,1429],{"className":247,"code":1428,"language":249,"meta":250,"style":250},"import os\n\nimport pytest\nfrom testcontainers.postgres import PostgresContainer\n\n\n@pytest.fixture(scope=\"session\")\ndef postgres():\n    container = PostgresContainer(\"postgres:16-alpine\")\n\n    # Reuse is a local-only optimisation: a container that survives between runs\n    # also survives between builds, which is exactly what CI must not have.\n    if os.environ.get(\"TC_REUSE\") == \"1\" and not os.environ.get(\"CI\"):\n        container = container.with_reuse(True)\n\n    with container:\n        yield container\n",[28,1430,1431,1435,1439,1443,1447,1451,1455,1459,1463,1468,1472,1477,1482,1487,1492,1496,1500],{"__ignoreMap":250},[254,1432,1433],{"class":151,"line":256},[254,1434,496],{},[254,1436,1437],{"class":151,"line":262},[254,1438,272],{"emptyLinePlaceholder":271},[254,1440,1441],{"class":151,"line":268},[254,1442,259],{},[254,1444,1445],{"class":151,"line":275},[254,1446,265],{},[254,1448,1449],{"class":151,"line":280},[254,1450,272],{"emptyLinePlaceholder":271},[254,1452,1453],{"class":151,"line":286},[254,1454,272],{"emptyLinePlaceholder":271},[254,1456,1457],{"class":151,"line":292},[254,1458,283],{},[254,1460,1461],{"class":151,"line":298},[254,1462,289],{},[254,1464,1465],{"class":151,"line":304},[254,1466,1467],{},"    container = PostgresContainer(\"postgres:16-alpine\")\n",[254,1469,1470],{"class":151,"line":310},[254,1471,272],{"emptyLinePlaceholder":271},[254,1473,1474],{"class":151,"line":315},[254,1475,1476],{},"    # Reuse is a local-only optimisation: a container that survives between runs\n",[254,1478,1479],{"class":151,"line":320},[254,1480,1481],{},"    # also survives between builds, which is exactly what CI must not have.\n",[254,1483,1484],{"class":151,"line":325},[254,1485,1486],{},"    if os.environ.get(\"TC_REUSE\") == \"1\" and not os.environ.get(\"CI\"):\n",[254,1488,1489],{"class":151,"line":331},[254,1490,1491],{},"        container = container.with_reuse(True)\n",[254,1493,1494],{"class":151,"line":337},[254,1495,272],{"emptyLinePlaceholder":271},[254,1497,1498],{"class":151,"line":1187},[254,1499,1179],{},[254,1501,1502],{"class":151,"line":1193},[254,1503,307],{},[10,1505,1506,1507,1510,1511,1514],{},"Reuse requires the Docker daemon to be configured with ",[28,1508,1509],{},"testcontainers.reuse.enable=true"," in ",[28,1512,1513],{},"~\u002F.testcontainers.properties","; without it the flag is ignored rather than failing, which is the right default. When it is active, testcontainers computes a hash of the container's configuration and reattaches to any running container with the same hash, so changing the image tag or an environment variable transparently starts a fresh one.",[10,1516,1517],{},"The trade-off is state. A reused Postgres keeps the rows any previous run committed, which is harmless when every test rolls back and fatal when one does not. That asymmetry is a useful diagnostic in itself: if enabling reuse locally makes tests fail, some test is committing without cleaning up, and that test would eventually have failed in CI under a different ordering. Treating a reuse failure as a bug in the suite rather than a reason to disable reuse tends to find real isolation defects.",[10,1519,1520,1521,1524,1525,1528,1529,1531,1532,1535],{},"Two smaller local conveniences are worth the same treatment. ",[28,1522,1523],{},"TESTCONTAINERS_RYUK_DISABLED"," shaves the reaper's own startup, at the cost of the cleanup guarantee — acceptable on a laptop where ",[28,1526,1527],{},"docker system prune"," is a keystroke away, never in a shared runner. And keeping a long-lived ",[28,1530,1058],{}," of the pinned images in a ",[28,1533,1534],{},"make setup"," target means a new contributor's first test run is fast rather than a four-minute download that looks like a hang.",[10,1537,1538],{},"The general rule behind all three is that a local optimisation must be opt-in and must announce itself. An environment variable that a developer sets deliberately is fine; a default that quietly changes isolation semantics depending on which machine the suite runs on is how a pipeline ends up with failures nobody can reproduce.",[14,1540,1542],{"id":1541},"frequently-asked-questions","Frequently Asked Questions",[10,1544,1545,1548,1549,1552,1553,1555],{},[1052,1546,1547],{},"How do I stop containers leaking when a test run is interrupted?","\nUse the container as a context manager so ",[28,1550,1551],{},"__exit__"," stops it, and enable Ryuk, the reaper sidecar testcontainers starts by default. Ryuk holds a connection to the test process and removes every labelled container when that connection drops, which covers ",[28,1554,896],{}," and crashed runs where Python's cleanup never executes.",[10,1557,1558,1561],{},[1052,1559,1560],{},"Can containers be reused between runs to save startup time?","\nYes, with testcontainers' reuse flag plus a Docker daemon that has reuse enabled, which keeps a labelled container alive across runs. It is excellent locally and wrong in CI, where a reused container means state carried between builds. Gate it on an environment variable so developers get the speed and the pipeline gets the isolation.",[10,1563,1564,1567,1568,1570],{},[1052,1565,1566],{},"Why does the container's port keep changing?","\nBecause testcontainers maps the container's port to an ephemeral host port on purpose, so parallel runs never collide. Always read the mapped port with ",[28,1569,833],{}," or the module's connection-URL helper instead of hardcoding it.",[10,1572,1573,1576,1577,1579],{},[1052,1574,1575],{},"Do Testcontainers work inside a CI container?","\nYes, with either a mounted Docker socket or a Docker-in-Docker service. Mounting the host socket is faster and shares the image cache; Docker-in-Docker is more isolated and needs privileged mode. In both cases the container's ports are reachable from the test process, but the hostname may not be ",[28,1578,877],{},", so read it from the API rather than assuming.",[10,1581,1582,1585],{},[1052,1583,1584],{},"How much startup time should a session-scoped container cost?","\nTwo to five seconds for Postgres or Redis with a warm image cache, and longer for Kafka or Elasticsearch, which have real initialisation work. If it is consistently slower, the wait strategy is polling too infrequently or the image is being pulled on every run.",[14,1587,1589],{"id":1588},"related-guides","Related guides",[19,1591,1592,1599,1604,1610,1617],{},[22,1593,1594,1595,482],{},"Work through a complete Postgres fixture in ",[345,1596,1598],{"href":1597},"\u002Fintegration-database-and-service-testing\u002Fspinning-up-services-with-testcontainers\u002Fstarting-postgres-with-testcontainers-python\u002F","starting Postgres with testcontainers-python",[22,1600,1601,1602,482],{},"Replace every startup sleep with ",[345,1603,481],{"href":480},[22,1605,1606,1607,482],{},"Layer per-test isolation on top with ",[345,1608,1609],{"href":347},"database fixtures and transactional tests",[22,1611,1612,1613,482],{},"Debug a container-only failure using ",[345,1614,1616],{"href":1615},"\u002Fsystematic-debugging-performance-profiling\u002Fdebugging-tests-in-ci-and-containers\u002Fattaching-debugpy-to-a-container\u002F","attaching debugpy to a container",[22,1618,1619,1620,482],{},"Keep the fast suite Docker-free by faking the transport instead, as in ",[345,1621,1623],{"href":1622},"\u002Fadvanced-mocking-test-doubles-in-python\u002Fmocking-network-and-http-calls\u002F","mocking network and HTTP calls",[10,1625,1626,1627],{},"← Back to ",[345,1628,1629],{"href":1096},"Integration, Database & Service Testing",[1631,1632,1633],"style",{},"html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .sJ8bj, html code.shiki .sJ8bj{--shiki-default:#6A737D;--shiki-dark:#6A737D}html pre.shiki code .sScJk, html code.shiki .sScJk{--shiki-default:#6F42C1;--shiki-dark:#B392F0}html pre.shiki code .sZZnC, html code.shiki .sZZnC{--shiki-default:#032F62;--shiki-dark:#9ECBFF}html pre.shiki code .sj4cs, html code.shiki .sj4cs{--shiki-default:#005CC5;--shiki-dark:#79B8FF}html pre.shiki code .sVt8B, html code.shiki .sVt8B{--shiki-default:#24292E;--shiki-dark:#E1E4E8}",{"title":250,"searchDepth":262,"depth":262,"links":1635},[1636,1637,1638,1645,1646,1647,1648,1649,1650,1651,1652,1653],{"id":16,"depth":262,"text":17},{"id":59,"depth":262,"text":60},{"id":237,"depth":262,"text":238,"children":1639},[1640,1641,1642,1643,1644],{"id":242,"depth":268,"text":243},{"id":352,"depth":268,"text":353},{"id":411,"depth":268,"text":412},{"id":485,"depth":268,"text":486},{"id":570,"depth":268,"text":571},{"id":642,"depth":262,"text":643},{"id":758,"depth":262,"text":759},{"id":886,"depth":262,"text":887},{"id":1044,"depth":262,"text":1045},{"id":1105,"depth":262,"text":1106},{"id":1283,"depth":262,"text":1284},{"id":1421,"depth":262,"text":1422},{"id":1541,"depth":262,"text":1542},{"id":1588,"depth":262,"text":1589},"Run real Postgres, Redis and Kafka in pytest with testcontainers-python: session lifecycles, wait strategies, port mapping, parallel workers, and CI image caching.","md",{"slug":1657,"type":1658,"breadcrumb":1659,"datePublished":1660,"dateModified":1660,"faq":1661,"howto":1672},"spinning-up-services-with-testcontainers","topic","Testcontainers","2026-09-18",[1662,1664,1666,1668,1670],{"q":1547,"a":1663},"Use the container as a context manager so __exit__ stops it, and enable Ryuk, the reaper sidecar testcontainers starts by default. Ryuk holds a connection to the test process and removes every labelled container when that connection drops, which covers SIGKILL and crashed runs where Python's cleanup never executes.",{"q":1560,"a":1665},"Yes, with testcontainers' reuse flag plus a Docker daemon that has reuse enabled, which keeps a labelled container alive across runs. It is excellent locally and wrong in CI, where a reused container means state carried between builds. Gate it on an environment variable so developers get the speed and the pipeline gets the isolation.",{"q":1566,"a":1667},"Because testcontainers maps the container's port to an ephemeral host port on purpose, so parallel runs never collide. Always read the mapped port with get_exposed_port() or the module's connection-URL helper instead of hardcoding it.",{"q":1575,"a":1669},"Yes, with either a mounted Docker socket or a Docker-in-Docker service. Mounting the host socket is faster and shares the image cache; Docker-in-Docker is more isolated and needs privileged mode. In both cases the container's ports are reachable from the test process, but the hostname may not be localhost, so read it from the API rather than assuming.",{"q":1584,"a":1671},"Two to five seconds for Postgres or Redis with a warm image cache, and longer for Kafka or Elasticsearch, which have real initialisation work. If it is consistently slower, the wait strategy is polling too infrequently or the image is being pulled on every run.",{"name":1673,"description":1674,"steps":1675},"How to run a real service in pytest with Testcontainers","Start one container per session, wait on a genuine readiness signal, and read the mapped connection details from the API.",[1676,1679,1682,1685,1688],{"name":1677,"text":1678},"Pin the image tag","Choose an exact image tag matching production so the suite's behaviour does not depend on when the cache was warmed.",{"name":1680,"text":1681},"Start the container at session scope","Use the container as a context manager inside a session-scoped fixture so one instance serves the whole run and is stopped at the end.",{"name":1683,"text":1684},"Wait on a real readiness signal","Use the module's wait strategy or a bounded poll against a log line, port or health query instead of sleeping.",{"name":1686,"text":1687},"Read the mapped connection details","Call get_connection_url or get_exposed_port so the test connects to the ephemeral host port rather than a hardcoded one.",{"name":1689,"text":1690},"Isolate parallel workers","Give each xdist worker its own container or its own database inside one container, keyed on PYTEST_XDIST_WORKER.","\u002Fintegration-database-and-service-testing\u002Fspinning-up-services-with-testcontainers",{"title":5,"description":1654},"integration-database-and-service-testing\u002Fspinning-up-services-with-testcontainers\u002Findex","VweRGmQTVRWKrRwmA5mJaGUQr0XKKZwJIsouwwvmv0w",1789718765719]