Skip to main content
╔═══════════════════════════════════════════════════════════════════════════╗
║                                                                           ║
║       ████████╗███████╗███████╗████████╗██████╗ ██╗███████╗██╗  ██╗       ║
║       ╚══██╔══╝██╔════╝██╔════╝╚══██╔══╝██╔══██╗██║██╔════╝██║ ██╔╝       ║
║          ██║   █████╗  ███████╗   ██║   ██████╔╝██║███████╗█████╔╝        ║
║          ██║   ██╔══╝  ╚════██║   ██║   ██╔══██╗██║╚════██║██╔═██╗        ║
║          ██║   ███████╗███████║   ██║   ██║  ██║██║███████║██║  ██╗       ║
║          ╚═╝   ╚══════╝╚══════╝   ╚═╝   ╚═╝  ╚═╝╚═╝╚══════╝╚═╝  ╚═╝       ║
║                                                                           ║
║              Find the code most likely to need better tests               ║
║                                                                           ║
╚═══════════════════════════════════════════════════════════════════════════╝

testrisk

PyPI License: MIT Python Test

Find the code most likely to need better tests. testrisk reads coverage.py data, maps uncovered lines onto functions, and ranks them by test risk — not another coverage percentage.

Why testrisk?

  • Rank gaps, do not just list them — uncovered branches, cyclomatic-ish complexity, git-changed lines, and missing or ceremonial tests all feed one score
  • Human by default, JSON when you need it — --prompt turns the same evidence into a concise agent task
  • Optional CI gates — --fail-under-changed 95, --fail-on-risk HIGH, and --fail-on-weak; the default command still just advises
  • Small install — one runtime dependency: coverage (see pyproject.toml)
testrisk 1.1.0

  Coverage    91.7%   Branch 84.2%
  Changed     94.1% covered   2 uncovered lines

Highest-value test gaps

  1. demo/services.py::ServiceManager.start
     risk      HIGH  ·  18.5
     missing   15-18  ·  2 branches
     tests     indirect → tests/test_services.py

  ----------------------------------------------------------

  2. demo/services.py::parse_config
     risk      LOW  ·  4.2
     missing   8-9
     tests     tests/test_services.py

  ----------------------------------------------------------

  Next        tests/test_services.py
  Shown       2 gaps
  Risk        1 HIGH  ·  0 MEDIUM  ·  1 LOW
  Checks      0 none  ·  1 indirect  ·  0 weak  ·  1 meaningful
  Hint        Start with the top HIGH gap. Score favors changed uncovered lines and tests that cannot fail.

Installation

uvx (recommended — zero install)

cd /path/to/your/repo
uvx testrisk

Persistent install on your PATH:

uv tool install testrisk
testrisk --doctor

via pipx (isolated CLI)

pipx install testrisk

via pip

pip install testrisk

If testrisk is not on your PATH

python -m testrisk --version
python -m testrisk --doctor

from source

git clone https://github.com/karlhillx/testrisk.git
cd testrisk
uv sync
uv run testrisk --version
uv run python -m testrisk --version

Quick start

Generate coverage, then rank this branch:

pytest --cov --cov-report=json
testrisk --changed

--changed is the everyday command. A full-repo testrisk still works when you want every gap; the report will nudge you back to --changed when the list is long.

testrisk looks for coverage.json, then coverage.xml, then .coverage, walking up from . to the nearest pyproject.toml / .git when --repo is omitted. If coverage is older than files you just changed, doctor and the text report warn you to refresh it.

testrisk --changed                 # everyday: only gaps on git-changed lines
testrisk --top 10                  # default
testrisk --file src/foo.py         # one file or glob (repeatable)
testrisk --exclude '**/generated/**'
testrisk --no-default-omit         # keep __init__.py and migrations/ in the ranking
testrisk --min-risk HIGH           # hide LOW / MEDIUM
testrisk --weak-only               # missing, indirect, or ceremonial related tests
testrisk --json                    # machine-readable report
testrisk --prompt                  # evidence-based agent task (includes source)
testrisk --explain                 # show how each score was assembled
testrisk --fail-under-changed 95   # exit 1 if changed-line coverage is low
testrisk --fail-on-risk HIGH       # exit 1 if any matched gap is HIGH or worse
testrisk --fail-on-weak            # exit 1 if related tests cannot fail
testrisk --doctor                  # coverage file, git, and the generate command

