Fixture- and conftest-aware test impact analysis for pytest -- select only the tests affected by a git diff, no coverage tracing, no database.
Project description
pytest-impact
Fixture- and conftest-aware test impact analysis for pytest: given a git
diff, pytest-impact selects (and deselects the rest of) only the tests
actually affected by changes to test files, conftest.py, fixtures, and
hooks.
There is no coverage tracing and no persisted database. All state is a
single git ref to diff against. It works cold, on the very first CI run, from
nothing but the diff -- and it is override-aware: it always resolves the
winning FixtureDef for a given fixture name at a given test, so an override
in a deeper conftest.py is never confused with the fixture it shadows.
Why not just X?
pytest-impact's one job is understanding pytest's own fixture and
conftest dependency graph statically, from the diff alone. It intentionally
does not try to be a general-purpose "what changed" tool -- there are
already good ones, and they solve a different problem:
| pytest-impact | pytest-testmon | pytest-picked | pytest-impacted | snob | |
|---|---|---|---|---|---|
| Unit of change tracking | fixture / conftest / hook / test symbols (AST) | line-level coverage of arbitrary source | changed/new files | git diff (file/coverage oriented) | static import graph (transitive imports) |
| Understands fixture overrides | Yes -- resolves the winning FixtureDef per test |
N/A (traces real execution, so overrides are implicitly correct) | No (file-level only) | No | No |
| Tracks arbitrary application code | No (by design -- see Limitations) | Yes (its core strength) | No | Partial | Yes, via import edges (its core strength) |
| Needs a persisted DB / prior run | No | Yes (.testmondata) |
No | No | No |
| Works cold on first CI run | Yes | No (needs a baseline run) | Yes | Yes | Yes |
Detects decorator-only changes (e.g. scope=, parametrize) |
Yes | Yes (via re-execution) | No | No | No (no import edge to follow) |
| Coverage instrumentation required | No | Yes | No | No | No |
| conftest.py / fixture changes | precise, per fixture (fixture graph) | precise (via coverage) | file-level only | not addressed | whole-suite fallback -- no import edge exists for injected fixtures, so conftest.py changes just run everything |
In short: pytest-testmon owns "did any source code this test actually
executed change?" (via real coverage) -- the right tool for arbitrary
application-code changes. pytest-picked is file-level and fixture-blind:
if you touch a shared conftest.py, it can only tell you that file
changed, not which tests are actually affected by which fixture in it.
pytest-impacted is diff/coverage oriented and doesn't reason about the
fixture graph either. snob selects from the static import graph ("this
changed file is transitively imported by these test files") and is fast and
effective for source-level changes, but test modules never import conftest
-- fixtures and hooks are injected by name at collection time, not imported
-- so snob has no edge to follow there and falls back to running the entire
suite on any conftest.py/config change. pytest-impact fills the specific
gap of test-infrastructure churn -- shared fixtures, conftest hierarchies,
collection hooks -- where a one-line change three conftest.py files up can
silently invalidate a handful of specific tests, and file-level, import-graph,
or coverage-based tools either over-select (whole file/whole subtree) or
can't reason about it at all without first executing it once.
Use pytest-impact and pytest-testmon together for the best of both: run
pytest-impact to catch fixture/conftest/hook churn from a clean diff, and
pytest-testmon for line-level coverage on everything else.
Composing with import-graph tools
pytest-impact and import-graph selectors like snob cover different, mostly
non-overlapping change axes -- app/source-code imports vs. the
fixture/conftest graph -- so they compose well as a union rather than a
choice. A simple CI recipe: run both selectors against the same diff and
execute the union of their selected tests, e.g.
# snob: source/app-code import impact (prints selected test ids)
snob $(git diff --name-only origin/main...HEAD) > snob_selected.txt
# pytest-impact: fixture/conftest/hook impact
pytest --impact --impact-base=origin/main --impact-explain-json=impact.json
# union snob_selected.txt with the "selected" entries in impact.json, then run that set
To be clear about the boundary: pytest-impact deliberately does not
track arbitrary application/source-code changes (see Limitations below) --
that's exactly what snob's import graph and pytest-testmon's coverage
tracing are for.
Install
pip install pytest-impact
Registers automatically as a pytest11 plugin -- no -p pytest_impact or
conftest.py wiring needed. With no flags passed, it is a complete no-op:
existing behavior is unchanged.
Usage
Locally
Diff against HEAD (i.e. your uncommitted working-tree changes) and run
only the affected tests:
pytest --impact
Diff against a specific ref (e.g. before pulling, or against a stable branch):
pytest --impact --impact-base=main
Add --impact-explain for a human-readable reason per test:
pytest --impact --impact-explain
-- pytest-impact --
selected 1, deselected 5 (base=HEAD, effective=b00de47e...)
SELECTED tests/test_users.py::test_create_user
- fixture 'user' changed in tests/conftest.py
DESELECTED tests/sub/test_admin.py::test_admin_action
DESELECTED tests/sub/test_admin.py::test_sub_config
DESELECTED tests/test_config.py::test_config_defaults
DESELECTED tests/test_pure.py::test_math
DESELECTED tests/test_users.py::test_list_users
Add --impact-explain-json=report.json for a machine-readable version (CI
artifact, dashboarding, etc.):
{
"impact_base": "HEAD",
"resolved_base": "b00de47e...",
"effective_base": "b00de47e...",
"merge_base_used": true,
"fail_open": null,
"fallback": false,
"fallback_files": [],
"notes": [],
"summary": { "total": 6, "selected": 1, "deselected": 5 },
"selected": [
{ "nodeid": "tests/test_users.py::test_create_user",
"reasons": ["fixture 'user' changed in tests/conftest.py"] }
],
"deselected": ["tests/sub/test_admin.py::test_admin_action", "..."]
}
CI / pull requests (merge-base mode)
For a PR build, you want "what did this branch change", not "what differs
from origin/main's current tip right now" (which would also pick up
unrelated commits that landed on the base branch after you branched).
pytest-impact handles this by default: the effective diff base is always
merge-base(--impact-base, HEAD), so:
git fetch origin main
pytest --impact --impact-base=origin/main --impact-explain-json=impact-report.json
behaves like git diff origin/main...HEAD (three-dot/merge-base semantics),
not git diff origin/main (two-dot). Only changes introduced by the PR
branch itself count. If you ever need the literal two-dot diff instead, pass
--impact-no-merge-base.
A minimal GitHub Actions job:
- run: git fetch origin ${{ github.base_ref }}
- run: >
pytest --impact --impact-base=origin/${{ github.base_ref }}
--impact-explain --impact-explain-json=impact-report.json
- uses: actions/upload-artifact@v4
with:
name: impact-report
path: impact-report.json
CLI options
| Option | Default | Meaning |
|---|---|---|
--impact |
off | Enable selection. No effect at all unless passed. |
--impact-base=REF |
HEAD |
Git ref to diff against. |
--impact-no-merge-base |
off | Diff directly against --impact-base instead of merge-base(base, HEAD). |
--impact-explain |
off | Print a reason for every selected/deselected test. |
--impact-explain-json=PATH |
none | Write the JSON report described above to PATH. |
impact_fallback_files (ini) |
see below | Extra fnmatch patterns that trigger a full-suite fallback run when changed. |
How selection works
For each collected test, pytest-impact builds its dependency surface
and selects it if any part of that surface intersects the changed symbols:
- The test itself -- its own file+qualname changed, or its module has a dirty top-level (an import or module-level statement changed).
- Every fixture in its closure (
item._fixtureinfo.name2fixturedefs), using the winning definition per name -- so an override in a nestedconftest.pyis what's checked, not the fixture it shadows. - Fixtures reached only via
request.getfixturevalue(...)-- a literal string argument resolves to that one fixture; anything dynamic (a variable, an f-string, a computed value) conservatively adds every fixture visible from that test as a dependency, so a dynamic lookup can never be silently missed. - Every ancestor
conftest.py-- a changedpytest_*hook, or any module-level change in a conftest, conservatively selects the whole subtree it governs (hooks can alter collection/execution in ways no static analysis can fully characterize).
A function/method's fingerprint hashes its decorators + signature + body
together, so a decorator-only edit -- @pytest.fixture(scope=...),
@pytest.mark.parametrize(...) -- is detected even when the body is
byte-for-byte identical.
If a changed .py file can't be parsed, or the diff itself is untrustworthy
(no git repo, an unresolvable --impact-base, a failing git command), the
whole run fails open: every test is selected and a warning is printed.
pytest-impact never silently under-selects.
Certain files always trigger a full run regardless of the fixture graph,
because they can change runtime behavior no static analysis captures:
pyproject.toml, setup.cfg, setup.py, tox.ini, pytest.ini,
requirements*.txt/.in, poetry.lock, Pipfile/Pipfile.lock,
uv.lock. Extend this list per-project via the impact_fallback_files ini
option (in pytest.ini/pyproject.toml/setup.cfg):
[pytest]
impact_fallback_files =
data/*.json
migrations/*.sql
Limitations (honest)
- It does not track arbitrary application/source-code changes. If your
test imports and calls a plain function in
myapp/utils.pywith no fixture or conftest involved,pytest-impactwill not know that test is affected when that function changes -- that's exactly what pytest-testmon's dynamic coverage tracing is for.pytest-impact's scope is deliberately the fixture/conftest/hook graph, not your whole codebase. - Dynamic fixture resolution is conservative, not omniscient. A
non-literal
request.getfixturevalue(expr)can't be resolved statically, so it's treated as "depends on everything in scope" -- correct (never under-selects) but can over-select in pathological cases. - Non-Python inputs aren't tracked. Data files, environment variables, external services, and anything else a test depends on outside of Python source and the fixture graph are invisible to this analysis.
- Conftest hook changes select the whole governed subtree, not just the tests that "really" depend on that specific hook behavior -- hooks can alter collection or execution in ways too varied to characterize statically, so this is deliberately conservative rather than risk a missed test.
License
MIT
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_impact-0.1.0.tar.gz.
File metadata
- Download URL: pytest_impact-0.1.0.tar.gz
- Upload date:
- Size: 24.0 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
c8cfb8e775f375874dc56fde6ea6c6b20c1370c6f2a3c546ce63a4edd89c3ae3
|
|
| MD5 |
79f7147aab16a82dbc61da0fd7689c61
|
|
| BLAKE2b-256 |
acacc34ca24623bed0980f749a700181ce604102ded0c8f5c335b82a2cbde948
|
Provenance
The following attestation bundles were made for pytest_impact-0.1.0.tar.gz:
Publisher:
publish.yml on az-pz/pytest-impact
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
pytest_impact-0.1.0.tar.gz -
Subject digest:
c8cfb8e775f375874dc56fde6ea6c6b20c1370c6f2a3c546ce63a4edd89c3ae3 - Sigstore transparency entry: 2191320804
- Sigstore integration time:
-
Permalink:
az-pz/pytest-impact@4498038f89004c45788df974a2111af746983ea0 -
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:
publish.yml@4498038f89004c45788df974a2111af746983ea0 -
Trigger Event:
release
-
Statement type:
File details
Details for the file pytest_impact-0.1.0-py3-none-any.whl.
File metadata
- Download URL: pytest_impact-0.1.0-py3-none-any.whl
- Upload date:
- Size: 20.2 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 |
42c9dfc7d1b9c6ab2acdd47454c00a097e79165d07de3c0775c678808eeb3d62
|
|
| MD5 |
43fe2e23a99cb3f1e6bcb56919c25557
|
|
| BLAKE2b-256 |
3f5ad4d7bff6b9b4cfe95117efc3d291fb0b1c6a23a2c2bb83478ef37192c73b
|
Provenance
The following attestation bundles were made for pytest_impact-0.1.0-py3-none-any.whl:
Publisher:
publish.yml on az-pz/pytest-impact
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
pytest_impact-0.1.0-py3-none-any.whl -
Subject digest:
42c9dfc7d1b9c6ab2acdd47454c00a097e79165d07de3c0775c678808eeb3d62 - Sigstore transparency entry: 2191320844
- Sigstore integration time:
-
Permalink:
az-pz/pytest-impact@4498038f89004c45788df974a2111af746983ea0 -
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:
publish.yml@4498038f89004c45788df974a2111af746983ea0 -
Trigger Event:
release
-
Statement type: