5.5 KiB
ADR-0001: Test Isolation Pytest Plugin and Shift-Left Quality Gates
Date: 2026-07-13 Status: Accepted
Context
Unit tests in devx were slow (10s+) and getting slower. Investigation revealed two root causes:
- Unpatched subprocess calls — test functions calling
subprocess.run,update_doc_versions, orrun_cmdwithout@patchdecorators, causing real subprocess execution during tests. - Excessive iterations — statistical tests with 1000-iteration loops that should use property-based testing or smaller samples.
These issues were discovered manually by profiling with
pytest --durations=0. There was no automated check to prevent
regressions — new tests could introduce the same patterns and slow
down the suite again.
Additionally, translation completeness checks
(devx.ci.check_translations) only ran in CI, not locally. Developers
discovered missing translations at CI time, wasting round-trips.
Decision
1. Test Isolation as a Pytest Plugin (pytest11 entry point)
Implement the test isolation check as a pytest plugin registered
via the pytest11 entry point in pyproject.toml:
[project.entry-points.pytest11]
devx_test_isolation = "devx.tools.check_test_isolation"
This makes the check transparent and always-on — every pytest
invocation in any repo with devx installed automatically runs the
static analysis. No extra Makefile target or CI step needed.
The plugin (devx.tools.check_test_isolation) statically analyzes
test files during pytest_collection_finish and emits
UserWarning for violations:
- unpatched-subprocess:
subprocess.run/call/Popen/check_call/check_outputcalled in a test function without@patch - unpatched-sleep:
time.sleepcalled without@patch - unpatched-helper: known subprocess-spawning helpers
(
update_doc_versions,run_cmd,run_tests) called without@patch(and without patching their internal dependencies) - excessive-iterations:
for _ in range(N)where N > 100
The plugin recognizes transitive safety: if run_cmd is patched,
run_tests (which calls run_cmd) is safe. This is tracked via
HELPER_INTERNAL_CALLS.
A standalone CLI (python -m devx.tools.check_test_isolation) is also
provided for CI gates and pre-commit hooks where pytest isn't run.
2. Shift-Left Quality Gates in make lint
Add devx-check-translations and devx-check-test-isolation to the
devx-lint target in devx.mak. This means make lint now runs:
- ruff check + format
- pyright typecheck
- bandit security scan
- translation completeness (missing keys, dead keys, missing languages)
- test isolation (unpatched subprocess, time.sleep, excessive loops)
These were previously CI-only checks. Running them in make lint
catches issues at the developer's machine, not in CI.
3. Pre-commit Hook Coverage
Update the pre-commit hook to run all three shift-left checks:
test speed, translation completeness, and test isolation. This
catches issues even earlier than make lint — before the commit
is even created.
Consequences
Positive
- Automatic enforcement: The pytest plugin runs on every
pytestinvocation across devx, grm, and infra — no per-repo configuration needed. New tests with unpatched subprocess calls emit warnings immediately. - Shift-left: Translation gaps and test isolation violations are
caught locally (pre-commit /
make lint) instead of in CI. - Fast feedback: Static analysis adds <0.1s to test runs — no runtime overhead.
- No false positives: The transitive dependency tracking
(
HELPER_INTERNAL_CALLS) correctly recognizes that patchingrun_cmdmakesrun_testssafe, and patchingsubprocess.runmakes all helpers safe.
Negative
- Coverage instrumentation gap: The pytest plugin module is loaded
before coverage starts, so module-level code (decorators, class
definitions) appears uncovered. Mitigated by
-p no:devx_test_isolationin devx's ownpyproject.tomladdoptsand# pragma: no coveron plugin hook functions. - Static analysis limitations: The plugin only sees direct calls
in test function bodies, not indirect calls through
main()or other wrappers. This is acceptable — thecheck_test_speedtool catches the symptom (slow tests) for indirect cases. - Translation burden: Every new
_()call in source requires adding 6 language translations. This is by design (all supported languages must be complete) but adds friction for quick prototypes.
Implementation Details
Pytest Plugin Discovery
The pytest11 entry point is the standard mechanism for pytest
plugins. When devx is installed (via pip), pytest auto-discovers
the plugin. No conftest.py or pytest_plugins declaration needed
in consumer repos.
Disabling the Plugin
--no-test-isolationflag: disables analysis for a single run-p no:devx_test_isolationinaddopts: disables for a repo (used in devx's ownpyproject.tomlfor coverage reasons)
Strict Mode
--strict-test-isolationflag: promotes warnings to errors and prints a summary to stderrfilterwarnings = ["error:Test isolation:UserWarning"]inpyproject.toml: same effect via pytest's warning filter system
Known Subprocess Helpers
The KNOWN_SUBPROCESS_HELPERS dict maps function names to
descriptions. HELPER_INTERNAL_CALLS maps each helper to the
function names it internally calls, enabling transitive safety
checks. Both are defined in check_test_isolation.py and can be
extended as new subprocess-spawning helpers are added to devx.