--quiet prints only the suggested next test file.

Defaults can live in pyproject.toml so CI and local runs match:

[tool.testrisk]
changed = true
fail-on-risk = "HIGH"
fail-on-weak = true
fail-under-changed = 95
omit = ["**/generated/**"]
include = ["src/**"]
default-omit = true
explain = false

[tool.testrisk.weights]
missing-lines = 1.0
missing-branches = 2.0
complexity = 0.75
changed-uncovered = 3.0
no-tests = 4.0

CLI flags override the table. --exclude merges with omit. --changed / --no-changed override changed. --no-default-omit turns off the built-in skip of __init__.py and migrations/.

GitHub Actions

Use the composite action after you generate coverage. Fetch the base branch so --changed can diff:

- uses: actions/checkout@v6
  with:
    fetch-depth: 0

- name: Tests with coverage
  run: pytest --cov --cov-report=json

- uses: karlhillx/testrisk@v1
  with:
    changed: true
    fail-on-risk: HIGH
    fail-on-weak: true
    fail-under-changed: "95"

On pull requests, testrisk treats GITHUB_BASE_REF (then origin/<ref>) as the default --base. Pin a release with version: 1.1.0 if you do not want uvx to pull latest.

--prompt

Write focused unit tests for these uncovered behaviors.

Do not modify production code unless required to expose a testable seam.

Target:
src/foo.py::parse_config

Uncovered:
44-48, 57

Untested branches:
line 46: false branch
line 57: exception/exit branch

Complexity: 6
Score: 12.0
Risk: MEDIUM

Existing tests:
tests/test_foo.py

--prompt also includes a numbered Source: excerpt of the uncovered lines when the file is on disk.

How ranking works

Each function or method with uncovered statements or branches gets a score:

Signal Weight
Uncovered executable lines × 1
Uncovered branches × 2
Complexity above 1 × 0.75
Changed uncovered lines × 3
No meaningful related tests (missing or ceremonial) + 4

Risk is HIGH / MEDIUM / LOW from that score plus a few hard rules (for example: five or more changed uncovered lines, or complexity ≥ 10 with two uncovered branches).

Related tests are existing test_*.py / *_test.py files that mention the function. Short or generic names (start, run, get) also need the module stem or class name in the same file. A conventional tests/test_<stem>.py is not treated as coverage just because it exists. Each gap is labeled none (no test file yet), indirect (the conventional file exists but does not mention the function), weak (mentioned, but the checks cannot fail), or meaningful.

Related tests that cannot fail — no assertions, only assert True / assert x is not None, or an import with no call — are marked Weak. They still appear as related files, but they do not count as meaningful checks (same +4 as none or indirect). This is a static filter, not mutation testing. testrisk does not run your suite.

<module> gaps (import-time statements that sit outside a function) are omitted unless they have uncovered branches or sit on git-changed lines. __init__.py and migrations/ are omitted by default.

If no related test is found, the suggestion is tests/test_<stem>.py for a flat layout, or tests/<package>/test_<stem>.py for src/ trees and nested packages.

--base defaults to GITHUB_BASE_REF (and origin/<ref>) when set, then origin/main, then main, then origin/master, then master, then HEAD. Changed lines are git diff against the merge-base of that ref (plus your working tree).

--fail-on-risk and --fail-on-weak look at every gap that remains after --changed, omit/include, --min-risk, and --weak-only, not only the --top slice.

Exit codes

Code Meaning
0 Success (or changed-line coverage meets --fail-under-changed)
1 Runtime failure (no coverage data, or a --fail-under-changed / --fail-on-risk / --fail-on-weak gate)
2 Usage error
130 Interrupted with Ctrl-C

