pytest plugin for nondeterministic tests: run cases N times, report empirical pass fractions, flaky detection, and cost.
Project description
pytest-probability
A test that passes 7 times out of 10 is not a passing test — it's a probability.
A pytest plugin for nondeterministic code — LLM-driven functions, model
inference, integration points, anything flaky-prone. A single-shot test lies
to you: it samples a distribution once and calls the result truth.
pytest-probability runs every case N times and reports the empirical pass
fraction per case, so 7/10 FLAKY stops hiding inside a green checkmark.
$ pytest benchmarks/ --prob-runs=10
...
================================= probability ==================================
[classify::is_question] classify 10/10 $0.0020
[classify::identify_pii] classify 7/10 $0.0020 FLAKY
[classify::extract_amount] classify 0/10 $0.0020 FAIL
[triage::refund-terse] triage 8/10 $0.0010 FLAKY
[triage::refund-chain_of_thought] triage 10/10 $0.0040
Overall: 35/50 passed (70%)
Cost: $0.0110
Tokens: m-small 1,200 in / 80 out / 640 cached $0.0010
m-large 4,800 in / 900 out $0.0040
Documentation
Full documentation at pytest-probability.readthedocs.io — installation, a quickstart, guides for benchmark files, parametrization, running, cost accounting and the JSON report, a complete options reference, and a developer guide covering the plugin's internals.
To build locally: pip install -r docs/requirements.txt && sphinx-build -W -b html docs docs/_build/html.
Install
pip install pytest-probability
No configuration needed — the plugin registers itself via the pytest11
entry point. The only dependency is pytest itself. Requires Python 3.11+
and pytest 7.4+.
Usage
Benchmark files are named bench_*.py, and every bench_* function in
them is a benchmark — the same convention pytest applies to test_*.
There is no plugin syntax to learn: cases are stock
@pytest.mark.parametrize, values arrive as function arguments, and
the function yields one result per step instead of asserting:
import time
import pytest
from pytest_probability import StepResult
@pytest.mark.parametrize("text,expected", [
pytest.param("is this a question?", "question", id="is_question"),
pytest.param("my ssn is 078-05-1120", "pii", id="identify_pii",
marks=pytest.mark.fast),
])
def bench_classify(text, expected):
t0 = time.time()
answer = my_classifier(text)
yield StepResult(
label="classify",
passed=(answer == expected),
elapsed=time.time() - t0,
cost=0.0002, # optional — summed into the report
)
Every parameter combination is a case with its own fraction row,
namespaced by function (classify::identify_pii); items collect as
bench_x.py::bench_classify::identify_pii[run3]. pytest.param
names and marks individual cases; a function with no parametrize runs
as a single case. Dataset-driven suites are a comprehension away:
[pytest.param(row, id=row["id"]) for row in load_dataset()].
pytest benchmarks/ --prob-runs=10 # every case ×10
pytest benchmarks/ -k identify_pii # one case, by id
pytest benchmarks/ -m "fast and not eu" # marks, boolean expressions
pytest benchmarks/ -n 4 # parallel via pytest-xdist
Module-level setup() / teardown() run once per benchmark file.
Comparison axes
Stacked decorators cross-product with pytest's exact id layout and ordering — put the configurations you're comparing (models, prompts, thresholds) on their own axes and each combination gets its own row:
import pytest
from pytest_probability import StepResult, TokenUsage
@pytest.mark.parametrize("style", ["terse", "chain_of_thought"])
@pytest.mark.parametrize("text", [
pytest.param("my card was charged twice", id="refund"),
])
def bench_triage(text, style):
response = my_classifier(text, prompt_style=style)
yield StepResult(
label="triage",
passed=response.answer == "billing",
usage=[TokenUsage(model=response.model,
input_tokens=response.input_tokens,
output_tokens=response.output_tokens,
cost=response.cost)],
)
[triage::refund-terse] triage 8/10 $0.0010 FLAKY
[triage::refund-chain_of_thought] triage 10/10 $0.0040
Options
| Option | Where | Default | Meaning |
|---|---|---|---|
--prob-runs=N |
CLI | ini or 1 | run every case N times |
--prob-json=PATH |
CLI | — | write a JSON report to PATH |
--prob-delay=SECONDS |
CLI | ini or 0 | sleep between case executions |
--prob-transpose |
CLI | ini or off | run-major order: run 1 of everything, then run 2, … |
prob_delay |
ini | 0 | default for --prob-delay |
prob_transpose |
ini | false | default for --prob-transpose |
prob_runs |
ini | 1 | default for --prob-runs |
prob_pattern |
ini | bench_*.py |
glob for benchmark files |
JSON report
--prob-json=report.json writes a single machine-readable file (think
--junitxml, but for fractions) — aggregate rows plus the raw per-run
step records for downstream analysis:
{
"created": "2026-07-07T21:43:18",
"runs": 10,
"exit_status": 1,
"totals": {"passes": 35, "fails": 15, "errors": 0, "count": 50,
"pass_rate": 70.0, "cost": 0.011,
"usage": {"m-small": {"input_tokens": 1200, "output_tokens": 80,
"cached_input_tokens": 640, "cost": 0.001}}},
"rows": [
{"case": "classify::identify_pii", "label": "classify", "passes": 7,
"fails": 3, "errors": 0, "total": 10, "pass_rate": 70.0,
"status": "flaky", "cost": 0.002, "usage": {}}
],
"records": [
{"case": "classify::identify_pii", "run": 3, "steps": [
{"label": "classify", "passed": false, "elapsed": 0.01, "error": null,
"message": "got 'other'", "cost": 0.0002, "usage": []}]}
]
}
Under pytest-xdist the controller writes the file with the full result set; workers never write partial reports.
Semantics
- One pytest item per (function, case, run) —
bench_x.py::bench_classify::identify_pii[run3].-k,-x,--lf, JUnit XML, and xdist all operate per run. - A failing step fails that run's item with a compact message (no traceback
noise); an exception in a bench function is a normal pytest failure and
counts as an
errorstep in the aggregate. - Runs are a three-class outcome — pass, fail, error — and the row status
names the combination:
FLAKYis reserved for genuine pass/fail nondeterminism, while passes mixed with errors show7/10 3 ERRORED(the model never answered wrong; the harness dropped runs). Fractions are always over all runs. - Selection is pure pytest:
-kover composed case ids,-mover marks (with and/or/not), node ids for single runs. Note pytest's-kgrammar rejects=— select on id substrings and mark names. - Steps are consumed by duck-typing: anything with
label,passed, and optionallyelapsed/error/message/cost/usageworks, including types from other frameworks.usageentries (objects or plain dicts withmodeland token fields) aggregate per model into theTokens:block and the JSON report. - The fraction summary is computed from
report.user_properties, so it stays correct under pytest-xdist (workers serialize results back to the controller, which renders the table). - Exit code follows pytest: any failed or errored run fails the session, so a flaky case exits nonzero. For softer CI gates (e.g. alert only below 80%), policy-check the JSON report in a follow-up step.
--prob-delaythrottles between executions and never sleeps before the first; under pytest-xdist each worker throttles its own stream.- Default order is case-major (
alpha[run1],alpha[run2],beta[run1], …);--prob-transposemakes it run-major (alpha[run1],beta[run1],alpha[run2], …), which spreads each case's runs over time — useful when back-to-back runs would hit the same transient state. Ordering is advisory under xdist, which schedules items across workers itself.
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_probability-0.1.0.tar.gz.
File metadata
- Download URL: pytest_probability-0.1.0.tar.gz
- Upload date:
- Size: 35.6 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.12.11
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
6bda9252237521bad0047083179f27f780b7c2e65ae643038f7f741277265979
|
|
| MD5 |
508ece18ae9049afa504d50829d669d6
|
|
| BLAKE2b-256 |
59c67b202c56608a4b68f8e995d7e0a038d25a704ac26e3516e6cd68d7abb546
|
File details
Details for the file pytest_probability-0.1.0-py3-none-any.whl.
File metadata
- Download URL: pytest_probability-0.1.0-py3-none-any.whl
- Upload date:
- Size: 13.9 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.12.11
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
dbe9b46eea068e90110ed486c3529e77ec7cce48bf470f15ec95060207bbc087
|
|
| MD5 |
c8e037f7753a2a5e8273c68c52cb3937
|
|
| BLAKE2b-256 |
e14889d96d83db99944b0fbd83caff851cd2e5ae3d0e64114f74b8f7c42bffa7
|