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=auditThat 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.
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.
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.
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.
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.
pip install pytest-sideeffectsTurn 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.
| 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).
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 repoContent 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 40It 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.
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.
MIT