A pytest plugin that passively tracks every test's wall-clock duration across runs and flags regressions and newly-slow tests.
Project description
pytest-speedguard
A pytest plugin that passively tracks every test's wall-clock duration across runs, keeps a noise-tolerant rolling baseline, and flags tests that regress (get meaningfully slower) or newly appear as slow. Cross-platform, with zero runtime dependencies beyond pytest — the statistics are implemented in pure Python.
Badges reflect live state: CI shows the real GitHub Actions result and the PyPI badge shows the real published version (unpublished = "not found").
The problem: test suites rot silently
A suite creeps from 2 minutes to 12 minutes over a year and nobody notices the single test that quietly got 5× slower. Each individual run looks "basically the same as last time," so the slowdown hides in the noise until CI is painfully slow and the culprit commit is long gone.
Why pytest-benchmark doesn't cover this
pytest-benchmark is excellent,
but it solves a different problem: deliberate microbenchmarks you write by
hand for a handful of hot functions (benchmark(func, ...)). It won't tell you
that test_import_users in your integration suite doubled last Tuesday, because
you never wrote a benchmark for it.
pytest-speedguard is passive and whole-suite: it watches every test
you already have, needs no code changes, and answers a different question —
"did anything in my existing suite get slower?"
Install
pip install pytest-speedguard
The plugin auto-registers via pytest's pytest11 entry point — nothing else to
configure.
Quickstart
# 1. Run once to seed a baseline (every test is "new" — nothing to compare yet).
pytest
# 2. ... time passes, code changes ...
# 3. Run again. Tests that got meaningfully slower are flagged.
pytest
Example output when something regressed:
=============================== speedguard ================================
Top regressions:
test baseline current delta n
---------------------------- -------- ------- ------- -
tests/test_api.py::test_sync 208.0ms 504.5ms +142.5% 8
1 regression, 0 new slow tests (threshold 50%, floor 50ms)
By default this is informational (it never fails your build). Add
--speedguard-fail to turn it into a CI gate.
How the statistical model works (and why)
For each test, the plugin keeps a rolling window of recent durations. A test is flagged as a regression only if all four guards agree:
- Noise floor —
current >= min-duration(default 50 ms). Sub-50ms timings are dominated by scheduler jitter; a test going from 1ms to 3ms is noise, not a regression. Kills tiny-fast-test false positives. - Warm-up guard — there must be at least
min-samples(default 3) baseline samples. With fewer, the test is still "warming up" and can't be judged. Stops a brand-new or barely-seen test from firing on its second run. - Relative guard —
current > median × (1 + threshold)(default +50%). The robust median of the window is the reference, not the mean, so one slow outlier in the history doesn't move the bar. - Absolute statistical guard —
current > median + noise_factor × scaled_mad(defaultnoise_factor3.0).scaled_mad = 1.4826 × MAD(median absolute deviation) is a robust, outlier-resistant estimate of the standard deviation. This demands the jump be larger than the test's own normal run-to-run jitter. Kills CI-runner-noise false positives on tests that are naturally variable. When dispersion is zero (few or identical samples) this guard is skipped and the decision rests on the relative guard, so we never divide by — or flag on — zero dispersion.
Using median + MAD instead of mean + standard deviation is deliberate: test timings on shared CI runners are heavy-tailed, and robust statistics ignore the occasional 10× outlier from a noisy neighbour instead of letting it poison the baseline.
New slow tests (no baseline history yet) whose duration is at or above the floor are listed separately so you notice expensive newcomers, but they are never counted as regressions.
Options
Every option is available on the command line and as an ini key (in
pytest.ini, tox.ini, or pyproject.toml's [tool.pytest.ini_options]).
The command line wins over ini.
| CLI flag | ini key | Type | Default | Description |
|---|---|---|---|---|
--speedguard / --no-speedguard |
speedguard |
bool | true |
Master switch. |
--speedguard-threshold |
speedguard_threshold |
float | 0.5 |
Relative regression threshold (0.5 = +50%). |
--speedguard-min-duration |
speedguard_min_duration |
float (s) | 0.05 |
Ignore tests faster than this (noise floor). |
--speedguard-noise-factor |
speedguard_noise_factor |
float | 3.0 |
Require current > median + factor × scaled_mad. |
--speedguard-window |
speedguard_window |
int | 20 |
Rolling window size (samples kept per test). |
--speedguard-min-samples |
speedguard_min_samples |
int | 3 |
Minimum baseline samples before flagging (warm-up). |
--speedguard-phase |
speedguard_phase |
call | total |
call |
Time the test body (call) or setup+call+teardown (total). |
--speedguard-update |
speedguard_update |
auto | always | never |
auto |
Baseline update policy (see below). |
--speedguard-accept |
— | flag | off | Re-baseline: record all current durations, suppress reporting, don't fail. |
--speedguard-fail |
speedguard_fail |
bool | false |
Fail the session (non-zero exit) on any regression — the CI gate. |
--speedguard-baseline |
speedguard_baseline |
path | .pytest_speedguard/baseline.json |
Baseline file location. |
--speedguard-top |
— | int | 20 |
Max rows per report section. |
Update modes and --accept
Controls how the baseline window is updated after a run:
auto(default) — append the new sample to the rolling window only if the test did not regress. A genuine regression therefore keeps firing on every run until it's fixed or explicitly accepted, instead of being silently absorbed into the baseline.always— always append (a pure rolling window). Simpler, but it only ever catches sudden spikes, because gradual slowdowns are continuously absorbed.never— compare only, never write. The baseline is frozen. Recommended for CI against a committed baseline file (see below).
--speedguard-accept is the deliberate way to move the baseline after an
intended slowdown: it records all current durations as the new baseline,
suppresses regression reporting for that run, and won't fail on regressions.
# You intentionally made things slower and that's fine — accept the new normal:
pytest --speedguard-accept
Known limitation: slow-creep under the per-run threshold
A rolling baseline (auto/always) compares each run against recent
history. If a test creeps up by, say, 3% every week — always staying under the
per-run threshold — each run looks fine while the baseline slowly drifts up with
it. Over a year that's a large regression that was never flagged, because no
single run ever crossed the line.
Mitigation — freeze the reference in CI:
- Seed and commit a baseline file to your repo:
pytest --speedguard-update=always # build up samples over a few runs git add .pytest_speedguard/baseline.json git commit -m "Freeze test duration baseline"
- In CI, compare against that fixed reference and never rewrite it:
pytest --speedguard-update=never --speedguard-fail
Now drift is measured against a fixed point in time, so slow-creep
accumulates against a stable reference and is caught. Re-baseline deliberately
(--speedguard-accept, then commit the file) whenever you accept a new
normal.
Parallel runs (pytest-xdist)
pytest-speedguard is xdist-aware in v1 via controller-side
aggregation. Workers just run tests and stream their reports back to the
controller; all comparison, reporting, and baseline writing happen only on the
controller (guarded by hasattr(config, "workerinput")), so there are no
racing writers and the baseline stays consistent. Just run pytest as usual:
pytest -n auto
Baseline JSON schema
The baseline is a small, human-readable JSON file written atomically (temp
file + os.replace) so an interrupted run can never corrupt it. A missing or
corrupt file is treated as an empty baseline (it never crashes your run).
{
"schema": 1,
"phase": "call",
"tests": {
"tests/test_api.py::test_sync": { "samples": [0.201, 0.199, 0.204] },
"tests/test_api.py::test_list[page-2]": { "samples": [0.512] }
}
}
schema— on-disk format version.phase— which timing phase the samples represent (callortotal). Loading a baseline recorded under a different phase resets it, since the numbers aren't comparable.tests— a map of pytest node id → rolling window of recent durations (seconds). Parametrized tests have distinct node ids (e.g.test_x[a-1]) and are tracked separately.
Development
pip install -e .[dev]
pytest -q
License
MIT — see LICENSE.
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_speedguard-0.1.0.tar.gz.
File metadata
- Download URL: pytest_speedguard-0.1.0.tar.gz
- Upload date:
- Size: 22.9 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
55bd38b47244eb9ae4f2f191f372baf3516fa805517377019bdf076b637fe0b8
|
|
| MD5 |
7c3149fa1679131650388f947c8cd288
|
|
| BLAKE2b-256 |
e133fb0e762c67d4fa81d7b1e5f7d688dcb5e0580ebc6d30a01419dc1d8889de
|
Provenance
The following attestation bundles were made for pytest_speedguard-0.1.0.tar.gz:
Publisher:
publish.yml on az-pz/pytest-speedguard
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
pytest_speedguard-0.1.0.tar.gz -
Subject digest:
55bd38b47244eb9ae4f2f191f372baf3516fa805517377019bdf076b637fe0b8 - Sigstore transparency entry: 2173480702
- Sigstore integration time:
-
Permalink:
az-pz/pytest-speedguard@2d5cb27b3b2dc03b5e896a1ebb31a3d9cf236f87 -
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@2d5cb27b3b2dc03b5e896a1ebb31a3d9cf236f87 -
Trigger Event:
release
-
Statement type:
File details
Details for the file pytest_speedguard-0.1.0-py3-none-any.whl.
File metadata
- Download URL: pytest_speedguard-0.1.0-py3-none-any.whl
- Upload date:
- Size: 19.5 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 |
aff7809012c71f35457f77ccb9963f90f2972769551052fdfc3ef2bec8d5b96d
|
|
| MD5 |
5905d7ffffebaa201ce202ee2d886c87
|
|
| BLAKE2b-256 |
bd108ee3db6228ae6826998965e9a5283543947aafb1f3befad513e495926f21
|
Provenance
The following attestation bundles were made for pytest_speedguard-0.1.0-py3-none-any.whl:
Publisher:
publish.yml on az-pz/pytest-speedguard
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
pytest_speedguard-0.1.0-py3-none-any.whl -
Subject digest:
aff7809012c71f35457f77ccb9963f90f2972769551052fdfc3ef2bec8d5b96d - Sigstore transparency entry: 2173480716
- Sigstore integration time:
-
Permalink:
az-pz/pytest-speedguard@2d5cb27b3b2dc03b5e896a1ebb31a3d9cf236f87 -
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@2d5cb27b3b2dc03b5e896a1ebb31a3d9cf236f87 -
Trigger Event:
release
-
Statement type: