Isolation & Contracts

Faking a Whole Filesystem with pyfakefs

Code that reads /etc/myapp/config.yaml, writes to ~/.cache/myapp, or walks a directory tree expecting a particular layout is awkward to test against the real filesystem. Absolute paths cannot be pointed at a temporary directory without changing the code, creating /etc/... in a test needs privileges nobody should grant, and leftover files from a failed run leak into the next one. pyfakefs replaces the filesystem modules with an in-memory implementation for the duration of a test, so the code reads and writes exactly the paths it always does, and none of them touch disk.

It is a powerful tool with a narrow sweet spot. For code that already accepts a directory as a parameter, tmp_path is simpler, faster to reason about, and uses the real filesystem — which is sometimes the point. pyfakefs earns its place where paths are fixed, layouts are elaborate, or the behaviour under another operating system's path rules must be tested.

Prerequisites

Solution

Python
import os
from pathlib import Path


def load_config() -> dict:
    # Fixed absolute path: hard to test without faking the filesystem.
    path = Path("/etc/myapp/config.yaml")
    if not path.exists():
        return {"mode": "default"}
    return parse_yaml(path.read_text())


def rotate_logs(directory: Path, keep: int = 3) -> None:
    logs = sorted(directory.glob("app.*.log"))
    for old in logs[:-keep]:
        old.unlink()


def test_config_is_read_from_etc(fs):
    fs.create_file("/etc/myapp/config.yaml", contents="mode: strict\n")
    assert load_config() == {"mode": "strict"}


def test_missing_config_falls_back(fs):
    assert load_config() == {"mode": "default"}      # empty fake filesystem


def test_rotation_keeps_the_newest_three(fs):
    logdir = Path("/var/log/myapp")
    for day in range(1, 6):
        fs.create_file(logdir / f"app.2026-09-0{day}.log")

    rotate_logs(logdir, keep=3)

    assert sorted(p.name for p in logdir.iterdir()) == [
        "app.2026-09-03.log", "app.2026-09-04.log", "app.2026-09-05.log",
    ]
What pyfakefs replaces during a test Code under test calls open, os, os.path, pathlib and shutil as usual. With the fs fixture active, those modules are redirected to an in-memory filesystem holding only the files the test created. The real disk is never touched, and the fake is discarded at teardown. The same calls, a different filesystem code under test open("/etc/…") Path.glob, os.walk shutil.copy in-memory filesystem /etc/myapp/config.yaml /var/log/myapp/app.*.log only what the test created real disk untouched Discarded at teardown: no leftover files, no privileges needed for /etc.
The code does not change and does not know. That is both the tool's strength and the reason to keep its use narrow.

Why this works

pyfakefs patches the modules through which Python code touches files — os, os.path, pathlib, shutil, io, and the built-in open — replacing them with implementations backed by an in-memory tree. The fs fixture activates the patch before the test and removes it afterwards, and it also patches those names in modules that imported them directly, so from os import path in the code under test still sees the fake.

Because the tree starts empty, every file the code sees was created by the test. That makes the test's preconditions explicit and complete: a reader knows exactly which files exist, and nothing on the developer's machine or the CI runner can leak in. It also makes absolute paths safe, since /etc in the fake is just a directory the test created.

That isolation cuts both ways, and it is worth being explicit about. A test running under pyfakefs proves the code behaves correctly against the fake's model of a filesystem, which is very good but not identical to every real one: case sensitivity, symlink resolution, extended attributes and filesystem-specific limits may differ. For logic that depends on those details, a real tmp_path on the target platform is the stronger test, and the two approaches are complementary rather than competing. Most suites need a little of each.

Edge cases and failure modes

  • C extensions opening files. Libraries that open files from C — some image and database libraries — bypass the patch and hit the real disk. Test those with tmp_path.
  • Paths in other processes. A subprocess sees the real filesystem. Anything that shells out needs real files.
  • Modules cached before the patch. A module that stored a reference to os.stat in a private variable at import may escape. pyfakefs handles common cases; Patcher(modules_to_reload=...) covers the rest.
  • Temp directories. tempfile works inside the fake, but code that relies on the real /tmp being writable by another process will not.
  • Overusing it. Faking the filesystem for code that could take a directory argument adds magic without benefit. Prefer the parameter and tmp_path.
  • Platform-specific behaviour. The fake emulates the host OS by default. Set fs.os = OSType.WINDOWS to test drive letters, backslash separators and case-insensitive lookups from a Linux CI runner, without needing a Windows machine.

Testing error paths the real disk makes hard

The most valuable thing a fake filesystem offers is not avoiding real files — tmp_path does that — but producing conditions that are awkward or impossible to create on demand. Permission errors, full disks and read-only mounts are all situations production code must handle, and all are painful to arrange for real in a test.

