Skip to content

Repository files navigation

pytest-sideeffects

ci python pypi

Find out which real files your test suite writes to — including from subprocesses your other guards cannot see.

Most test suites are less isolated than their authors believe. A helper appends to a log, a fixture rewrites a config file, a test calls main() and main() shells out to a script that does the real thing. Everything passes, so nobody looks.

$ pytest --sideeffects=audit

That is the whole setup. No fixtures to adopt, no code to change.

------------------------------- pytest-sideeffects -------------------------------
3 real file(s) changed while the suite ran:
  added     data/outbox/2026-08-17.json
  modified  logs/run.log
  modified  state/ledger.jsonl
  unattributed changes are usually a subprocess, a C extension or an atexit
  handler; run with --sideeffects=both to name the test when the write happens
  in-process.

Why another isolation plugin

Because the two mechanisms people already use each have a blind spot, and this one has both halves.

knows which test sees subprocess sees C extensions / os.system
monkeypatching the filesystem yes no no
hashing the tree before/after no yes yes
pytest-sideeffects yes (guard) yes (audit) yes (audit)

pytest-socket blocks network calls in-process. pyfakefs replaces the filesystem wholesale. Neither answers the question "did my suite change anything real?" — and neither follows a child process.

The subprocess gap is not theoretical. The incident this plugin came out of was a test that called main(); main() spawned subprocess.run([sys.executable, "poster.py", "--post-next"]); the child was a fresh interpreter with none of the parent's patches, and it published a real post to a real social account. Every in-process guard in that repo said green.

The two modes

--sideeffects=audit — what changed

Hashes every file under rootdir before and after the session and reports the difference. Process-agnostic, so it catches whatever wrote the file. Read-only: it never blocks anything. Use it to find out what you are dealing with.

--sideeffects=guard — stop it happening

Wraps open, os.open, os.remove/rename/replace/mkdir/makedirs/truncate, shutil.copy*/move/rmtree and the matching pathlib.Path methods. A write outside the allowed roots raises SideEffectBlocked, naming the test and the path:

SideEffectBlocked: pytest-sideeffects blocked open('data/ledger.jsonl') during
tests/test_poster.py::test_main: that path is outside the roots this suite may
write to. Point the code at tmp_path, or allow it with --sideeffects-allow=<glob>
if the write is intentional.

--sideeffects=both — both, and they cooperate

The guard supplies attribution for the paths it saw; the audit still reports what the guard could not reach. Anything reported by the audit but not by the guard came from outside this process — that difference is a diagnosis, not noise.

Install

pip install pytest-sideeffects

Turn it on for everyone via pyproject.toml:

[tool.pytest.ini_options]
sideeffects = "audit"
sideeffects_allow = ["build/*", "docs/_generated/*"]

In CI, make it a hard failure:

pytest --sideeffects=both --sideeffects-strict --sideeffects-json=sideeffects.json

--sideeffects-strict exits non-zero when something leaked, even if every test passed.

Options

flag effect
--sideeffects={off,audit,guard,both} mode; default off
--sideeffects-allow=GLOB path glob the suite may write to (repeatable)
--sideeffects-root=DIR tree to audit; defaults to rootdir (repeatable)
--sideeffects-strict non-zero exit when anything leaked
--sideeffects-warn-only guard records instead of raising
--sideeffects-json=PATH machine-readable report
--sideeffects-no-defaults drop the built-in allowlist entirely

Allowed out of the box, because a suite writing here surprises nobody: tmp_path and the system temp dir, __pycache__, .pytest_cache, .hypothesis, .mypy_cache, .ruff_cache, .coverage*, site-packages and the rest of the environment Python lives in, the user cache directories (~/.cache, ~/Library/Caches, %LOCALAPPDATA%), and whatever pytest itself was told to write (--junitxml, --log-file, the cache dir).

For application code: running_under_test()

A guard living in the test process cannot protect a child process. This can, because it reads an inherited environment variable — put it at the point of no return:

from pytest_sideeffects import running_under_test

def publish(post):
    if running_under_test():
        raise RuntimeError("refusing to publish from a test run")
    return api.create(post)

True when PYTEST_SIDEEFFECTS=1 (exported by this plugin for the whole session), when PYTEST_CURRENT_TEST is set (pytest's own variable, also inherited), or when pytest is imported in this process. PYTEST_SIDEEFFECTS_ALLOW=1 forces it back to False for the one test that genuinely needs the real thing.

Outside pytest there is a context manager with the same guard:

from pytest_sideeffects import no_side_effects

with no_side_effects():
    render_report()          # raises if it writes into the repo

Design notes

Content hashes, never modification times. Measured on a real repository: an mtime-based sweep reported 1450 changed files after a run; hashing the same tree over the same window reported 2. Editors, checkouts and backup agents touch mtime without changing a byte, and a tool that cries wolf 1448 times gets uninstalled. Files above 20 MB are the one exception — they are tracked by size and mtime, and the report labels them as such rather than pretending.

Measure the background before blaming a test. Some files change on their own: a daemon, a language server, a sync client. Get a control reading first:

python -m pytest_sideeffects control 40

It watches the same tree for 40 seconds with no tests running. Whatever moves there is not your suite's fault.

False positives are the real failure mode. A guard that fires on --junitxml output or on matplotlib's font cache gets switched off in one afternoon, and then it protects nothing. That is why the default allowlist is generous and the strict switch is opt-in.

Requirements

Python 3.9+, pytest 7+. No dependencies beyond pytest. Linux, macOS and Windows.

Under pytest-xdist, the audit runs in the controller process only and workers keep the guard: N workers each hashing the whole tree would cost N times as much and then each report the other workers' writes as unexplained changes.

License

MIT

About

Find out which real files your test suite writes to - including from subprocesses your other guards cannot see

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages