Isolating Filesystem Side Effects in Pytest with Function-Scoped tmp_path Fixtures
Learn how to isolate filesystem side effects in pytest using function-scoped tmp_path fixtures, explicit teardown, and practical recovery options for failing tests.
06 Jan 2026, 05:13 UTC

The problem with shared filesystem state
Tests that write to fixed paths such as /tmp/test-data or the repository root eventually collide with parallel runs, leave residue in version control, or fail on CI runners that restrict writes. A function-scoped pytest fixture built on the builtin tmp_path gives every test a unique pathlib.Path workspace and a deterministic place to clean up.
Desired outcome
- Every test that touches the filesystem receives a unique
pathlib.Pathworkspace. - Test code writes only inside that workspace.
- After the run, the repository and shared temp locations contain no new artifacts.
Prerequisites
- A virtual environment with pytest installed and selected.
- An importable package or module under test.
- A baseline
pytest -qrun that passes without filesystem fixtures.
Run pytest --version from your project root to confirm the installed CLI supports the options used below. The fixture names and flags described here are stable in current pytest 7.x and 8.x releases. Function scope means the fixture is created and destroyed for each individual test function, preventing state from leaking between tests.
Procedure: define the fixture
Create or edit conftest.py at the repository root or inside your tests/ directory. This special pytest configuration file holds fixtures shared across multiple test modules. Define a function-scoped fixture that requests the builtin tmp_path, prepares subdirectories, yields the path, and then performs explicit cleanup.
# tests/conftest.py
import shutil
from pathlib import Path
import pytest
@pytest.fixture
def isolated_workspace(tmp_path):
"""Provide a unique filesystem workspace and clean up side effects."""
workspace = tmp_path / "project"
workspace.mkdir()
(workspace / "input").mkdir()
(workspace / "output").mkdir()
# Example resource that must be closed deterministically
log_handle = (workspace / "run.log").open("w")
yield workspace
# Explicit teardown: close handles, then remove generated caches
log_handle.close()
cache = workspace / ".cache"
if cache.exists():
shutil.rmtree(cache, ignore_errors=True)
The builtin tmp_path is itself a unique temporary directory for each test. The fixture above adds project-specific structure and guarantees that file handles and cache directories are dealt with before the next test starts.
Procedure: use the fixture in tests
Request the fixture by naming it as a test parameter. Construct files with Path.write_text or shutil operations, pass the resulting paths to the code under test, and avoid fixed filenames that could collide in parallel runs.
# tests/test_processor.py
from pathlib import Path
def test_report_generation(isolated_workspace):
input_file = isolated_workspace / "input" / "data.txt"
input_file.write_text("sample")
result = my_package.process(input_file, isolated_workspace / "output")
assert (isolated_workspace / "output" / "report.txt").exists()
Replace my_package.process with the actual callable under test. The key constraint is that all paths originate from the yielded workspace.
Expected checks
Run the suite and verify isolation with three checks.
-
From the project root, run
pytest -q tests/. All tests should pass, and no files should appear in the repository. -
Run
pytest --setup-show tests/test_processor.py::test_report_generation. The output should showSETUPandTEARDOWNlines bracketing the selected test, confirming the fixture ran at function scope. -
Run
git status --porcelain. The output must be empty. Any untracked files indicate a test wrote outside its workspace.
Recovery options when a test fails
If a test fails and you need to inspect the workspace, rerun with a fixed base directory so artifacts are collected in one place:
pytest --basetemp=.pytest-tmp tests/test_processor.py::test_report_generation
Inspect only the failing test's directory under .pytest-tmp. When teardown must run even after an error, wrap the fixture body in try/finally or call request.addfinalizer:
@pytest.fixture
def isolated_workspace(tmp_path, request):
workspace = tmp_path / "project"
workspace.mkdir()
def cleanup():
# Remove caches or close handles here
pass
request.addfinalizer(cleanup)
return workspace
If you use pytest-xdist, keep filesystem fixtures function-scoped for mutable state. Session or module fixtures can leak directories between tests unless they are recreated or explicitly reset in a finalizer.
Limitations and practical cautions
- Retention policies vary. Do not assume pytest retains or deletes temporary directories automatically across versions or plugins. The explicit teardown in the fixture handles resources your code creates, but pytest's own base temp retention is configurable.
- Windows file locking. Close file handles before assertions and teardown. On Windows, open handles can make otherwise correct cleanup fail.
- Do not write to the repository, home directory, or network paths. CI runners often restrict writes, and such writes make tests non-hermetic.
- Verify your CLI. Option names and defaults can differ across pytest versions and plugins. Run
pytest --helpto confirm flags such as--basetempand--setup-showin your environment.
A practical way to check the result
After adopting the fixture, intentionally fail one test, rerun with pytest --basetemp=.pytest-tmp, and inspect that test's directory. The workspace should contain the expected inputs and logs, and git status --porcelain should remain clean. If residue appears, trace the write back to a path that did not originate from the fixture.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.