pytest-rs is a re-implementation of the popular Python testing framework pytest in Rust, focused on speed: a drop-in compatible runner where startup, collection, fixture orchestration, coverage measurement, and reporting are native code, while test bodies run on embedded CPython.
Note: This project is currently in active development (alpha stage). Many features are still under implementation and subject to change. See docs/DESIGN.md for the architecture and roadmap.
pytest-rs is an independent project, not affiliated with or endorsed by the pytest project.
Prebuilt wheels are published to PyPI for Linux (x86_64 / aarch64) and macOS (arm64) on CPython 3.13 / 3.14:
uv add --dev pytest-rs # or: pip install pytest-rsThen run your existing suite, no changes needed:
pytest-rs # whole suite, like `pytest`
pytest-rs tests/test_foo.py # one file
pytest-rs -n 4 # parallel workers (pytest-xdist compatible)
pytest-rs --cov=mypkg # native coverage (pytest-cov compatible)pytest-rs reads the same configuration pytest does (pytest.ini, pyproject.toml [tool.pytest] / [tool.pytest.ini_options], tox.ini, setup.cfg) and understands the familiar flags (-v, -x, -k, -m, --lf, --tb=..., -p no:NAME, ...). It leaves the pytest command itself untouched, but it does install an importable pytest package, so import pytest (fixtures, pytest.raises, pytest.mark, pytest.approx, ...) works standalone too — e.g. from a subprocess that never runs through the pytest-rs binary.
Don't install this alongside the real
pytestpackage in the same environment: both distributions claim the samepytestimport path, and pip/uv have no awareness that they conflict — installation order decides which files end up insite-packages/pytest/, and neither installer cleanly removes the other's.When switching a project over,
pytestitself,pluggy, andiniconfigare the only packages pytest-rs supersedes — safe to drop. Any other pytest plugin the project uses (pytest-django,pytest-aiohttp, ...) is a real, separately-installed package unless it's one of the plugins listed under Bundled plugins below, and must stay installed for its fixtures/hooks to keep working.Watch for
pytestarriving transitively: every pytest plugin declares a dependency on it, so keeping one of the bundled plugins installed (pytest-mockfor itsMockerFixtureannotation, say) pulls the realpytestback in behind your back.uv tree --package pytest/pip show pytestafter a lock change is the quick check; the annotations those plugins are usually kept around for ship with pytest-rs itself (see Type annotations).
Because every plugin depends on pytest, the obvious uv add --dev pytest-aiohttp installs the real distribution on top of pytest-rs's files. Install the plugin without its dependencies instead — the ones pytest-rs supersedes are already in place — and add back only the dependencies it genuinely needs.
Take pytest-aiohttp==1.1.1. It requires pytest, pytest-asyncio, and aiohttp: the first two are provided by pytest-rs, the third is a real package you still need.
One-off (a CI step, or trying a plugin out):
uv pip install --no-deps pytest-aiohttp==1.1.1 # or: pip install --no-deps ...
uv pip install aiohttp # its non-pytest dependencies, explicitlyFor a locked project, keep the plugin in your dependency groups and override the requirements pytest-rs supersedes with a marker that never matches — uv then resolves the plugin but installs neither pytest nor pytest-asyncio:
[dependency-groups]
dev = ["pytest-rs", "pytest-aiohttp==1.1.1"]
[tool.uv]
override-dependencies = [
"pytest ; sys_platform == 'never'",
"pytest-asyncio ; sys_platform == 'never'",
]uv sync on that project installs pytest-rs, pytest-aiohttp, and aiohttp (plus aiohttp's own deps) — and no pytest. Confirm with uv pip list | grep -i pytest: seeing a bare pytest line means the real distribution got in and has overwritten part of pytest-rs's pytest/ directory.
Which names to override is just "whichever of pytest-rs's own bundled distributions the plugin asks for": pytest, pluggy, iniconfig, plus any of the bundled plugins — e.g. a plugin depending on pytest-asyncio (pytest-aiohttp) or on pytest-xdist (pytest-split-style plugins) needs that name overridden too. Everything else the plugin depends on is a normal package: leave it alone.
- Linux or macOS (no Windows support yet)
- CPython 3.13+ built with a shared libpython — true for uv-managed Pythons, python.org installers, Homebrew, conda, and distro packages. Plain pyenv builds need
PYTHON_CONFIGURE_OPTS="--enable-shared" pyenv install ....
The compatibility layers for pytest-asyncio, anyio's pytest plugin, pytest-mock, pytest-cov, pytest-split, pytest-benchmark and pytest-xdist-style -n parallelism are built in — no separate plugin installs (the anyio layer runs tests through the installed anyio library's backends, so anyio itself must be in the environment as usual). Two ways to turn features off:
Per project or per run, like pytest (works with the prebuilt wheel):
[tool.pytest.ini_options]
addopts = "-p no:benchmark -p no:split"At build time, when installing from source — bundled plugins are Cargo features, all enabled by default:
[tool.uv]
config-settings-package = { pytest-rs = { build-args = "--no-default-features --features asyncio,mock" } }A suite that annotates its fixture parameters keeps working: the wheel installs
pytest_mock, pytest_asyncio, pytest_cov and pytest_benchmark as real,
py.typed packages, so
from pytest_benchmark.fixture import BenchmarkFixture
from pytest_mock import MockerFixture
def test_x(mocker: MockerFixture, benchmark: BenchmarkFixture) -> None: ...type-checks and imports without the corresponding distributions installed — which
is what keeps the real pytest out of the environment (see the note above). Each
declares the surface pytest-rs's own fixture provides; where a bundled plugin is
narrower than upstream, the missing attribute is left undeclared rather than
typed and broken, so a type checker tells you before a run does.
pytest-split has no importable API to mirror (--splits/--group are
CLI-only), and the anyio layer uses the installed anyio package's own types.
Installed pytest11 entry points load through the pytest API shim — plugins pytest-rs does not reimplement can still work as-is. The supported surface includes fixtures, markers, pytest_addoption (plugin --flags and ini options), config.stash, custom hookspecs (pytest_addhooks), pytest_runtest_protocol/pytest_runtest_call hookwrappers, custom collectors (pytest_collect_file → File/Item), and terminal-reporter replacement (a plugin that unregisters the terminalreporter plugin and registers its own subclass takes over the output — pytest-rs suppresses its native rendering and drives the replacement through the same hooks pluggy would). Verified status:
| evidence | plugins |
|---|---|
| own upstream test suite runs under pytest-rs and gates CI (per-suite pass-rate in the Third-party plugins conformance table below) | pytest-timeout, pytest-randomly, pytest-env, pytest-socket, pytest-snapshot, pytest-ruff, pytest-rerunfailures, pytest-order, pytest-repeat, pytest-instafail, pytest-icdiff, pytest-metadata, pytest-subtests, pytest-mypy, pytest-bdd, pytest-django, pytest-aiohttp; anyio's own plugin module also loads this way |
functional smoke demo gates CI (conformance/plugin_smoke.py) |
Faker, time-machine, requests-mock, inline-snapshot (snapshot assertions + --inline-snapshot flag), pytest-run-parallel (--parallel-threads really runs each test on N threads) |
| reporter replacement — terminal output byte-diffed against real pytest 9.0.3 | pytest-pretty, pytest-sugar (progress bar, instant failures; activates on a tty or --force-sugar) |
| not reimplemented yet | pytest-html (needs the report data model exposed); syrupy (serializer/extension framework) |
A plugin that fails to import (e.g. it reaches into pytest/pluggy internals the shim doesn't provide) warns and is skipped without breaking the run. -p no:NAME and PYTEST_DISABLE_PLUGIN_AUTOLOAD opt out, like pytest.
Installing one of these into a pytest-rs environment needs care, since the plugin's own pytest dependency would reinstall the real distribution — see Adding a third-party plugin without pulling pytest back in.
Startup, collection, fixture orchestration, coverage measurement, and parallel workers are native Rust code. The main wins:
--covruns — coverage is collected viasys.monitoring(Python 3.12+ low-overhead instrumentation) instead ofcoverage.py's tracing hooks. Typically 2–3x faster, and the gap widens with suite size.-nparallel runs — workers fork off the already-warm parent interpreter by default (unix only), skipping per-worker interpreter/plugin/conftest import cost. Each forked worker still fires its ownpytest_configureand collects/appliespytest_collection_modifyitemsindependently, so behavior matches a freshly spawned worker. SetPYTEST_RS_DIST_SPAWN=1to opt back into spawning a fresh subprocess per worker (upstream xdist's model) instead.- Large collections — fixture resolution and parametrize expansion run in Rust; suites with thousands of tests see faster collection.
Benchmarks on open-source projects (macOS arm64, median of 3 warm runs), reproducible with bench/suites.sh — clones each suite at a pinned tag, installs pytest-rs into its venv, and times both runners.
| suite (tests) | mode | pytest | pytest-rs | speedup |
|---|---|---|---|---|
| marshmallow (1119) | (plain) |
0.44 s | 0.32 s | 1.4x |
| marshmallow (1119) | --cov |
0.86 s | 0.42 s | 2.0x |
| marshmallow (1119) | -n 3 --cov |
0.97 s | 0.45 s | 2.2x |
| click (1336) | (plain) |
1.25 s | 1.18 s | 1.1x |
| click (1336) | --cov |
2.08 s | 1.32 s | 1.6x |
| click (1336) | -n 3 --cov |
1.65 s | 1.19 s | 1.4x |
| networkx (6890) | (plain) |
30.77 s | 28.96 s | 1.1x |
| networkx (6890) | --cov |
128.01 s | 42.38 s | 3.0x |
| networkx (6890) | -n 3 --cov |
48.79 s | 22.12 s | 2.2x |
Small-to-medium suites see a 1.3–1.4x speedup even without coverage, thanks to lower startup overhead. On large suites where test bodies dominate, the plain-mode gap narrows; the bigger wins come from --cov and -n parallelism. Try it on your own suite:
hyperfine -w 1 'pytest -q' 'pytest-rs -q'
hyperfine -w 1 'pytest --cov=mypkg' 'pytest-rs --cov=mypkg'- unix only (no Windows)
- no
--pdb/ debugger integration yet - third-party pytest plugins are loaded via the
pytest11entry point and thepytestAPI shim; plugins reaching deep into pytest internals may not work (see "Third-party plugins" above for verified examples)
Compatibility is verified by running the upstream test suites of the libraries pytest-rs reproduces, unchanged, under pytest-rs (conformance/).
Current results (total = passed + failed + errors + skipped + deselected + known_failed; deselected = tests that never run, excluded via --deselect in conformance/suites.toml because running them would be flaky or environmentally invalid; known_failed = tests that run normally but fail for a known, permanent, accepted reason (matched by exact nodeid against the run's own output, so a test that starts passing again is simply counted as passed instead of masking a real fix); both count against the total but not toward conformance; files excluded = whole files that also fail under vanilla pytest, out of scope; updated automatically by conformance/runner.py — see conformance/RESULTS.md for per-file detail):
linux (CI-verified)
pytest & plugin ecosystem (the APIs pytest-rs reimplements):
| suite | tag | passed | failed | errors | skipped | deselected | known_failed | total | conformant % | files all-pass | files run | files excluded |
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| pytest | 9.0.3 | 2784 | 0 | 0 | 53 | 0 | 12 | 2849 | 99.6% | 54 | 54 | 61 |
| pytest-asyncio | v1.4.0 | 268 | 0 | 0 | 0 | 0 | 0 | 268 | 100.0% | 30 | 30 | 0 |
| pytest-mock | v3.15.1 | 87 | 0 | 0 | 1 | 0 | 2 | 90 | 97.8% | 1 | 1 | 0 |
| pytest-cov | v7.1.0 | 205 | 0 | 0 | 3 | 0 | 1 | 209 | 99.5% | 1 | 1 | 0 |
| pytest-xdist | v3.8.0 | 96 | 0 | 0 | 2 | 0 | 0 | 98 | 100.0% | 1 | 1 | 6 |
| pytest-split | 0.9.0 | 59 | 0 | 0 | 0 | 0 | 0 | 59 | 100.0% | 1 | 1 | 3 |
| pytest-benchmark | v5.1.0 | 122 | 0 | 0 | 1 | 0 | 0 | 123 | 100.0% | 6 | 7 | 6 |
| anyio | 4.13.0 | 3120 | 0 | 0 | 42 | 0 | 0 | 3162 | 100.0% | 26 | 26 | 0 |
Third-party plugins (not reimplemented — their own upstream test suites run under pytest-rs, loaded via the pytest11 entry-point shim):
| suite | tag | passed | failed | errors | skipped | deselected | known_failed | total | conformant % | files all-pass | files run | files excluded |
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| pytest-aiohttp | v1.1.1 | 7 | 0 | 0 | 0 | 0 | 0 | 7 | 100.0% | 2 | 2 | 0 |
| pytest-timeout | 2.4.0 | 42 | 0 | 0 | 1 | 0 | 0 | 43 | 100.0% | 1 | 1 | 0 |
| pytest-mypy | v1.0.1 | 76 | 0 | 0 | 2 | 0 | 0 | 78 | 100.0% | 1 | 1 | 0 |
| pytest-ruff | v0.5 | 10 | 0 | 0 | 0 | 0 | 0 | 10 | 100.0% | 1 | 1 | 0 |
| pytest-subtests | v0.14.2 | 32 | 0 | 0 | 0 | 0 | 0 | 32 | 100.0% | 1 | 1 | 0 |
| pytest-metadata | v2.0.4 | 10 | 0 | 0 | 0 | 0 | 0 | 10 | 100.0% | 1 | 1 | 0 |
| pytest-snapshot | v0.9.0 | 106 | 0 | 0 | 0 | 0 | 1 | 107 | 99.1% | 3 | 3 | 0 |
| pytest-icdiff | 0.5 | 10 | 0 | 0 | 0 | 0 | 2 | 12 | 83.3% | 1 | 1 | 0 |
| pytest-socket | 0.7.0 | 63 | 0 | 0 | 0 | 0 | 2 | 65 | 96.9% | 6 | 6 | 0 |
| pytest-order | v1.4.0 | 134 | 0 | 0 | 0 | 0 | 0 | 134 | 100.0% | 16 | 16 | 0 |
| pytest-repeat | v0.9.4 | 16 | 0 | 0 | 0 | 0 | 0 | 16 | 100.0% | 1 | 1 | 0 |
| pytest-instafail | v0.5.0 | 63 | 0 | 0 | 0 | 0 | 0 | 63 | 100.0% | 1 | 1 | 0 |
| pytest-env | 1.6.0 | 72 | 0 | 0 | 0 | 0 | 3 | 75 | 96.0% | 3 | 3 | 0 |
| pytest-rerunfailures | 9.1.1 | 47 | 0 | 0 | 1 | 0 | 0 | 48 | 100.0% | 1 | 1 | 0 |
| pytest-randomly | 4.1.0 | 37 | 0 | 0 | 0 | 0 | 0 | 37 | 100.0% | 1 | 1 | 0 |
| pytest-bdd | 8.1.0 | 138 | 0 | 0 | 1 | 0 | 0 | 139 | 100.0% | 35 | 35 | 0 |
| pytest-django | v4.9.0 | 215 | 0 | 0 | 1 | 0 | 0 | 216 | 100.0% | 12 | 13 | 0 |
Real-world projects (their suites run unchanged, as drop-in evidence):
| suite | tag | passed | failed | errors | skipped | deselected | known_failed | total | conformant % | files all-pass | files run | files excluded |
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| click | 8.3.1 | 1314 | 0 | 0 | 21 | 0 | 0 | 1335 | 100.0% | 20 | 20 | 0 |
| jinja | 3.1.6 | 909 | 0 | 0 | 0 | 0 | 0 | 909 | 100.0% | 22 | 22 | 0 |
| marshmallow | 4.1.1 | 1119 | 0 | 0 | 0 | 0 | 0 | 1119 | 100.0% | 12 | 12 | 3 |
| rich | v14.2.0 | 855 | 0 | 0 | 25 | 0 | 0 | 880 | 100.0% | 60 | 62 | 0 |
| sqlglot | v30.11.0 | 1120 | 0 | 0 | 0 | 0 | 0 | 1120 | 100.0% | 52 | 54 | 0 |
| httpx | 0.28.1 | 1410 | 0 | 0 | 1 | 0 | 7 | 1418 | 99.5% | 31 | 31 | 0 |
| httpx2 | v2.4.0 | 1426 | 0 | 0 | 1 | 0 | 0 | 1427 | 100.0% | 31 | 31 | 0 |
| starlette | 0.46.2 | 907 | 0 | 0 | 0 | 0 | 0 | 907 | 100.0% | 28 | 28 | 0 |
| attrs | 25.3.0 | 1341 | 0 | 0 | 5 | 0 | 0 | 1346 | 100.0% | 22 | 24 | 0 |
| more-itertools | v10.7.0 | 670 | 0 | 0 | 1 | 0 | 0 | 671 | 100.0% | 2 | 2 | 0 |
| werkzeug | 3.1.3 | 922 | 0 | 0 | 1 | 25 | 0 | 948 | 97.4% | 24 | 25 | 0 |
| fastapi | 0.115.12 | 2333 | 0 | 0 | 130 | 0 | 1 | 2464 | 100.0% | 304 | 310 | 0 |
| packaging | 25.0 | 26948 | 0 | 0 | 0 | 0 | 0 | 26948 | 100.0% | 12 | 12 | 0 |
| pandas | v3.0.3 | 160780 | 0 | 0 | 26985 | 0 | 8 | 187773 | 100.0% | 880 | 961 | 3 |
| networkx | 3.6.1 | 6815 | 0 | 0 | 79 | 0 | 0 | 6894 | 100.0% | 259 | 266 | 0 |
| pydantic | v2.11.7 | 5338 | 0 | 0 | 921 | 0 | 14 | 6273 | 99.8% | 79 | 82 | 0 |
| scikit-learn-1 | 1.9.0 | 8432 | 0 | 0 | 6624 | 0 | 0 | 15056 | 100.0% | 79 | 87 | 0 |
| scikit-learn-2 | 1.9.0 | 5046 | 0 | 0 | 1892 | 0 | 0 | 6938 | 100.0% | 52 | 58 | 0 |
| scikit-learn-3 | 1.9.0 | 9251 | 0 | 0 | 2530 | 0 | 3 | 11784 | 100.0% | 107 | 114 | 0 |
The suites are included as shallow git submodules under conformance/suites/ at the pinned release tags. Initialize them once after cloning:
git submodule update --init --depth 1Then run the full conformance harness:
cargo build
uv run --no-project python conformance/runner.py --local # uses submodules
uv run --no-project python conformance/runner.py # re-clones from upstream (CI mode)| Project | License | Tag |
|---|---|---|
| pytest | MIT | 9.0.3 |
| pytest-asyncio | Apache-2.0 | v1.4.0 |
| pytest-mock | MIT | v3.15.1 |
| pytest-cov | MIT | v7.1.0 |
| pytest-xdist | MIT | v3.8.0 |
| pytest-split | MIT | 0.9.0 |
| pytest-benchmark | BSD-2-Clause | v5.1.0 |
pytest-rs reimplements the public APIs of these projects, plus anyio's pytest plugin (MIT). Parts of the bundled Python shims are ports of upstream code; see THIRD-PARTY-NOTICES.md. Credit for the API design and the test suites belongs to their respective authors.
This project is licensed under the MIT License. See the LICENSE file for more details.