pyfakefs makes each a line of setup. fs.create_file(path, st_mode=0o000) produces a file the code cannot read, and — as long as the test is not running as root inside the fake — opening it raises PermissionError exactly as it would on a real system. fs.set_disk_usage(total_size=1024) caps the fake disk, so a write that exceeds it raises OSError with ENOSPC, the out-of-space error. fs.add_real_directory(path, read_only=True) maps a repository fixture in such a way that any attempt to modify it fails.

Python
import errno
from pathlib import Path

import pytest


def test_export_reports_a_full_disk(fs):
    fs.set_disk_usage(total_size=100)            # a tiny disk
    fs.create_dir("/exports")

    with pytest.raises(ExportFailed) as excinfo:
        export_report(Path("/exports/report.csv"), rows=make_rows(1_000))

    assert excinfo.value.__cause__.errno == errno.ENOSPC
    assert not Path("/exports/report.csv").exists()   # no half-written file left

The final assertion is the one that matters. Code that writes directly to the destination leaves a truncated file behind when the disk fills; code that writes to a temporary file and renames it on success leaves nothing. The test distinguishes them in milliseconds, and without a fake it would require actually filling a disk.

Filesystem failures a fake can produce on demand Three conditions. A file created with no permissions makes reads raise PermissionError. A capped disk size makes large writes raise an out-of-space error. A read-only mapped directory makes modifications fail. Each is one line of setup with pyfakefs and hard to arrange on a real system. One line of setup per failure mode permission denied create_file(…, st_mode=0o000) PermissionError disk full set_disk_usage( total_size=100) OSError ENOSPC read-only add_real_directory(…, read_only=True) writes fail
These are the conditions production hits during incidents, and the ones a real-filesystem test suite almost never exercises.

pyfakefs or tmp_path

The two tools answer different questions, and choosing between them is mostly about the code's design rather than the test's preference.

tmp_path gives each test a real, empty, unique directory that pytest cleans up. It is the right default for any code that accepts a path argument: the test passes tmp_path / "config.yaml", and the code exercises the real filesystem with its real semantics — permissions, symlinks, case sensitivity, atomic renames. Nothing is patched, so nothing can escape the patch.

pyfakefs is right when the path is not a parameter. Configuration read from /etc, caches written under ~, a tool that walks from the filesystem root — these can only be tested with a real filesystem by changing the machine, and a fake is the lesser evil. It is also the only practical way to test Windows path behaviour on a Linux runner, since fs.os = OSType.WINDOWS switches separator and drive-letter rules.

A useful rule of thumb: if introducing a directory parameter is a small change, make it and use tmp_path. The code gains a seam that also helps production — configurable locations are rarely a bad thing — and the tests lose a layer of patching. Reserve pyfakefs for fixed paths that genuinely cannot move, and for cross-platform path logic.

Choosing between tmp_path and pyfakefs If the code accepts a path argument, or a small change can make it do so, use tmp_path and the real filesystem. If the path is fixed, such as a system configuration location, or the test must emulate another operating system's path rules, use pyfakefs. Can the path be a parameter? yes → tmp_path real filesystem semantics nothing patched, nothing escapes the default choice configurable paths help production too no → pyfakefs /etc, ~, fixed tool layouts Windows rules on a Linux runner the targeted choice watch for C-level file access
Most file-handling code belongs on the left once it takes its paths as arguments.

Preloading fixtures without copying them

Many tests need a realistic file to read — a sample configuration, a recorded export, a malformed input found in production. Copying those into the fake by hand with create_file(contents=...) works for short strings and becomes unmaintainable for anything larger. fs.add_real_file and fs.add_real_directory solve it by mapping files from the repository into the fake filesystem, optionally at a different path.

Python
from pathlib import Path

FIXTURES = Path(__file__).parent / "fixtures"


def test_parses_the_production_sample(fs):
    fs.add_real_file(FIXTURES / "config.prod.yaml", target_path="/etc/myapp/config.yaml")
    assert load_config()["mode"] == "strict"

The mapping is lazy — the real file is read only when the code opens it — and read-only by default, so a test cannot accidentally modify a fixture in the repository. That combination keeps fixtures in version control as ordinary files, reviewable in diffs, while the code under test sees them at the absolute paths it expects. It is the piece that makes pyfakefs practical for suites with more than a handful of file-driven tests.

Frequently Asked Questions

When should I use pyfakefs instead of tmp_path? When the code under test uses fixed absolute paths such as /etc/myapp/config.yaml or ~/.cache, when it needs to see a directory layout that would be awkward to create for real, or when it must be tested against another operating system's path rules. For code that accepts a directory argument, tmp_path is simpler and uses the real filesystem.

Does pyfakefs work with pathlib and shutil? Yes. It patches os, os.path, pathlib, shutil, io.open and the built-in open, so ordinary file code sees the fake filesystem. Modules that open files through C extensions bypass it and need either a real temporary directory or explicit handling.

Can I load real files into the fake filesystem? Yes. fs.add_real_file and fs.add_real_directory map real paths into the fake one, read-only by default, which is the way to give tests access to fixtures stored in the repository without copying them.

← Back to Faking the Filesystem and Environment