Find out which tests leak global state — and exactly what they leak. Cross-platform, no os.fork.
pytest-hygiene snapshots key global process state before and after every test's
full lifecycle (setup + call + teardown), diffs the two, and tells you precisely what
each test left dirty:
=============================== test hygiene ================================
3 state leak(s) detected across 2 test(s) (auditors: env, cwd, ..., random):
tests/test_api.py::test_uses_proxy
LEAK [env] set env var 'HTTP_PROXY'='http://localhost:8080'
LEAK [threads] non-daemon thread 'poller' still alive after test
tests/test_cli.py::test_runs_in_tmp
LEAK [cwd] changed working directory: '/repo' -> '/tmp'
You've hit it before. A test passes in isolation but fails when the suite runs — or worse,
it causes an unrelated test three files over to fail. The culprit is almost always
leaked global state: a test mutates something process-wide (an environment variable,
the working directory, sys.path, a logging handler, the global RNG) and never restores it.
The next test inherits the mess.
These failures are miserable to debug because the failing test isn't the guilty one, and the symptom (order-dependence) hides the cause (what state leaked).
| Tool | Approach | Limitation |
|---|---|---|
pytest-forked, pytest-isolate |
Run each test in a forked subprocess | Fork-based — does not work on Windows. Heavy. Hides leaks rather than reporting them. |
pytest-cleanslate |
Bisects order-dependent failures at module granularity | Only tells you which module pair interacts; not what state leaked, and not per-test. |
None of them answer the actual question: what did this test leave behind?
pytest-hygiene is different:
- Cross-platform. Pure snapshot/diff around the run-test protocol — no
os.fork, no POSIX-only calls. Works on Windows, macOS and Linux (proven by the CI matrix). - It names the leak. Not "these two modules interfere" but "
test_fooleft env varHTTP_PROXYset, added a logging handler to root, and spawned a non-daemon thread." - Low overhead. Capturing is a handful of cheap dict/list copies per test.
pip install pytest-hygieneThe plugin activates automatically (it registers a pytest11 entry point). There is
nothing to import.
Just run your suite — auditing is on by default and stays quiet unless something leaks:
pytestLeak a bit of state and you'll see the test hygiene section shown above. To turn leaks
into hard failures (great for CI gating):
pytest --hygiene-strictTo temporarily disable auditing:
pytest --no-hygieneEach auditor snapshots one slice of global state. By default only leak-severity items
are shown; warning/info items appear with --hygiene-verbose.
Auditor (aspect) |
Detects | Default severity |
|---|---|---|
env |
Environment variables added, removed, or changed (os.environ). Ignores pytest's own PYTEST_CURRENT_TEST. |
leak |
cwd |
Current working directory changed and not restored (os.getcwd()). |
leak |
syspath |
Entries added to / removed from sys.path. |
leak |
sysmodules |
A module in sys.modules replaced (same name, different object) or deleted = leak. Newly imported modules = info (shown only in verbose — first imports are usually benign). |
leak / info |
warnings |
warnings.filters mutated and not restored. |
leak |
logging |
Root logger level changed, handlers added/removed, or logging.disable() level changed. |
leak |
threads |
Threads started during the test still alive at teardown. Non-daemon = leak; daemon = warning. | leak / warning |
random |
Global RNG state consumed/reseeded (random.getstate()), a determinism smell. If numpy is already imported, numpy.random global state too. |
warning |
mock |
unittest.mock patches started with .start() (via patch, patch.object, or patch.dict) but never .stop()ed — they stay active for every later test. Inspected only if unittest.mock is already imported. |
leak |
signal |
Process signal handlers installed or changed and not restored (signal.getsignal). |
leak |
Both
numpy(forrandom) andunittest.mock(formock) are inspected only when already imported. Neither auditor imports the module itself, so there is zero cost if your suite doesn't use it — and if mock was never imported there can be no unstopped patch to find.
Every option is available both on the command line and as an ini setting (in
pytest.ini, pyproject.toml, tox.ini, or setup.cfg). Command-line flags win over
ini values.
| Flag | Effect |
|---|---|
--hygiene / --no-hygiene |
Enable / disable auditing (default: enabled). |
--hygiene-strict |
Report any test that leaks state as a failure (via its teardown phase, surfaced by pytest as an error). Only leak-severity items trigger this — not warnings/info. |
--hygiene-aspects=env,cwd,... |
Comma/space-separated auditor names to enable (default: all). |
--hygiene-verbose |
Also show info/warning items (new modules, RNG use, daemon threads), and print a clean-bill-of-health line when nothing leaks. |
# pytest.ini
[pytest]
hygiene = true ; bool, default true — enable/disable
hygiene_strict = false ; bool, default false — leaks become failures
hygiene_verbose = false ; bool, default false — include info/warning items
hygiene_aspects = ; args, default all — e.g. "env cwd threads"
hygiene_allow = ; linelist — suppress known-benign leaksOr in pyproject.toml:
[tool.pytest.ini_options]
hygiene = true
hygiene_strict = false
hygiene_aspects = ["env", "cwd", "threads"]
hygiene_allow = ["HTTP_PROXY", "env:CI", "sysmodules:my_app.*"]Some leaks are known and benign — a session-scoped fixture that intentionally sets an
env var, a library that installs a logging handler on first import. Suppress them with
hygiene_allow, one pattern per line:
- Bare pattern — matched against the leak's subject (env var name, module name,
sys.path entry, thread name, cwd path). Supports shell-style globs:
HTTP_PROXY # exact env var HYG_* # any env var / subject starting with HYG_ - Scoped pattern
aspect:pattern— only applies within one auditor:env:CI # allow the CI env var, but still flag it elsewhere sysmodules:my_app.* threads:worker-*
The scope prefix is only treated as a scope when it names a real auditor, so Windows paths
like C:\Temp are matched literally (the leading C: is not mistaken for a scope).
Note:
warningsleaks don't carry a single string subject, so they can't be allowlisted by pattern — disable thewarningsaspect via--hygiene-aspectsif you need to.
pytest-hygiene attributes a leak to the test during whose lifecycle the state changed.
State set up by a session-, module-, or class-scoped fixture is deliberately not torn
down after the first test that triggers it — so that first test can look like it "leaked"
the fixture's state (an env var, an imported module, a logging handler).
This is expected, and v1 does not try to fully solve scope attribution. The escape hatch is
hygiene_allow: allowlist the specific subject the shared fixture owns. For example, a
session fixture that sets DATABASE_URL:
[tool.pytest.ini_options]
hygiene_allow = ["DATABASE_URL"]- A hook wrapper around
pytest_runtest_protocol(item, nextitem)takes a before snapshot just before setup begins and stashes it on the item. - A hook wrapper around
pytest_runtest_makereporttakes the after snapshot once the teardown phase has fully finished, then diffs before vs. after. - Capturing around the entire setup + call + teardown lifecycle is deliberate: state that a fixture sets up and then correctly tears down nets to zero and is not reported. Only genuine leftovers count.
- Leaks are collected per
item.nodeidand printed in thetest hygienesection at session end (pytest_terminal_summary). Under--hygiene-strict, a leaking test's teardown report is marked failed so the run's exit status reflects the leak.
Every snapshot is a shallow copy of a dict/list (or a tuple of identities), so per-test overhead is minimal.
pytest-hygiene is xdist-aware. When you run distributed (pytest -n auto), each worker
audits the tests it runs and ships any detected leaks back to the controller, which merges
them into a single test hygiene summary — so you get the same report whether or not the
suite is parallelised. --hygiene-strict failures propagate through xdist's normal report
channel, so a leaking test still fails the run.
git clone https://github.com/az-pz/pytest-hygiene
cd pytest-hygiene
pip install -e .[dev] # installs pytest + numpy (to exercise the optional path)
pytest -qThe test suite dogfoods the plugin: it runs with pytest-hygiene active and asserts our
own tests leak nothing.
Recently shipped (previously deferred):
mockauditor — detectsunittest.mockpatches that were started but never stopped.signalauditor — flags changed process signal handlers.pytest-xdistaggregation — thetest hygienesummary is collated across worker processes.
Still on the wish list:
- Scope-aware attribution — stop attributing session/module/class-scoped fixture state to the first test that triggers it (see the nuance above).
- Allowlisting
warningsleaks — they currently carry no single string subject, so they can only be silenced by disabling the wholewarningsaspect.
MIT — see LICENSE.