[{"data":1,"prerenderedAt":1922},["ShallowReactive",2],{"page-\u002Fadvanced-mocking-test-doubles-in-python\u002Ffaking-the-filesystem-and-environment\u002F":3},{"id":4,"title":5,"body":6,"description":1886,"extension":1887,"meta":1888,"navigation":108,"path":1918,"seo":1919,"stem":1920,"__hash__":1921},"content\u002Fadvanced-mocking-test-doubles-in-python\u002Ffaking-the-filesystem-and-environment\u002Findex.md","Faking the Filesystem and Environment in Tests",{"type":7,"value":8,"toc":1870},"minimark",[9,13,18,56,60,71,74,127,147,274,278,291,347,361,390,396,421,435,573,577,595,612,645,673,683,717,726,850,854,860,892,952,955,959,965,968,977,1021,1033,1044,1082,1085,1164,1168,1171,1193,1202,1205,1245,1249,1252,1380,1383,1397,1411,1415,1514,1518,1521,1619,1637,1656,1664,1734,1738,1751,1763,1773,1786,1791,1794,1797,1807,1813,1816,1822,1826,1859,1866],[10,11,12],"p",{},"Tests that touch the filesystem or read the environment fail in the two ways that are hardest to debug: they pass alone and fail in a suite, or they pass on one machine and fail on another. Both symptoms have the same cause — process-global state that one test changed and another observed. The tools to fix it are all in pytest and the standard library, and choosing between them is mostly a question of how much of the filesystem the code under test insists on owning.",[14,15,17],"h2",{"id":16},"prerequisites","Prerequisites",[19,20,21,42,48],"ul",{},[22,23,24,28,29,33,34,37,38,41],"li",{},[25,26,27],"strong",{},"pytest 7.0+"," for ",[30,31,32],"code",{},"tmp_path",", ",[30,35,36],{},"tmp_path_factory"," and ",[30,39,40],{},"monkeypatch",".",[22,43,44,47],{},[25,45,46],{},"pyfakefs 5.3+"," only for the whole-filesystem case in the last section.",[22,49,50,51,41],{},"Familiarity with fixture scope from ",[52,53,55],"a",{"href":54},"\u002Fadvanced-pytest-architecture-configuration\u002Fmastering-pytest-fixtures\u002F","mastering pytest fixtures",[14,57,59],{"id":58},"core-concept-real-files-beat-mocked-ones","Core concept: real files beat mocked ones",[10,61,62,63,66,67,70],{},"The instinct to ",[30,64,65],{},"patch(\"builtins.open\")"," is almost always wrong. A mocked ",[30,68,69],{},"open"," returns whatever you told it to, which means the test never exercises encoding, newline translation, permissions, partial reads, or the difference between a missing file and an empty one — and those are where file-handling bugs live.",[10,72,73],{},"A real temporary directory costs microseconds and removes all of that guesswork:",[75,76,81],"pre",{"className":77,"code":78,"language":79,"meta":80,"style":80},"language-python shiki shiki-themes github-light github-dark","def test_config_loader_handles_utf8(tmp_path):\n    config = tmp_path \u002F \"app.toml\"\n    config.write_text('name = \"café\"\\n', encoding=\"utf-8\")   # a real file, real bytes\n\n    loaded = load_config(config)\n\n    assert loaded[\"name\"] == \"café\"          # encoding actually verified\n","python","",[30,82,83,91,97,103,110,116,121],{"__ignoreMap":80},[84,85,88],"span",{"class":86,"line":87},"line",1,[84,89,90],{},"def test_config_loader_handles_utf8(tmp_path):\n",[84,92,94],{"class":86,"line":93},2,[84,95,96],{},"    config = tmp_path \u002F \"app.toml\"\n",[84,98,100],{"class":86,"line":99},3,[84,101,102],{},"    config.write_text('name = \"café\"\\n', encoding=\"utf-8\")   # a real file, real bytes\n",[84,104,106],{"class":86,"line":105},4,[84,107,109],{"emptyLinePlaceholder":108},true,"\n",[84,111,113],{"class":86,"line":112},5,[84,114,115],{},"    loaded = load_config(config)\n",[84,117,119],{"class":86,"line":118},6,[84,120,109],{"emptyLinePlaceholder":108},[84,122,124],{"class":86,"line":123},7,[84,125,126],{},"    assert loaded[\"name\"] == \"café\"          # encoding actually verified\n",[10,128,129,131,132,135,136,138,139,142,143,41],{},[30,130,32],{}," is a ",[30,133,134],{},"pathlib.Path"," to a fresh directory, unique per test, removed automatically after a few runs are kept for inspection. ",[30,137,36],{}," is its session-scoped sibling for expensive shared trees, and it is the correct fix for the ",[30,140,141],{},"ScopeMismatch"," described in ",[52,144,146],{"href":145},"\u002Fadvanced-pytest-architecture-configuration\u002Fmastering-pytest-fixtures\u002Ffixing-scopemismatch-errors-in-pytest\u002F","fixing ScopeMismatch errors in pytest",[148,149,152,270],"figure",{"className":150},[151],"diagram",[153,154,161,162,161,166,161,170,161,178,161,187,161,196,161,202,161,206,161,210,161,215,161,219,161,222,161,226,161,229,161,232,161,235,161,237,161,240,161,244,161,247,161,250,161,253,161,256,161,259,161,263,161,267],"svg",{"viewBox":155,"role":156,"ariaLabelledBy":157,"xmlns":160},"0 0 760 270","img",[158,159],"fstools-t","fstools-d","http:\u002F\u002Fwww.w3.org\u002F2000\u002Fsvg","\n  ",[163,164,165],"title",{"id":158},"Four ways to isolate filesystem access",[167,168,169],"desc",{"id":159},"A table comparing tmp_path, tmp_path_factory, pyfakefs and patching open, across what each isolates, whether real file semantics are exercised, and the cost of each.",[171,172],"rect",{"x":173,"y":173,"width":174,"height":175,"rx":176,"fill":177},"0","760","270","14","#fffdf8",[179,180,165],"text",{"x":181,"y":182,"textAnchor":183,"fontSize":184,"fontWeight":185,"fill":186},"380","30","middle","15","700","#3d405b",[171,188],{"x":189,"y":190,"width":191,"height":192,"rx":193,"fill":194,"stroke":186,"strokeWidth":195},"24","52","712","40","10","#f2cc8f","1.5",[179,197,201],{"x":198,"y":199,"fontSize":200,"fontWeight":185,"fill":186},"38","76","12","Criterion",[179,203,205],{"x":204,"y":199,"textAnchor":183,"fontSize":200,"fontWeight":185,"fill":186},"390","Isolates",[179,207,209],{"x":208,"y":199,"textAnchor":183,"fontSize":200,"fontWeight":185,"fill":186},"620","Real semantics",[171,211],{"x":189,"y":212,"width":191,"height":192,"fill":213,"stroke":186,"strokeWidth":214},"92","#f4f1de","1",[179,216,32],{"x":198,"y":217,"fontSize":218,"fill":186},"116","11.5",[179,220,221],{"x":204,"y":217,"textAnchor":183,"fontSize":218,"fill":186},"one directory, per test",[179,223,225],{"x":208,"y":217,"textAnchor":183,"fontSize":218,"fill":224},"#2a5f49","yes",[171,227],{"x":189,"y":228,"width":191,"height":192,"fill":177,"stroke":186,"strokeWidth":214},"132",[179,230,36],{"x":198,"y":231,"fontSize":218,"fill":186},"156",[179,233,234],{"x":204,"y":231,"textAnchor":183,"fontSize":218,"fill":186},"a shared tree, per session",[179,236,225],{"x":208,"y":231,"textAnchor":183,"fontSize":218,"fill":224},[171,238],{"x":189,"y":239,"width":191,"height":192,"fill":213,"stroke":186,"strokeWidth":214},"172",[179,241,243],{"x":198,"y":242,"fontSize":218,"fill":186},"196","pyfakefs",[179,245,246],{"x":204,"y":242,"textAnchor":183,"fontSize":218,"fill":186},"the whole filesystem",[179,248,249],{"x":208,"y":242,"textAnchor":183,"fontSize":218,"fill":224},"mostly",[171,251],{"x":189,"y":252,"width":191,"height":192,"fill":177,"stroke":186,"strokeWidth":214},"212",[179,254,65],{"x":198,"y":255,"fontSize":218,"fill":186},"236",[179,257,258],{"x":204,"y":255,"textAnchor":183,"fontSize":218,"fill":186},"one call site",[179,260,262],{"x":208,"y":255,"textAnchor":183,"fontSize":218,"fill":261},"#8f3d22","none",[86,264],{"x1":265,"y1":190,"x2":265,"y2":266,"stroke":186,"strokeWidth":214},"274","252",[86,268],{"x1":269,"y1":190,"x2":269,"y2":266,"stroke":186,"strokeWidth":214},"505",[271,272,273],"figcaption",{},"Only the last row skips real file behaviour, which is exactly the behaviour most file-handling bugs depend on.",[14,275,277],{"id":276},"step-by-step-implementation","Step-by-step implementation",[10,279,280,283,284,286,287,290],{},[25,281,282],{},"1. Give the test its own directory."," Build whatever tree the code expects inside ",[30,285,32],{},", using ",[30,288,289],{},"pathlib"," rather than string paths so the test reads the same on every platform:",[75,292,294],{"className":77,"code":293,"language":79,"meta":80,"style":80},"def test_walks_only_yaml(tmp_path):\n    (tmp_path \u002F \"conf\").mkdir()\n    (tmp_path \u002F \"conf\" \u002F \"a.yaml\").write_text(\"x: 1\\n\")\n    (tmp_path \u002F \"conf\" \u002F \"b.json\").write_text(\"{}\")\n    (tmp_path \u002F \"conf\" \u002F \"nested\").mkdir()\n    (tmp_path \u002F \"conf\" \u002F \"nested\" \u002F \"c.yaml\").write_text(\"y: 2\\n\")\n\n    found = discover_configs(tmp_path \u002F \"conf\")\n\n    assert sorted(p.name for p in found) == [\"a.yaml\", \"c.yaml\"]\n",[30,295,296,301,306,311,316,321,326,330,336,341],{"__ignoreMap":80},[84,297,298],{"class":86,"line":87},[84,299,300],{},"def test_walks_only_yaml(tmp_path):\n",[84,302,303],{"class":86,"line":93},[84,304,305],{},"    (tmp_path \u002F \"conf\").mkdir()\n",[84,307,308],{"class":86,"line":99},[84,309,310],{},"    (tmp_path \u002F \"conf\" \u002F \"a.yaml\").write_text(\"x: 1\\n\")\n",[84,312,313],{"class":86,"line":105},[84,314,315],{},"    (tmp_path \u002F \"conf\" \u002F \"b.json\").write_text(\"{}\")\n",[84,317,318],{"class":86,"line":112},[84,319,320],{},"    (tmp_path \u002F \"conf\" \u002F \"nested\").mkdir()\n",[84,322,323],{"class":86,"line":118},[84,324,325],{},"    (tmp_path \u002F \"conf\" \u002F \"nested\" \u002F \"c.yaml\").write_text(\"y: 2\\n\")\n",[84,327,328],{"class":86,"line":123},[84,329,109],{"emptyLinePlaceholder":108},[84,331,333],{"class":86,"line":332},8,[84,334,335],{},"    found = discover_configs(tmp_path \u002F \"conf\")\n",[84,337,339],{"class":86,"line":338},9,[84,340,109],{"emptyLinePlaceholder":108},[84,342,344],{"class":86,"line":343},10,[84,345,346],{},"    assert sorted(p.name for p in found) == [\"a.yaml\", \"c.yaml\"]\n",[10,348,349,352,353,356,357,360],{},[25,350,351],{},"2. Set environment variables through monkeypatch."," ",[30,354,355],{},"os.environ[\"X\"] = \"1\""," is never undone; ",[30,358,359],{},"monkeypatch.setenv"," records the previous value — including its absence — and restores it even when the test raises:",[75,362,364],{"className":77,"code":363,"language":79,"meta":80,"style":80},"def test_reads_region_from_environment(monkeypatch):\n    monkeypatch.setenv(\"APP_REGION\", \"eu-west-1\")\n    monkeypatch.delenv(\"APP_DEBUG\", raising=False)     # absent is a valid state\n\n    assert build_client().region == \"eu-west-1\"\n",[30,365,366,371,376,381,385],{"__ignoreMap":80},[84,367,368],{"class":86,"line":87},[84,369,370],{},"def test_reads_region_from_environment(monkeypatch):\n",[84,372,373],{"class":86,"line":93},[84,374,375],{},"    monkeypatch.setenv(\"APP_REGION\", \"eu-west-1\")\n",[84,377,378],{"class":86,"line":99},[84,379,380],{},"    monkeypatch.delenv(\"APP_DEBUG\", raising=False)     # absent is a valid state\n",[84,382,383],{"class":86,"line":105},[84,384,109],{"emptyLinePlaceholder":108},[84,386,387],{"class":86,"line":112},[84,388,389],{},"    assert build_client().region == \"eu-west-1\"\n",[10,391,392,395],{},[25,393,394],{},"3. Redirect the working directory rather than assuming it."," Code that resolves relative paths depends on the process's current directory, which pytest does not control by default:",[75,397,399],{"className":77,"code":398,"language":79,"meta":80,"style":80},"def test_writes_report_relative_to_cwd(tmp_path, monkeypatch):\n    monkeypatch.chdir(tmp_path)               # restored at teardown\n    write_report({\"ok\": True})\n    assert (tmp_path \u002F \"report.json\").exists()\n",[30,400,401,406,411,416],{"__ignoreMap":80},[84,402,403],{"class":86,"line":87},[84,404,405],{},"def test_writes_report_relative_to_cwd(tmp_path, monkeypatch):\n",[84,407,408],{"class":86,"line":93},[84,409,410],{},"    monkeypatch.chdir(tmp_path)               # restored at teardown\n",[84,412,413],{"class":86,"line":99},[84,414,415],{},"    write_report({\"ok\": True})\n",[84,417,418],{"class":86,"line":105},[84,419,420],{},"    assert (tmp_path \u002F \"report.json\").exists()\n",[10,422,423,426,427,430,431,41],{},[25,424,425],{},"4. Make paths injectable where you can."," Every one of the techniques above is a workaround for a hardcoded dependency. A function that takes ",[30,428,429],{},"base: Path"," needs no monkeypatching at all, and the test reads as an ordinary call — the same argument made for collaborators in ",[52,432,434],{"href":433},"\u002Fadvanced-mocking-test-doubles-in-python\u002Fdependency-injection-for-testability\u002F","dependency injection for testability",[148,436,438,570],{"className":437},[151],[153,439,161,444,161,447,161,450,161,468,161,471,161,473,161,480,161,486,161,491,161,496,161,502,161,507,161,509,161,513,161,517,161,521,161,525,161,529,161,532,161,536,161,540,161,544,161,548,161,552,161,555,161,558,161,562,161,566],{"viewBox":440,"role":156,"ariaLabelledBy":441,"xmlns":160},"0 0 760 434",[442,443],"fsladder-t","fsladder-d",[163,445,446],{"id":442},"From hardcoded paths to injectable ones",[167,448,449],{"id":443},"A vertical progression: code that hardcodes an absolute path, then code that reads a path from the environment, then code that resolves a path relative to the working directory, and finally code that takes the base path as a parameter.",[451,452,453,454,161],"defs",{},"\n    ",[455,456,463],"marker",{"id":457,"viewBox":458,"refX":459,"refY":460,"markerWidth":461,"markerHeight":461,"orient":462},"fsladder-a","0 0 10 10","9","5","7","auto-start-reverse",[464,465],"path",{"d":466,"fill":467},"M0 0 L10 5 L0 10 z","#81b29a",[171,469],{"x":173,"y":173,"width":174,"height":470,"rx":176,"fill":177},"434",[179,472,446],{"x":181,"y":182,"textAnchor":183,"fontSize":184,"fontWeight":185,"fill":186},[171,474],{"x":475,"y":476,"width":477,"height":478,"rx":200,"fill":177,"stroke":467,"strokeWidth":479},"34","58","320","64","1.8",[179,481,485],{"x":482,"y":483,"textAnchor":183,"fontSize":484,"fontWeight":185,"fill":186},"194","86","12.5","hardcoded \u002Fetc\u002Fapp.toml",[179,487,490],{"x":482,"y":488,"textAnchor":183,"fontSize":489,"fill":186},"103","11","needs pyfakefs to test",[86,492],{"x1":482,"y1":493,"x2":482,"y2":494,"stroke":467,"strokeWidth":479,"markerEnd":495},"128","149","url(#fsladder-a)",[171,497],{"x":498,"y":478,"width":499,"height":190,"rx":193,"fill":177,"stroke":467,"strokeWidth":195,"strokeDashArray":500},"384","346",[460,501],"4",[179,503,506],{"x":504,"y":505,"textAnchor":183,"fontSize":489,"fill":186},"557","93","The only case that justifies a fake filesystem",[171,508],{"x":475,"y":231,"width":477,"height":478,"rx":200,"fill":213,"stroke":467,"strokeWidth":479},[179,510,512],{"x":482,"y":511,"textAnchor":183,"fontSize":484,"fontWeight":185,"fill":186},"184","read from an env var",[179,514,516],{"x":482,"y":515,"textAnchor":183,"fontSize":489,"fill":186},"201","monkeypatch.setenv is enough",[86,518],{"x1":482,"y1":519,"x2":482,"y2":520,"stroke":467,"strokeWidth":479,"markerEnd":495},"226","247",[171,522],{"x":498,"y":523,"width":499,"height":190,"rx":193,"fill":213,"stroke":467,"strokeWidth":195,"strokeDashArray":524},"162",[460,501],[179,526,528],{"x":504,"y":527,"textAnchor":183,"fontSize":489,"fill":186},"191","Restorable, process-global",[171,530],{"x":475,"y":531,"width":477,"height":478,"rx":200,"fill":177,"stroke":467,"strokeWidth":479},"254",[179,533,535],{"x":482,"y":534,"textAnchor":183,"fontSize":484,"fontWeight":185,"fill":186},"282","relative to cwd",[179,537,539],{"x":482,"y":538,"textAnchor":183,"fontSize":489,"fill":186},"299","monkeypatch.chdir is enough",[86,541],{"x1":482,"y1":542,"x2":482,"y2":543,"stroke":467,"strokeWidth":479,"markerEnd":495},"324","345",[171,545],{"x":498,"y":546,"width":499,"height":190,"rx":193,"fill":177,"stroke":467,"strokeWidth":195,"strokeDashArray":547},"260",[460,501],[179,549,551],{"x":504,"y":550,"textAnchor":183,"fontSize":489,"fill":186},"289","Restorable, but implicit",[171,553],{"x":475,"y":554,"width":477,"height":478,"rx":200,"fill":213,"stroke":467,"strokeWidth":479},"352",[179,556,557],{"x":482,"y":181,"textAnchor":183,"fontSize":484,"fontWeight":185,"fill":186},"base path parameter",[179,559,561],{"x":482,"y":560,"textAnchor":183,"fontSize":489,"fill":186},"397","no patching at all",[171,563],{"x":498,"y":564,"width":499,"height":190,"rx":193,"fill":213,"stroke":467,"strokeWidth":195,"strokeDashArray":565},"358",[460,501],[179,567,569],{"x":504,"y":568,"textAnchor":183,"fontSize":489,"fill":186},"387","Explicit and injectable",[271,571,572],{},"Each rung down the ladder removes a category of test scaffolding; the bottom rung needs none.",[14,574,576],{"id":575},"replacing-the-whole-filesystem-with-pyfakefs","Replacing the whole filesystem with pyfakefs",[10,578,579,580,583,584,587,588,591,592,594],{},"Everything above redirects code that is willing to be redirected. Some code is not: a config loader that reads ",[30,581,582],{},"\u002Fetc\u002Fapp\u002Fsettings.toml",", a library that writes to ",[30,585,586],{},"~\u002F.cache",", a routine that walks ",[30,589,590],{},"\u002F"," looking for a certificate bundle. Those paths cannot be pointed at ",[30,593,32],{}," without changing the code, and sometimes the code belongs to a dependency.",[10,596,597,599,600,33,603,33,606,37,609,611],{},[30,598,243],{}," replaces the filesystem layer itself. It patches the ",[30,601,602],{},"os",[30,604,605],{},"io",[30,607,608],{},"shutil",[30,610,289],{}," modules so every path operation resolves against an in-memory tree, and the real disk is untouched:",[75,613,615],{"className":77,"code":614,"language":79,"meta":80,"style":80},"def test_reads_system_config(fs):           # `fs` is the pyfakefs fixture\n    fs.create_file(\"\u002Fetc\u002Fapp\u002Fsettings.toml\", contents='region = \"eu-west-1\"\\n')\n\n    settings = load_system_settings()        # reads the hardcoded \u002Fetc path\n\n    assert settings[\"region\"] == \"eu-west-1\"\n",[30,616,617,622,627,631,636,640],{"__ignoreMap":80},[84,618,619],{"class":86,"line":87},[84,620,621],{},"def test_reads_system_config(fs):           # `fs` is the pyfakefs fixture\n",[84,623,624],{"class":86,"line":93},[84,625,626],{},"    fs.create_file(\"\u002Fetc\u002Fapp\u002Fsettings.toml\", contents='region = \"eu-west-1\"\\n')\n",[84,628,629],{"class":86,"line":99},[84,630,109],{"emptyLinePlaceholder":108},[84,632,633],{"class":86,"line":105},[84,634,635],{},"    settings = load_system_settings()        # reads the hardcoded \u002Fetc path\n",[84,637,638],{"class":86,"line":112},[84,639,109],{"emptyLinePlaceholder":108},[84,641,642],{"class":86,"line":118},[84,643,644],{},"    assert settings[\"region\"] == \"eu-west-1\"\n",[10,646,647,648,651,652,33,655,37,658,660,661,664,665,668,669,672],{},"Three properties make this different from the other tools. It is ",[25,649,650],{},"total",": absolute paths, ",[30,653,654],{},"os.walk",[30,656,657],{},"shutil.copytree",[30,659,289],{}," all see the fake tree, so no path escapes it. It is ",[25,662,663],{},"fast",": nothing touches a disk, so a test that creates ten thousand files runs in milliseconds. And it is ",[25,666,667],{},"hermetic",": a test cannot accidentally read a developer's real ",[30,670,671],{},"~\u002F.aws\u002Fcredentials",", which is a genuine safety property when the code under test looks for credentials.",[10,674,675,676,33,679,682],{},"The trade-offs are equally real. Native extensions that open files through C bypass the patched modules entirely and see an empty directory, which produces confusing failures in code using ",[30,677,678],{},"lxml",[30,680,681],{},"numpy.load"," or a database driver. Anything that shells out to a subprocess is unaffected, because the child process has its own real filesystem. And a fake filesystem does not reproduce permission semantics, filesystem-specific case sensitivity, or the behaviour of a full disk unless you configure it to.",[75,684,686],{"className":77,"code":685,"language":79,"meta":80,"style":80},"def test_handles_permission_error(fs):\n    fs.create_file(\"\u002Fetc\u002Fapp\u002Fsettings.toml\", contents=\"\")\n    fs.chmod(\"\u002Fetc\u002Fapp\u002Fsettings.toml\", 0o000)      # simulate an unreadable file\n\n    with pytest.raises(PermissionError):\n        load_system_settings()\n",[30,687,688,693,698,703,707,712],{"__ignoreMap":80},[84,689,690],{"class":86,"line":87},[84,691,692],{},"def test_handles_permission_error(fs):\n",[84,694,695],{"class":86,"line":93},[84,696,697],{},"    fs.create_file(\"\u002Fetc\u002Fapp\u002Fsettings.toml\", contents=\"\")\n",[84,699,700],{"class":86,"line":99},[84,701,702],{},"    fs.chmod(\"\u002Fetc\u002Fapp\u002Fsettings.toml\", 0o000)      # simulate an unreadable file\n",[84,704,705],{"class":86,"line":105},[84,706,109],{"emptyLinePlaceholder":108},[84,708,709],{"class":86,"line":112},[84,710,711],{},"    with pytest.raises(PermissionError):\n",[84,713,714],{"class":86,"line":118},[84,715,716],{},"        load_system_settings()\n",[10,718,719,720,722,723,725],{},"Reach for ",[30,721,243],{}," when the paths are unredirectable, and prefer ",[30,724,32],{}," everywhere else. The rule of thumb: if you can pass a path in, do; if the code insists on an absolute path you cannot change, fake the filesystem; if the code shells out, neither will help and the test belongs at the integration level.",[148,727,729,847],{"className":728},[151],[153,730,161,735,161,738,161,741,161,764,161,767,161,769,161,773,161,777,161,781,161,785,161,789,161,795,161,798,161,802,161,805,161,809,161,813,161,816,161,818,161,821,161,824,161,828,161,832,161,835,161,837,161,840,161,843],{"viewBox":731,"role":156,"ariaLabelledBy":732,"xmlns":160},"0 0 760 332",[733,734],"fspick-t","fspick-d",[163,736,737],{"id":733},"Choosing a filesystem isolation strategy",[167,739,740],{"id":734},"A decision diagram: if the path can be injected, use tmp_path; if the path is read from the environment, set the variable; if the path is hardcoded and absolute, use pyfakefs.",[451,742,453,743,453,748,453,754,453,759,161],{},[455,744,746],{"id":745,"viewBox":458,"refX":459,"refY":460,"markerWidth":461,"markerHeight":461,"orient":462},"fspick-a",[464,747],{"d":466,"fill":186},[455,749,751],{"id":750,"viewBox":458,"refX":459,"refY":460,"markerWidth":461,"markerHeight":461,"orient":462},"fspick-c",[464,752],{"d":466,"fill":753},"#e07a5f",[455,755,757],{"id":756,"viewBox":458,"refX":459,"refY":460,"markerWidth":461,"markerHeight":461,"orient":462},"fspick-s",[464,758],{"d":466,"fill":467},[455,760,762],{"id":761,"viewBox":458,"refX":459,"refY":460,"markerWidth":461,"markerHeight":461,"orient":462},"fspick-g",[464,763],{"d":466,"fill":194},[171,765],{"x":173,"y":173,"width":174,"height":766,"rx":176,"fill":177},"332",[179,768,737],{"x":181,"y":182,"textAnchor":183,"fontSize":184,"fontWeight":185,"fill":186},[770,771],"polygon",{"points":772,"fill":213,"stroke":186,"strokeWidth":479},"380,50 590,108 380,166 170,108",[179,774,776],{"x":181,"y":775,"textAnchor":183,"fontSize":200,"fontWeight":185,"fill":186},"104","Can you control the",[179,778,780],{"x":181,"y":779,"textAnchor":183,"fontSize":200,"fontWeight":185,"fill":186},"121","path the code uses?",[464,782],{"d":783,"fill":262,"stroke":467,"strokeWidth":479,"markerEnd":784},"M380 166 L380 192 L133 192 L133 206","url(#fspick-s)",[179,786,788],{"x":787,"y":242,"textAnchor":183,"fontSize":489,"fontWeight":185,"fill":224},"133","yes, inject it",[171,790],{"x":189,"y":791,"width":792,"height":793,"rx":200,"fill":177,"stroke":467,"strokeWidth":794},"214","217","84","2",[179,796,32],{"x":787,"y":797,"textAnchor":183,"fontSize":484,"fontWeight":185,"fill":186},"245",[179,799,801],{"x":787,"y":800,"textAnchor":183,"fontSize":489,"fill":186},"262","real files, real semantics",[179,803,561],{"x":787,"y":804,"textAnchor":183,"fontSize":489,"fill":186},"277",[464,806],{"d":807,"fill":262,"stroke":194,"strokeWidth":479,"markerEnd":808},"M380 166 L380 192 L380 192 L380 206","url(#fspick-g)",[179,810,812],{"x":181,"y":242,"textAnchor":183,"fontSize":489,"fontWeight":185,"fill":811},"#8a5a00","only via the env",[171,814],{"x":815,"y":791,"width":792,"height":793,"rx":200,"fill":177,"stroke":194,"strokeWidth":794},"271",[179,817,359],{"x":181,"y":797,"textAnchor":183,"fontSize":484,"fontWeight":185,"fill":186},[179,819,820],{"x":181,"y":800,"textAnchor":183,"fontSize":489,"fill":186},"point it at tmp_path",[179,822,823],{"x":181,"y":804,"textAnchor":183,"fontSize":489,"fill":186},"restored at teardown",[464,825],{"d":826,"fill":262,"stroke":753,"strokeWidth":479,"markerEnd":827},"M380 166 L380 192 L627 192 L627 206","url(#fspick-c)",[179,829,831],{"x":830,"y":242,"textAnchor":183,"fontSize":489,"fontWeight":185,"fill":261},"627","no, it is hardcoded",[171,833],{"x":834,"y":791,"width":792,"height":793,"rx":200,"fill":177,"stroke":753,"strokeWidth":794},"519",[179,836,243],{"x":830,"y":797,"textAnchor":183,"fontSize":484,"fontWeight":185,"fill":186},[179,838,839],{"x":830,"y":800,"textAnchor":183,"fontSize":489,"fill":186},"whole layer replaced",[179,841,842],{"x":830,"y":804,"textAnchor":183,"fontSize":489,"fill":186},"watch native code",[179,844,846],{"x":181,"y":477,"textAnchor":183,"fontSize":489,"fontStyle":845,"fill":186},"italic","Subprocesses see the real filesystem regardless of which branch you take.",[271,848,849],{},"Prefer the leftmost branch that works: each step right replaces more of the real system with a simulation.",[14,851,853],{"id":852},"environment-isolation-beyond-variables","Environment isolation beyond variables",[10,855,856,859],{},[30,857,858],{},"os.environ"," is the obvious process-global state, but it is not the only one a test can dirty. Four others leak just as easily and are worth handling in the same autouse fixture.",[10,861,862,865,866,871,872,875,876,879,880,883,884,887,888,891],{},[25,863,864],{},"The working directory",", as above. ",[25,867,868],{},[30,869,870],{},"sys.path",", which a test that inserts a directory must remove — ",[30,873,874],{},"monkeypatch.syspath_prepend"," does both. ",[25,877,878],{},"Locale and timezone",", which change number and date formatting process-wide; ",[30,881,882],{},"TZ"," in particular needs ",[30,885,886],{},"time.tzset()"," after the change on POSIX. And ",[25,889,890],{},"umask",", which affects the permissions of every file created afterwards and has no restore helper at all.",[75,893,895],{"className":77,"code":894,"language":79,"meta":80,"style":80},"import os\nimport pytest\n\n@pytest.fixture(autouse=True)\ndef _clean_process_state(monkeypatch, tmp_path):\n    monkeypatch.setenv(\"TZ\", \"UTC\")\n    if hasattr(os, \"tzset\"):\n        os.tzset()                              # C-level clocks read TZ once\n    monkeypatch.setenv(\"LC_ALL\", \"C.UTF-8\")     # stable formatting and sorting\n    monkeypatch.chdir(tmp_path)                 # never the repository root\n    yield\n",[30,896,897,902,907,911,916,921,926,931,936,941,946],{"__ignoreMap":80},[84,898,899],{"class":86,"line":87},[84,900,901],{},"import os\n",[84,903,904],{"class":86,"line":93},[84,905,906],{},"import pytest\n",[84,908,909],{"class":86,"line":99},[84,910,109],{"emptyLinePlaceholder":108},[84,912,913],{"class":86,"line":105},[84,914,915],{},"@pytest.fixture(autouse=True)\n",[84,917,918],{"class":86,"line":112},[84,919,920],{},"def _clean_process_state(monkeypatch, tmp_path):\n",[84,922,923],{"class":86,"line":118},[84,924,925],{},"    monkeypatch.setenv(\"TZ\", \"UTC\")\n",[84,927,928],{"class":86,"line":123},[84,929,930],{},"    if hasattr(os, \"tzset\"):\n",[84,932,933],{"class":86,"line":332},[84,934,935],{},"        os.tzset()                              # C-level clocks read TZ once\n",[84,937,938],{"class":86,"line":338},[84,939,940],{},"    monkeypatch.setenv(\"LC_ALL\", \"C.UTF-8\")     # stable formatting and sorting\n",[84,942,943],{"class":86,"line":343},[84,944,945],{},"    monkeypatch.chdir(tmp_path)                 # never the repository root\n",[84,947,949],{"class":86,"line":948},11,[84,950,951],{},"    yield\n",[10,953,954],{},"Making the working directory a fresh temporary path for every test is the single highest-value line in that fixture. It turns \"the test wrote a file into the repository\" from an invisible side effect into an impossibility, and it exposes code that silently depended on being run from the project root.",[14,956,958],{"id":957},"testing-code-that-shells-out","Testing code that shells out",[10,960,961,962,964],{},"Subprocesses sit outside every isolation mechanism on this page. A child process gets a copy of the environment at spawn time, sees the real filesystem regardless of ",[30,963,243],{},", and inherits the working directory — so a test that patches all three and then shells out is testing something quite different from what it configured.",[10,966,967],{},"Three techniques cover the realistic cases.",[10,969,970,352,973,976],{},[25,971,972],{},"Pass the environment explicitly.",[30,974,975],{},"subprocess.run(..., env={...})"," replaces inheritance with an explicit mapping, which is both more testable and safer: the child receives exactly what you listed and nothing from the developer's shell.",[75,978,980],{"className":77,"code":979,"language":79,"meta":80,"style":80},"import subprocess\n\ndef run_migration(dsn: str, *, env: dict[str, str] | None = None) -> None:\n    subprocess.run(\n        [\"alembic\", \"upgrade\", \"head\"],\n        env={\"DATABASE_URL\": dsn, \"PATH\": \"\u002Fusr\u002Fbin:\u002Fbin\", **(env or {})},\n        check=True,\n    )\n",[30,981,982,987,991,996,1001,1006,1011,1016],{"__ignoreMap":80},[84,983,984],{"class":86,"line":87},[84,985,986],{},"import subprocess\n",[84,988,989],{"class":86,"line":93},[84,990,109],{"emptyLinePlaceholder":108},[84,992,993],{"class":86,"line":99},[84,994,995],{},"def run_migration(dsn: str, *, env: dict[str, str] | None = None) -> None:\n",[84,997,998],{"class":86,"line":105},[84,999,1000],{},"    subprocess.run(\n",[84,1002,1003],{"class":86,"line":112},[84,1004,1005],{},"        [\"alembic\", \"upgrade\", \"head\"],\n",[84,1007,1008],{"class":86,"line":118},[84,1009,1010],{},"        env={\"DATABASE_URL\": dsn, \"PATH\": \"\u002Fusr\u002Fbin:\u002Fbin\", **(env or {})},\n",[84,1012,1013],{"class":86,"line":123},[84,1014,1015],{},"        check=True,\n",[84,1017,1018],{"class":86,"line":332},[84,1019,1020],{},"    )\n",[10,1022,1023,1026,1027,1029,1030,1032],{},[25,1024,1025],{},"Give the child a real directory."," Where the subprocess reads or writes files, ",[30,1028,32],{}," works normally — the child sees the same real filesystem, and the temporary directory is a real path. This is one place where ",[30,1031,243],{}," actively hurts: the parent sees the fake tree and the child does not, so the two disagree about whether a file exists.",[10,1034,1035,1038,1039,1043],{},[25,1036,1037],{},"Fake the boundary, not the process."," For a test about how your code ",[1040,1041,1042],"em",{},"reacts"," to a subprocess result, patch the runner rather than launching anything:",[75,1045,1047],{"className":77,"code":1046,"language":79,"meta":80,"style":80},"from unittest.mock import patch\nimport subprocess\n\ndef test_reports_migration_failure():\n    failure = subprocess.CalledProcessError(returncode=1, cmd=[\"alembic\"])\n    with patch(\"myapp.migrate.subprocess.run\", side_effect=failure):\n        assert run_and_report() == \"migration failed\"\n",[30,1048,1049,1054,1058,1062,1067,1072,1077],{"__ignoreMap":80},[84,1050,1051],{"class":86,"line":87},[84,1052,1053],{},"from unittest.mock import patch\n",[84,1055,1056],{"class":86,"line":93},[84,1057,986],{},[84,1059,1060],{"class":86,"line":99},[84,1061,109],{"emptyLinePlaceholder":108},[84,1063,1064],{"class":86,"line":105},[84,1065,1066],{},"def test_reports_migration_failure():\n",[84,1068,1069],{"class":86,"line":112},[84,1070,1071],{},"    failure = subprocess.CalledProcessError(returncode=1, cmd=[\"alembic\"])\n",[84,1073,1074],{"class":86,"line":118},[84,1075,1076],{},"    with patch(\"myapp.migrate.subprocess.run\", side_effect=failure):\n",[84,1078,1079],{"class":86,"line":123},[84,1080,1081],{},"        assert run_and_report() == \"migration failed\"\n",[10,1083,1084],{},"Reserve real subprocess execution for tests that are genuinely about the integration, mark them, and keep them out of the fast suite — a real process launch is milliseconds at best and seconds when it starts an interpreter.",[148,1086,1088,1161],{"className":1087},[151],[153,1089,161,1093,161,1096,161,1099,161,1101,161,1103,161,1105,161,1107,161,1110,161,1113,161,1115,161,1118,161,1121,161,1124,161,1126,161,1129,161,1132,161,1135,161,1137,161,1140,161,1143,161,1146,161,1148,161,1151,161,1154,161,1157,161,1159],{"viewBox":155,"role":156,"ariaLabelledBy":1090,"xmlns":160},[1091,1092],"subprocenv-t","subprocenv-d",[163,1094,1095],{"id":1091},"What a subprocess inherits and what it does not",[167,1097,1098],{"id":1092},"A table of four kinds of state - environment variables, the working directory, a pyfakefs filesystem, and monkeypatched module attributes - stating whether each reaches a spawned child process.",[171,1100],{"x":173,"y":173,"width":174,"height":175,"rx":176,"fill":177},[179,1102,1095],{"x":181,"y":182,"textAnchor":183,"fontSize":184,"fontWeight":185,"fill":186},[171,1104],{"x":189,"y":190,"width":191,"height":192,"rx":193,"fill":194,"stroke":186,"strokeWidth":195},[179,1106,201],{"x":198,"y":199,"fontSize":200,"fontWeight":185,"fill":186},[179,1108,1109],{"x":204,"y":199,"textAnchor":183,"fontSize":200,"fontWeight":185,"fill":186},"Reaches the child?",[179,1111,1112],{"x":208,"y":199,"textAnchor":183,"fontSize":200,"fontWeight":185,"fill":186},"Implication",[171,1114],{"x":189,"y":212,"width":191,"height":192,"fill":213,"stroke":186,"strokeWidth":214},[179,1116,1117],{"x":198,"y":217,"fontSize":218,"fill":186},"environment variables",[179,1119,1120],{"x":204,"y":217,"textAnchor":183,"fontSize":218,"fill":224},"yes, copied at spawn",[179,1122,1123],{"x":208,"y":217,"textAnchor":183,"fontSize":218,"fill":186},"set before launching",[171,1125],{"x":189,"y":228,"width":191,"height":192,"fill":177,"stroke":186,"strokeWidth":214},[179,1127,1128],{"x":198,"y":231,"fontSize":218,"fill":186},"working directory",[179,1130,1131],{"x":204,"y":231,"textAnchor":183,"fontSize":218,"fill":224},"yes, inherited",[179,1133,1134],{"x":208,"y":231,"textAnchor":183,"fontSize":218,"fill":186},"chdir applies",[171,1136],{"x":189,"y":239,"width":191,"height":192,"fill":213,"stroke":186,"strokeWidth":214},[179,1138,1139],{"x":198,"y":242,"fontSize":218,"fill":186},"pyfakefs filesystem",[179,1141,1142],{"x":204,"y":242,"textAnchor":183,"fontSize":218,"fill":261},"no, real disk",[179,1144,1145],{"x":208,"y":242,"textAnchor":183,"fontSize":218,"fill":186},"use tmp_path instead",[171,1147],{"x":189,"y":252,"width":191,"height":192,"fill":177,"stroke":186,"strokeWidth":214},[179,1149,1150],{"x":198,"y":255,"fontSize":218,"fill":186},"monkeypatched attributes",[179,1152,1153],{"x":204,"y":255,"textAnchor":183,"fontSize":218,"fill":261},"no, separate process",[179,1155,1156],{"x":208,"y":255,"textAnchor":183,"fontSize":218,"fill":186},"pass values as arguments",[86,1158],{"x1":265,"y1":190,"x2":265,"y2":266,"stroke":186,"strokeWidth":214},[86,1160],{"x1":269,"y1":190,"x2":269,"y2":266,"stroke":186,"strokeWidth":214},[271,1162,1163],{},"The last two rows are why a test that patches Python objects and then shells out is testing two different worlds at once.",[14,1165,1167],{"id":1166},"verification","Verification",[10,1169,1170],{},"Two checks confirm the isolation is real rather than assumed.",[75,1172,1176],{"className":1173,"code":1174,"language":1175,"meta":80,"style":80},"language-console shiki shiki-themes github-light github-dark","$ pytest -p no:randomly tests\u002F -q          # baseline\n$ pytest -p randomly tests\u002F -q             # shuffled order: state leaks now fail\n$ git status --porcelain                   # must be empty after a test run\n","console",[30,1177,1178,1183,1188],{"__ignoreMap":80},[84,1179,1180],{"class":86,"line":87},[84,1181,1182],{},"$ pytest -p no:randomly tests\u002F -q          # baseline\n",[84,1184,1185],{"class":86,"line":93},[84,1186,1187],{},"$ pytest -p randomly tests\u002F -q             # shuffled order: state leaks now fail\n",[84,1189,1190],{"class":86,"line":99},[84,1191,1192],{},"$ git status --porcelain                   # must be empty after a test run\n",[10,1194,1195,1196,1199,1200,41],{},"Running the suite in a randomised order is the standard way to surface order-dependence, and filesystem leakage is one of its most common causes. ",[30,1197,1198],{},"git status"," after a run catches the other half: a test that wrote into the working tree rather than into ",[30,1201,32],{},[10,1203,1204],{},"For environment leakage specifically, a teardown assertion is cheaper than debugging the consequence:",[75,1206,1208],{"className":77,"code":1207,"language":79,"meta":80,"style":80},"import os\nimport pytest\n\n@pytest.fixture(autouse=True)\ndef _no_env_leak():\n    before = dict(os.environ)\n    yield\n    assert dict(os.environ) == before, \"test modified os.environ without monkeypatch\"\n",[30,1209,1210,1214,1218,1222,1226,1231,1236,1240],{"__ignoreMap":80},[84,1211,1212],{"class":86,"line":87},[84,1213,901],{},[84,1215,1216],{"class":86,"line":93},[84,1217,906],{},[84,1219,1220],{"class":86,"line":99},[84,1221,109],{"emptyLinePlaceholder":108},[84,1223,1224],{"class":86,"line":105},[84,1225,915],{},[84,1227,1228],{"class":86,"line":112},[84,1229,1230],{},"def _no_env_leak():\n",[84,1232,1233],{"class":86,"line":118},[84,1234,1235],{},"    before = dict(os.environ)\n",[84,1237,1238],{"class":86,"line":123},[84,1239,951],{},[84,1241,1242],{"class":86,"line":332},[84,1243,1244],{},"    assert dict(os.environ) == before, \"test modified os.environ without monkeypatch\"\n",[14,1246,1248],{"id":1247},"fixtures-that-build-realistic-trees","Fixtures that build realistic trees",[10,1250,1251],{},"Once several tests need the same directory structure, the setup wants to be a fixture — and a factory fixture is more useful than a fixed one, because tests differ in the details that matter to them.",[75,1253,1255],{"className":77,"code":1254,"language":79,"meta":80,"style":80},"import pytest\nfrom pathlib import Path\n\n@pytest.fixture\ndef project_tree(tmp_path):\n    \"\"\"Return a factory that builds a project skeleton with the given files.\"\"\"\n    def build(files: dict[str, str], root: str = \"proj\") -> Path:\n        base = tmp_path \u002F root\n        for relative, content in files.items():\n            path = base \u002F relative\n            path.parent.mkdir(parents=True, exist_ok=True)\n            path.write_text(content, encoding=\"utf-8\")\n        return base\n    return build\n\ndef test_finds_nested_configs(project_tree):\n    root = project_tree({\n        \"pyproject.toml\": \"[tool.app]\\n\",\n        \"services\u002Fapi\u002Fapp.toml\": \"port = 8080\\n\",\n        \"services\u002Fapi\u002F.hidden.toml\": \"ignored = true\\n\",\n    })\n\n    assert [p.name for p in discover(root)] == [\"app.toml\", \"pyproject.toml\"]\n",[30,1256,1257,1261,1266,1270,1275,1280,1285,1290,1295,1300,1305,1310,1316,1322,1328,1333,1339,1345,1351,1357,1363,1369,1374],{"__ignoreMap":80},[84,1258,1259],{"class":86,"line":87},[84,1260,906],{},[84,1262,1263],{"class":86,"line":93},[84,1264,1265],{},"from pathlib import Path\n",[84,1267,1268],{"class":86,"line":99},[84,1269,109],{"emptyLinePlaceholder":108},[84,1271,1272],{"class":86,"line":105},[84,1273,1274],{},"@pytest.fixture\n",[84,1276,1277],{"class":86,"line":112},[84,1278,1279],{},"def project_tree(tmp_path):\n",[84,1281,1282],{"class":86,"line":118},[84,1283,1284],{},"    \"\"\"Return a factory that builds a project skeleton with the given files.\"\"\"\n",[84,1286,1287],{"class":86,"line":123},[84,1288,1289],{},"    def build(files: dict[str, str], root: str = \"proj\") -> Path:\n",[84,1291,1292],{"class":86,"line":332},[84,1293,1294],{},"        base = tmp_path \u002F root\n",[84,1296,1297],{"class":86,"line":338},[84,1298,1299],{},"        for relative, content in files.items():\n",[84,1301,1302],{"class":86,"line":343},[84,1303,1304],{},"            path = base \u002F relative\n",[84,1306,1307],{"class":86,"line":948},[84,1308,1309],{},"            path.parent.mkdir(parents=True, exist_ok=True)\n",[84,1311,1313],{"class":86,"line":1312},12,[84,1314,1315],{},"            path.write_text(content, encoding=\"utf-8\")\n",[84,1317,1319],{"class":86,"line":1318},13,[84,1320,1321],{},"        return base\n",[84,1323,1325],{"class":86,"line":1324},14,[84,1326,1327],{},"    return build\n",[84,1329,1331],{"class":86,"line":1330},15,[84,1332,109],{"emptyLinePlaceholder":108},[84,1334,1336],{"class":86,"line":1335},16,[84,1337,1338],{},"def test_finds_nested_configs(project_tree):\n",[84,1340,1342],{"class":86,"line":1341},17,[84,1343,1344],{},"    root = project_tree({\n",[84,1346,1348],{"class":86,"line":1347},18,[84,1349,1350],{},"        \"pyproject.toml\": \"[tool.app]\\n\",\n",[84,1352,1354],{"class":86,"line":1353},19,[84,1355,1356],{},"        \"services\u002Fapi\u002Fapp.toml\": \"port = 8080\\n\",\n",[84,1358,1360],{"class":86,"line":1359},20,[84,1361,1362],{},"        \"services\u002Fapi\u002F.hidden.toml\": \"ignored = true\\n\",\n",[84,1364,1366],{"class":86,"line":1365},21,[84,1367,1368],{},"    })\n",[84,1370,1372],{"class":86,"line":1371},22,[84,1373,109],{"emptyLinePlaceholder":108},[84,1375,1377],{"class":86,"line":1376},23,[84,1378,1379],{},"    assert [p.name for p in discover(root)] == [\"app.toml\", \"pyproject.toml\"]\n",[10,1381,1382],{},"The factory form keeps the fixture reusable while letting each test declare exactly the tree its assertion depends on — which also makes the test readable, because the input is visible next to the expectation rather than three files away.",[10,1384,1385,1386,1389,1390,1392,1393,1396],{},"Two refinements are worth adding as the suite grows. Return ",[30,1387,1388],{},"Path"," objects rather than strings, so tests compose them with ",[30,1391,590],{}," and never build platform-specific separators. And keep the content inline unless it is genuinely large; a test that references ",[30,1394,1395],{},"fixtures\u002Fbig.json"," forces the reader to open another file to understand the assertion, and a test that inlines three lines of TOML does not.",[10,1398,1399,1400,37,1403,1406,1407,1410],{},"For binary content and permissions, the same factory extends naturally with ",[30,1401,1402],{},"write_bytes",[30,1404,1405],{},"chmod",". What it should not do is grow conditionals: a factory with a ",[30,1408,1409],{},"mode=\"broken\""," parameter is two fixtures wearing one name, and splitting them keeps each test's intent legible.",[14,1412,1414],{"id":1413},"troubleshooting","Troubleshooting",[1416,1417,1418,1434],"table",{},[1419,1420,1421],"thead",{},[1422,1423,1424,1428,1431],"tr",{},[1425,1426,1427],"th",{},"Symptom",[1425,1429,1430],{},"Root cause",[1425,1432,1433],{},"Fix",[1435,1436,1437,1452,1469,1483,1497],"tbody",{},[1422,1438,1439,1443,1446],{},[1440,1441,1442],"td",{},"Test passes alone, fails in the suite",[1440,1444,1445],{},"env var or cwd left over from an earlier test",[1440,1447,1448,1449,1451],{},"use ",[30,1450,40],{},", add the leak assertion above",[1422,1453,1454,1460,1463],{},[1440,1455,1456,1459],{},[30,1457,1458],{},"FileNotFoundError"," only in CI",[1440,1461,1462],{},"relative path resolved against a different cwd",[1440,1464,1465,1468],{},[30,1466,1467],{},"monkeypatch.chdir(tmp_path)",", or inject the base path",[1422,1470,1471,1474,1477],{},[1440,1472,1473],{},"Files appear in the repository after a run",[1440,1475,1476],{},"code wrote relative to the working directory",[1440,1478,1479,1480,1482],{},"make ",[30,1481,32],{}," the cwd for every test",[1422,1484,1485,1488,1494],{},[1440,1486,1487],{},"Permissions differ between machines",[1440,1489,1490,1491,1493],{},"umask or a ",[30,1492,1405],{}," that is not reset",[1440,1495,1496],{},"set umask explicitly in a fixture",[1422,1498,1499,1502,1505],{},[1440,1500,1501],{},"Timestamp assertions fail seasonally",[1440,1503,1504],{},"timezone read from the host, DST transition",[1440,1506,1507,1508,1511,1512],{},"pin ",[30,1509,1510],{},"TZ=UTC"," and call ",[30,1513,886],{},[14,1515,1517],{"id":1516},"an-isolation-fixture-worth-copying","An isolation fixture worth copying",[10,1519,1520],{},"Most of this guide collapses into one autouse fixture that every suite can adopt. It establishes a known process state, gives each test its own directory, and fails loudly when something escapes.",[75,1522,1524],{"className":77,"code":1523,"language":79,"meta":80,"style":80},"import os\nimport pytest\n\nBASELINE_ENV = {\"TZ\": \"UTC\", \"LC_ALL\": \"C.UTF-8\", \"PYTHONHASHSEED\": \"0\"}\nCLEARED_ENV = (\"AWS_PROFILE\", \"APP_DEBUG\", \"HTTP_PROXY\", \"HTTPS_PROXY\")\n\n@pytest.fixture(autouse=True)\ndef isolated_process_state(monkeypatch, tmp_path):\n    for name in CLEARED_ENV:\n        monkeypatch.delenv(name, raising=False)      # nothing inherited from the shell\n    for name, value in BASELINE_ENV.items():\n        monkeypatch.setenv(name, value)\n    if hasattr(os, \"tzset\"):\n        os.tzset()                                    # C-level clocks re-read TZ\n    monkeypatch.chdir(tmp_path)                       # never the repository root\n    before = dict(os.environ)\n    yield tmp_path\n    leaked = {k for k in set(before) | set(os.environ)\n              if before.get(k) != os.environ.get(k)}\n    assert not leaked, f\"environment leaked: {sorted(leaked)}\"\n",[30,1525,1526,1530,1534,1538,1543,1548,1552,1556,1561,1566,1571,1576,1581,1585,1590,1595,1599,1604,1609,1614],{"__ignoreMap":80},[84,1527,1528],{"class":86,"line":87},[84,1529,901],{},[84,1531,1532],{"class":86,"line":93},[84,1533,906],{},[84,1535,1536],{"class":86,"line":99},[84,1537,109],{"emptyLinePlaceholder":108},[84,1539,1540],{"class":86,"line":105},[84,1541,1542],{},"BASELINE_ENV = {\"TZ\": \"UTC\", \"LC_ALL\": \"C.UTF-8\", \"PYTHONHASHSEED\": \"0\"}\n",[84,1544,1545],{"class":86,"line":112},[84,1546,1547],{},"CLEARED_ENV = (\"AWS_PROFILE\", \"APP_DEBUG\", \"HTTP_PROXY\", \"HTTPS_PROXY\")\n",[84,1549,1550],{"class":86,"line":118},[84,1551,109],{"emptyLinePlaceholder":108},[84,1553,1554],{"class":86,"line":123},[84,1555,915],{},[84,1557,1558],{"class":86,"line":332},[84,1559,1560],{},"def isolated_process_state(monkeypatch, tmp_path):\n",[84,1562,1563],{"class":86,"line":338},[84,1564,1565],{},"    for name in CLEARED_ENV:\n",[84,1567,1568],{"class":86,"line":343},[84,1569,1570],{},"        monkeypatch.delenv(name, raising=False)      # nothing inherited from the shell\n",[84,1572,1573],{"class":86,"line":948},[84,1574,1575],{},"    for name, value in BASELINE_ENV.items():\n",[84,1577,1578],{"class":86,"line":1312},[84,1579,1580],{},"        monkeypatch.setenv(name, value)\n",[84,1582,1583],{"class":86,"line":1318},[84,1584,930],{},[84,1586,1587],{"class":86,"line":1324},[84,1588,1589],{},"        os.tzset()                                    # C-level clocks re-read TZ\n",[84,1591,1592],{"class":86,"line":1330},[84,1593,1594],{},"    monkeypatch.chdir(tmp_path)                       # never the repository root\n",[84,1596,1597],{"class":86,"line":1335},[84,1598,1235],{},[84,1600,1601],{"class":86,"line":1341},[84,1602,1603],{},"    yield tmp_path\n",[84,1605,1606],{"class":86,"line":1347},[84,1607,1608],{},"    leaked = {k for k in set(before) | set(os.environ)\n",[84,1610,1611],{"class":86,"line":1353},[84,1612,1613],{},"              if before.get(k) != os.environ.get(k)}\n",[84,1615,1616],{"class":86,"line":1359},[84,1617,1618],{},"    assert not leaked, f\"environment leaked: {sorted(leaked)}\"\n",[10,1620,1621,1622,1625,1626,1629,1630,1632,1633,1636],{},"Three properties make it worth adopting wholesale rather than piecemeal. It is ",[25,1623,1624],{},"complete",": environment, clock configuration and working directory are all covered, so no test needs to remember any of them. It is ",[25,1627,1628],{},"fail-loud",": the teardown assertion names the test that bypassed ",[30,1631,40],{}," instead of letting a later test suffer for it. And it is ",[25,1634,1635],{},"cheap",": a few dictionary operations and a directory creation per test, invisible against any real test's runtime.",[10,1638,1639,1640,1643,1644,1647,1648,1651,1652,1655],{},"Two adjustments are worth making per project. Extend ",[30,1641,1642],{},"CLEARED_ENV"," with the variables your developers actually export — an ",[30,1645,1646],{},"AWS_PROFILE"," or a ",[30,1649,1650],{},"DATABASE_URL"," in a shell profile is the classic source of \"passes for me\". And drop the ",[30,1653,1654],{},"chdir"," if a large legacy suite depends on running from the repository root, but treat that dependency as debt: it is exactly the coupling that makes a test write into the working tree.",[10,1657,1658,1659,37,1661,1663],{},"With this in place, the remaining filesystem work in a suite is choosing between ",[30,1660,32],{},[30,1662,243],{}," per test, which is the decision the rest of this guide covers.",[148,1665,1667,1731],{"className":1666},[151],[153,1668,161,1673,161,1676,161,1679,161,1681,161,1683,161,1686,161,1689,161,1692,161,1697,161,1700,161,1702,161,1706,161,1709,161,1711,161,1713,161,1716,161,1719,161,1722,161,1724,161,1728],{"viewBox":1669,"role":156,"ariaLabelledBy":1670,"xmlns":160},"0 0 760 320",[1671,1672],"isolationlayers-t","isolationlayers-d",[163,1674,1675],{"id":1671},"What the isolation fixture covers",[167,1677,1678],{"id":1672},"A stack of four process-state dimensions the autouse fixture handles: cleared inherited variables, a pinned baseline environment, a per-test working directory, and a teardown assertion that catches leaks.",[171,1680],{"x":173,"y":173,"width":174,"height":477,"rx":176,"fill":177},[179,1682,1675],{"x":181,"y":182,"textAnchor":183,"fontSize":184,"fontWeight":185,"fill":186},[171,1684],{"x":182,"y":1685,"width":185,"height":190,"rx":193,"fill":213,"stroke":753,"strokeWidth":479},"56",[171,1687],{"x":182,"y":1685,"width":1688,"height":190,"rx":501,"fill":753},"8",[179,1690,1691],{"x":190,"y":483,"fontSize":484,"fontWeight":185,"fill":186},"cleared inherited vars",[179,1693,1696],{"x":1694,"y":483,"textAnchor":1695,"fontSize":489,"fill":186},"714","end","nothing from the developer’s shell reaches the test",[171,1698],{"x":182,"y":1699,"width":185,"height":190,"rx":193,"fill":177,"stroke":194,"strokeWidth":479},"120",[171,1701],{"x":182,"y":1699,"width":1688,"height":190,"rx":501,"fill":194},[179,1703,1705],{"x":190,"y":1704,"fontSize":484,"fontWeight":185,"fill":186},"150","pinned baseline",[179,1707,1708],{"x":1694,"y":1704,"textAnchor":1695,"fontSize":489,"fill":186},"TZ, locale and hash seed identical everywhere",[171,1710],{"x":182,"y":511,"width":185,"height":190,"rx":193,"fill":213,"stroke":467,"strokeWidth":479},[171,1712],{"x":182,"y":511,"width":1688,"height":190,"rx":501,"fill":467},[179,1714,1715],{"x":190,"y":791,"fontSize":484,"fontWeight":185,"fill":186},"per-test working directory",[179,1717,1718],{"x":1694,"y":791,"textAnchor":1695,"fontSize":489,"fill":186},"relative writes cannot reach the repository",[171,1720],{"x":182,"y":1721,"width":185,"height":190,"rx":193,"fill":177,"stroke":186,"strokeWidth":479},"248",[171,1723],{"x":182,"y":1721,"width":1688,"height":190,"rx":501,"fill":186},[179,1725,1727],{"x":190,"y":1726,"fontSize":484,"fontWeight":185,"fill":186},"278","teardown assertion",[179,1729,1730],{"x":1694,"y":1726,"textAnchor":1695,"fontSize":489,"fill":186},"names the test that bypassed monkeypatch",[271,1732,1733],{},"Four lines of policy in one fixture replace a convention every test author would otherwise have to remember.",[14,1735,1737],{"id":1736},"frequently-asked-questions","Frequently Asked Questions",[10,1739,1740,1743,1744,1747,1748,1750],{},[25,1741,1742],{},"Should I mock open() or use a real temporary directory?","\nUse a real temporary directory. Real files exercise the encoding, permission and path handling that a mocked ",[30,1745,1746],{},"open()"," skips entirely, and ",[30,1749,32],{}," makes them free to create and automatically cleaned up.",[10,1752,1753,1756,1757,1759,1760,1762],{},[25,1754,1755],{},"Why do environment variables leak between tests?","\nBecause ",[30,1758,858],{}," is process-global and a plain assignment is never undone. ",[30,1761,359],{}," records the previous value and restores it at teardown, including when the test fails.",[10,1764,1765,1768,1769,1772],{},[25,1766,1767],{},"When is pyfakefs the right tool?","\nWhen the code under test walks or writes across absolute paths you cannot redirect — a config loader reading ",[30,1770,1771],{},"\u002Fetc",", or code that hardcodes a root. pyfakefs replaces the whole filesystem layer so those paths resolve into an in-memory tree.",[10,1774,1775,1778,1779,1781,1782,1785],{},[25,1776,1777],{},"How do I test code that depends on the current working directory?","\nChange it with ",[30,1780,1467],{},", which restores the original directory at teardown. Better still, refactor the code to take a base path, so the dependency is visible and injectable.\nA last word on scope. Every technique here isolates ",[1040,1783,1784],{},"process"," state, and a test that also touches a database, a message broker or a shared cache needs the same discipline applied to those — a per-worker database name, a per-test transaction rollback, a namespaced key prefix. The principle transfers directly: state whose lifetime is longer than the test is state one test can leak into another, and the fix is always either a fresh instance or an explicit reset.",[1787,1788,1790],"h3",{"id":1789},"a-short-adoption-order","A short adoption order",[10,1792,1793],{},"Retrofitting isolation into an existing suite works best in this order, because each step makes the next one's failures interpretable.",[10,1795,1796],{},"First, add the teardown assertion for environment leakage without changing anything else. It will fail, and the failures are the list of tests to fix — a concrete backlog rather than a vague concern.",[10,1798,1799,1800,1803,1804,1806],{},"Second, replace ",[30,1801,1802],{},"tempfile"," with ",[30,1805,32],{}," everywhere. This is mechanical, cannot break behaviour, and removes the cleanup code that was silently skipped on failure.",[10,1808,1809,1810,1812],{},"Third, make ",[30,1811,32],{}," the working directory for every test. This will break the tests that depended on running from the repository root, and each break is a genuine coupling worth removing.",[10,1814,1815],{},"Fourth, pin the baseline environment. By now the suite is isolated enough that a timezone or locale change produces a comprehensible failure rather than a cascade.",[10,1817,1818,1819,1821],{},"Only then consider ",[30,1820,243],{},", and only for the paths that cannot be redirected. Teams that start there usually end up with a fake filesystem wrapped around code that never needed one, and with the subprocess and native-extension caveats permanently in their way.",[14,1823,1825],{"id":1824},"related-guides","Related guides",[19,1827,1828,1835,1842,1852],{},[22,1829,1830,1834],{},[52,1831,1833],{"href":1832},"\u002Fadvanced-mocking-test-doubles-in-python\u002Ffaking-the-filesystem-and-environment\u002Fusing-tmp-path-instead-of-tempfile-in-tests\u002F","Using tmp_path instead of tempfile in tests"," — the fixture, its factory sibling, and the cleanup semantics.",[22,1836,1837,1841],{},[52,1838,1840],{"href":1839},"\u002Fadvanced-mocking-test-doubles-in-python\u002Ffaking-the-filesystem-and-environment\u002Fpatching-environment-variables-with-monkeypatch-setenv\u002F","Patching environment variables with monkeypatch.setenv"," — env isolation in detail, including config caches read at import.",[22,1843,1844,1848,1849,1851],{},[52,1845,1847],{"href":1846},"\u002Fadvanced-mocking-test-doubles-in-python\u002Fpatching-strategies-for-complex-codebases\u002Fpatching-builtins-and-sys-modules-safely\u002F","Patching builtins and sys.modules safely"," — why patching ",[30,1850,69],{}," is the wrong tool for this job.",[22,1853,1854,1858],{},[52,1855,1857],{"href":1856},"\u002Fadvanced-mocking-test-doubles-in-python\u002Fcontrolling-time-and-randomness-in-tests\u002F","Controlling time and randomness in tests"," — the other two sources of environment-dependent flakiness.",[10,1860,1861,1862],{},"← Back to ",[52,1863,1865],{"href":1864},"\u002Fadvanced-mocking-test-doubles-in-python\u002F","Advanced Mocking & Test Doubles in Python",[1867,1868,1869],"style",{},"html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}",{"title":80,"searchDepth":93,"depth":93,"links":1871},[1872,1873,1874,1875,1876,1877,1878,1879,1880,1881,1882,1885],{"id":16,"depth":93,"text":17},{"id":58,"depth":93,"text":59},{"id":276,"depth":93,"text":277},{"id":575,"depth":93,"text":576},{"id":852,"depth":93,"text":853},{"id":957,"depth":93,"text":958},{"id":1166,"depth":93,"text":1167},{"id":1247,"depth":93,"text":1248},{"id":1413,"depth":93,"text":1414},{"id":1516,"depth":93,"text":1517},{"id":1736,"depth":93,"text":1737,"children":1883},[1884],{"id":1789,"depth":99,"text":1790},{"id":1824,"depth":93,"text":1825},"Test filesystem and environment-dependent Python code: tmp_path over tempfile, monkeypatch for env vars and cwd, pyfakefs for whole-filesystem isolation, and the trade-offs.","md",{"slug":1889,"type":1890,"breadcrumb":1891,"datePublished":1892,"dateModified":1892,"faq":1893,"howto":1902},"faking-the-filesystem-and-environment","topic","Filesystem & Environment","2026-08-01",[1894,1896,1898,1900],{"q":1742,"a":1895},"Use a real temporary directory. Real files exercise the encoding, permission and path handling that a mocked open() skips entirely, and tmp_path makes them free to create and automatically cleaned up.",{"q":1755,"a":1897},"Because os.environ is process-global and a plain assignment is never undone. monkeypatch.setenv records the previous value and restores it at teardown, including when the test fails.",{"q":1767,"a":1899},"When the code under test walks or writes across absolute paths you cannot redirect — a config loader reading \u002Fetc, or code that hardcodes a root. pyfakefs replaces the whole filesystem layer so those paths resolve into an in-memory tree.",{"q":1777,"a":1901},"Change it with monkeypatch.chdir(tmp_path), which restores the original directory at teardown. Better still, refactor the code to take a base path, so the dependency is visible and injectable.",{"name":1903,"description":1904,"steps":1905},"How to fake the filesystem and environment in Python tests","Isolate filesystem and environment dependencies with real temporary files and restorable process state.",[1906,1909,1912,1915],{"name":1907,"text":1908},"Replace tempfile with tmp_path","Use the pytest fixture so each test gets a unique directory that is cleaned up automatically.",{"name":1910,"text":1911},"Set environment variables with monkeypatch","Use setenv and delenv so every change is reverted at teardown, including on failure.",{"name":1913,"text":1914},"Redirect the working directory explicitly","Use monkeypatch.chdir rather than os.chdir so the original directory is restored.",{"name":1916,"text":1917},"Reach for pyfakefs only for unredirectable paths","Replace the whole filesystem layer when the code hardcodes absolute paths.","\u002Fadvanced-mocking-test-doubles-in-python\u002Ffaking-the-filesystem-and-environment",{"title":5,"description":1886},"advanced-mocking-test-doubles-in-python\u002Ffaking-the-filesystem-and-environment\u002Findex","tTv8DTVSAa8rgSYtFckxZ6LfweP0_jDLJlhrqoSDCK4",1785613403105]