Skip to content

az-pz/pytest-tidy

Repository files navigation

pytest-tidy

CI PyPI Python versions License: MIT

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 --fix

Why

A 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

Highlights

  • 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. --fix rewrites the unambiguous ones (byte-precise range edits that preserve your formatting and comments); --diff previews 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 github emits GitHub Actions annotations; --format json is machine-readable.
  • Configurable via pyproject.toml or setup.cfg, with # noqa support.

Install

pip install pytest-tidy

On 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]"

Usage

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 counts

When 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.

Useful flags

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.

pre-commit

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-fix

Both hooks only run on test_*.py, *_test.py, and conftest.py files.

pytest plugin

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.

Configuration

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 = PTD404

CLI flags always override file config.

Inline suppression

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)

Rules

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.

Philosophy

  • Read, never run. Everything is a pure ast walk. 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.

Contributing / development

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.md

Each 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.

Releasing

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:

  1. Bump version in pyproject.toml and update CHANGELOG.md.
  2. Tag and push: git tag v0.1.0 && git push --tags.
  3. 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.

License

MIT - see LICENSE.

About

A static, AST-based test-smell linter for pytest suites.

Topics

Resources

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages