A static, AST-based test-smell linter for pytest suites. Cross-platform, zero runtime execution — it reads your tests, it never runs them.
pytest-tidy walks the abstract syntax tree of your test files and flags the
semantic smells that let test suites rot: tests that assert nothing,
assert True, except: pass swallowing failures, pytest.raises(Exception)
catching everything, time.sleep(5) making CI slow and flaky, fixtures with the
wrong scope, and more. flake8-pytest-style catches formatting; pytest-tidy
catches meaning.
It ships three ways: a standalone CLI, a pre-commit hook, and a pytest plugin that warns during collection.
$ pytest-tidy tests/
tests/test_orders.py:14:12: PTD002 assertion on a 2-tuple is always true; did you mean `assert <cond>, <message>`? [*]
tests/test_orders.py:28:5: PTD101 `except Exception:` with no handling body swallows failures
tests/test_orders.py:41:5: PTD003 test 'test_refund' contains no assertions
tests/test_billing.py:9:23: PTD203 scope="function" is the default and can be removed [*]
Found 4 problems 2 fixable with --fixA test that silently passes is worse than no test: it gives false confidence and
hides regressions forever. These bugs are invisible in review because the code
looks like it tests something. pytest-tidy finds them statically:
# All of these pass no matter what your code does:
assert (order.total == 99, "wrong total") # PTD002 - a 2-tuple is always truthy
mock.assert_called_once # PTD501 - never called; missing ()
try:
charge(order)
except Exception:
pass # PTD101 - swallows AssertionError too- 32 rules across 7 categories (assertions, exceptions, fixtures, timing, structure, mocking, markers) - see the full catalog.
- Zero required dependencies. The core is pure-stdlib
ast. Works on Python 3.9+, every OS. - Autofix.
--fixrewrites the unambiguous ones (byte-precise range edits that preserve your formatting and comments);--diffpreviews them. - Near-zero false positives by design. Noisier rules are opt-in, not on by default.
- Three delivery modes: CLI,
pre-commit, and a pytest plugin. - CI-friendly output:
--format githubemits GitHub Actions annotations;--format jsonis machine-readable. - Configurable via
pyproject.tomlorsetup.cfg, with# noqasupport.
pip install pytest-tidyOn Python 3.11+ TOML config works out of the box. On 3.9 / 3.10, add the toml
extra if you want to configure via pyproject.toml (or just use setup.cfg,
which needs nothing extra):
pip install "pytest-tidy[toml]"pytest-tidy # lint test files under the current directory
pytest-tidy tests/ pkg/tests # lint specific paths
pytest-tidy tests/ --fix # apply autofixes in place
pytest-tidy tests/ --diff # show what --fix would change, write nothing
pytest-tidy tests/ --statistics # per-rule countsWhen given a directory, pytest-tidy lints only files matching your
test-file patterns (test_*.py, *_test.py, conftest.py). When given an
explicit file, it always lints it.
| Flag | Purpose |
|---|---|
--select CODES |
Enable only these rules/prefixes (replaces defaults). --select ALL turns on everything. |
--extend-select CODES |
Enable extra rules on top of the defaults (handy for opt-in rules). |
--ignore CODES |
Disable rules/prefixes. |
--fix / --diff |
Apply / preview autofixes. |
--format {text,json,github} |
Output format. |
--show-source |
Print the offending line with a caret. |
--statistics |
Per-rule problem counts. |
--list-rules |
Print the whole catalog (respects your config). |
--explain PTD002 |
Show a single rule's rationale. |
--no-config |
Ignore any discovered config file. |
Codes are matched by prefix, so --select PTD2 selects the whole fixtures
category and --ignore PTD3 silences all timing rules.
Exit codes: 0 clean, 1 problems found, 2 usage/internal error.
Add to .pre-commit-config.yaml:
repos:
- repo: https://github.com/arizzubair/pytest-tidy
rev: v0.1.0
hooks:
- id: pytest-tidy
# ...or auto-fix on commit instead:
# - id: pytest-tidy-fixBoth hooks only run on test_*.py, *_test.py, and conftest.py files.
The plugin is opt-in so it never surprises an existing suite. Enable it per run:
pytest --tidy...or persistently in pyproject.toml:
[tool.pytest.ini_options]
tidy = true
# optional, mirror the CLI selection flags:
tidy_extend_select = ["PTD404"]
tidy_ignore = ["PTD003"]Each finding surfaces as a PytestTidyWarning in the warnings summary during
collection - visible, but it never fails the run.
pytest-tidy reads [tool.pytest-tidy] from the nearest pyproject.toml, or
[pytest-tidy] from setup.cfg / tox.ini (closest file wins):
[tool.pytest-tidy]
# Turn opt-in rules on alongside the defaults:
extend-select = ["PTD008", "PTD302"]
# Silence a rule or a whole category:
ignore = ["PTD404"]
# Or lock to an explicit set (replaces the defaults):
# select = ["PTD001", "PTD002", "PTD1"]
# Don't lint these paths:
exclude = ["tests/data", "tests/legacy"]
# Extra helper calls that count as "an assertion" for PTD003:
assert-calls = ["assert_matches", "verify"]
# Treat functions with this prefix as tests (default: "test"):
test-function-prefix = "test"
# Which files count as test files when a directory is scanned:
test-file-patterns = ["test_*.py", "*_test.py", "conftest.py"]The equivalent setup.cfg (no extra dependency needed):
[pytest-tidy]
extend-select = PTD008, PTD302
ignore = PTD404CLI flags always override file config.
Standard # noqa comments are honored:
assert True # noqa - suppress every rule on this line
assert value == None # noqa: PTD004 - suppress just PTD004 (comma-separate more)32 rules, grouped by category. Full rationale, examples, and autofix notes live
in docs/rules.md; run pytest-tidy --list-rules to see the
set enabled by your config.
| Category | Codes | Examples |
|---|---|---|
| Assertions | PTD001-PTD009 | assert-on-constant, assert-on-tuple, test-without-assertion, assert-eq-none |
| Exceptions | PTD101-PTD104 | except-pass, raises-broad-exception, raises-multiple-statements |
| Fixtures | PTD201-PTD206 | fixture-scope-mismatch, redundant-fixture-scope, yield-fixture-deprecated |
| Timing & determinism | PTD301-PTD304 | sleep-in-test, datetime-now, random-without-seed, network-call |
| Structure & collection | PTD401-PTD404 | test-class-init, duplicate-test-name, return-in-test |
| Mocking | PTD501-PTD502 | mock-uncalled-assert, mock-nonexistent-attr |
| Markers & parametrize | PTD601-PTD603 | skip-without-reason, unconditional-skip, parametrize-values-mismatch |
Opt-in (disabled by default) rules: PTD008, PTD009, PTD103, PTD206, PTD302,
PTD303, PTD304, PTD404, PTD602. Enable them with --extend-select or config.
- Read, never run. Everything is a pure
astwalk. No imports of your code, no side effects, fully deterministic and cross-platform. - Near-zero false positives. A linter you have to argue with gets turned off. Rules that can't be sure stay opt-in, and default rules bias hard toward "only flag it if it's almost certainly a bug."
- Fix what's safe. Autofixes only fire on unambiguous rewrites, and re-lint to convergence so chained fixes settle.
python -m venv .venv
.venv/Scripts/pip install -e ".[dev]" # POSIX: .venv/bin/pip
python -m pytest # run the suite
python -m pytest_tidy tests # dogfood the linter on itself
python scripts/gen_rules_doc.py # regenerate docs/rules.mdEach rule is a small class in src/pytest_tidy/rules/ declaring the AST node
types it cares about and a check() method; the engine walks each file once and
dispatches nodes to interested rules.
Continuous integration runs the suite across Python 3.9-3.13 on Linux, macOS,
and Windows (.github/workflows/ci.yml). Publishing is automated via
.github/workflows/publish.yml using PyPI Trusted Publishing (OIDC), so
no API tokens are stored in the repo. To cut a release:
- Bump
versioninpyproject.tomland updateCHANGELOG.md. - Tag and push:
git tag v0.1.0 && git push --tags. - Publish a GitHub Release for the tag - the workflow builds the sdist/wheel and uploads them to PyPI.
Run the publish workflow manually (workflow_dispatch) to push to TestPyPI
first as a dry run.
MIT - see LICENSE.