A cross-platform pytest plugin that audits test isolation and reports exactly what global state each test leaks.
Project description
pytest-hygiene
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'
The problem: tests pass alone but fail together
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).
Why existing tools fall short
| 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.
Install
pip install pytest-hygiene
The plugin activates automatically (it registers a pytest11 entry point). There is
nothing to import.
Quickstart
Just run your suite — auditing is on by default and stays quiet unless something leaks:
pytest
Leak 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-strict
To temporarily disable auditing:
pytest --no-hygiene
What it audits
Each 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 |
numpysupport is strictly optional: therandomauditor only inspects numpy if it is already present insys.modules. It never imports numpy itself, so there is zero cost if you don't use it.
Configuration
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.
Command-line
| 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. |
ini options
# 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 leaks
Or 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.*"]
The allowlist (hygiene_allow)
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.
Known nuance: session/module/class-scoped fixtures
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"]
How it works
- 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.
Development
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 -q
The test suite dogfoods the plugin: it runs with pytest-hygiene active and asserts our
own tests leak nothing.
Releasing
Releases publish to PyPI automatically via the release.yml
workflow using PyPI Trusted Publishing (OIDC) —
no API tokens are stored.
One-time setup — on PyPI, go to Account → Publishing → Add a pending publisher and register:
| Field | Value |
|---|---|
| Owner | az-pz |
| Repository | pytest-hygiene |
| Workflow name | release.yml |
| Environment | pypi |
To cut a release:
- Bump
versioninpyproject.toml(e.g.0.1.0→0.2.0). - Commit, then tag and push:
git tag v0.2.0 git push origin v0.2.0
- The workflow runs the test suite, builds the sdist + wheel, verifies the tag matches the
package version, and publishes to
https://pypi.org/project/pytest-hygiene/.
PyPI versions are immutable — each release needs a unique, higher version number.
Roadmap / deferred
Ideas intentionally left for a future version:
MockAuditor— detectunittest.mockpatches that were started but never stopped.- Signal-handler auditor — flag changed
signalhandlers. pytest-xdistaggregation — collate the summary across worker processes.
License
MIT — see LICENSE.
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file pytest_hygiene-0.1.0.tar.gz.
File metadata
- Download URL: pytest_hygiene-0.1.0.tar.gz
- Upload date:
- Size: 23.8 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
786e86c6ef4965b89fb71c229678222a141d8b3820aef334e4e89701852e4aa2
|
|
| MD5 |
d9bf963c19c2c8e2a7401a68f2f04511
|
|
| BLAKE2b-256 |
a50ce9f7e240715153f930eb2dbf09e03ef9539bf15e8eb65cbd06f0e7133edf
|
Provenance
The following attestation bundles were made for pytest_hygiene-0.1.0.tar.gz:
Publisher:
release.yml on az-pz/pytest-hygiene
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
pytest_hygiene-0.1.0.tar.gz -
Subject digest:
786e86c6ef4965b89fb71c229678222a141d8b3820aef334e4e89701852e4aa2 - Sigstore transparency entry: 2170560443
- Sigstore integration time:
-
Permalink:
az-pz/pytest-hygiene@abaa098fd7c6ba8161f91b7b16483c0f339c2eba -
Branch / Tag:
refs/tags/v0.1.0 - Owner: https://github.com/az-pz
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@abaa098fd7c6ba8161f91b7b16483c0f339c2eba -
Trigger Event:
push
-
Statement type:
File details
Details for the file pytest_hygiene-0.1.0-py3-none-any.whl.
File metadata
- Download URL: pytest_hygiene-0.1.0-py3-none-any.whl
- Upload date:
- Size: 22.1 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
26a5c0fcf1c60d38af279e13031ee5edcfb37b8f9e4ff913459b5e9e0b3e76c5
|
|
| MD5 |
0065693ee5312e276ded2aa7c651b80a
|
|
| BLAKE2b-256 |
814912e017c438b2afb656ac93a5f68e9c88baef4728a430d6bd10c0605977ed
|
Provenance
The following attestation bundles were made for pytest_hygiene-0.1.0-py3-none-any.whl:
Publisher:
release.yml on az-pz/pytest-hygiene
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
pytest_hygiene-0.1.0-py3-none-any.whl -
Subject digest:
26a5c0fcf1c60d38af279e13031ee5edcfb37b8f9e4ff913459b5e9e0b3e76c5 - Sigstore transparency entry: 2170560492
- Sigstore integration time:
-
Permalink:
az-pz/pytest-hygiene@abaa098fd7c6ba8161f91b7b16483c0f339c2eba -
Branch / Tag:
refs/tags/v0.1.0 - Owner: https://github.com/az-pz
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@abaa098fd7c6ba8161f91b7b16483c0f339c2eba -
Trigger Event:
push
-
Statement type: