╔═══════════════════════════════════════════════════════════════════════════╗
║ ║
║ ████████╗███████╗███████╗████████╗██████╗ ██╗███████╗██╗ ██╗ ║
║ ╚══██╔══╝██╔════╝██╔════╝╚══██╔══╝██╔══██╗██║██╔════╝██║ ██╔╝ ║
║ ██║ █████╗ ███████╗ ██║ ██████╔╝██║███████╗█████╔╝ ║
║ ██║ ██╔══╝ ╚════██║ ██║ ██╔══██╗██║╚════██║██╔═██╗ ║
║ ██║ ███████╗███████║ ██║ ██║ ██║██║███████║██║ ██╗ ║
║ ╚═╝ ╚══════╝╚══════╝ ╚═╝ ╚═╝ ╚═╝╚═╝╚══════╝╚═╝ ╚═╝ ║
║ ║
║ Find the code most likely to need better tests ║
║ ║
╚═══════════════════════════════════════════════════════════════════════════╝
testrisk
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 —
--promptturns 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-pythoninpyproject.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)
| File | Size | Uploaded | |
|---|---|---|---|
| testrisk-1.1.0.tar.gz | 46.9 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| 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 logRelease 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