Use as a library

testrisk ships type hints (py.typed) and a small public API:

from testrisk import GapError, analyze

try:
    report = analyze(".", changed_only=True, top=5)
except GapError as exc:
    raise SystemExit(exc) from exc

for gap in report.gaps:
    print(gap.qualname, gap.risk, gap.score)

analyze(...) returns a GapReport. Optional kwargs: coverage, changed_only, base, files, omit, top, min_risk, weights, weak_only, default_omit. You can also pass an Options instance. omit / include globs and other defaults are read from [tool.testrisk] unless you pass Options(apply_config=False). Only names in testrisk.__all__ are public; import the CLI via python -m testrisk or the testrisk console script.

Requirements

  • Python 3.12+ (requires-python in pyproject.toml)
  • OS Linux, macOS, and Windows
  • coverage 7.x (installed automatically with testrisk)
  • git (optional; needed for --changed, the changed-code section, and --fail-under-changed)

Local development

uv sync
uv run pytest
uv run pytest --cov=testrisk --cov-report=xml tests/
uv run ruff check testrisk tests
uv run ty check

Environment variables

Variable Description
NO_COLOR Disable color when --color auto
FORCE_COLOR Enable color when --color auto even if stdout is not a TTY
TESTRISK_DEBUG Print a traceback on unexpected errors (same as --verbose for crashes)
GITHUB_BASE_REF Default --base on GitHub Actions pull requests (origin/<ref>, then <ref>)

testrisk does not run your test suite. It only reads coverage artifacts, source files, and git diffs.

Troubleshooting

"No coverage data found"

Run tests with coverage and write a report in the repo root:

pytest --cov --cov-report=json
# or
coverage run -m pytest && coverage json

Then pass --coverage PATH if the file is not in the usual place. Rank the branch with testrisk --changed.

Coverage looks stale

The coverage file is older than Python files changed on this branch (or dirty in the working tree). Re-run pytest --cov --cov-report=json and try again.

Changed-code section is missing

The checkout is not a git repo, or git is not on PATH. --fail-under-changed needs git.

uvx: command not found

Install uv (curl -LsSf https://astral.sh/uv/install.sh | sh or brew install uv), then retry uvx testrisk.

License

MIT License - see LICENSE for details.

Contributing

See CONTRIBUTING.md. User-facing changes should be noted in CHANGELOG.md. Security reports: SECURITY.md.

Links

Metadata

Release files for testrisk 1.1.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for testrisk 1.1.0
File Size Uploaded
testrisk-1.1.0.tar.gz 46.9 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for testrisk 1.1.0
File Interpreter ABI Platform
testrisk-1.1.0-py3-none-any.whl Python 3 none any Details

Total release size: 88.0 kB

Release files / testrisk-1.1.0.tar.gz

Download URL testrisk-1.1.0.tar.gz
Size 46.9 kB
Tags Source
SHA-256 checksum
How to use checksums
03cf4c8406de40b4f1b42a665796370a79bcaab5887b391c3ec4b66a37abba83
BLAKE2b-256 checksum
How to use checksums
bfd1a660ac770c4b56080adf46f4970cdb36816050efdfc022b87ff09c3ab139
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Sep 12, 2026.

Transparency log

Release files / testrisk-1.1.0-py3-none-any.whl

Download URL testrisk-1.1.0-py3-none-any.whl
Size 41.1 kB
Tags Python 3
SHA-256 checksum
How to use checksums
376f6e167c2df81132a700cd1262d90afbbf7580a8663f4c822bf969ac1e1f6b
BLAKE2b-256 checksum
How to use checksums
e9eac8fc685ec22183194359af59a3a9439989ef94010277ef521c28b5fe614b
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Sep 12, 2026.

Transparency log

Release history Release notifications | RSS feed

This release

1.1.0 This release

2 release files

1.0.0

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page