Compatibility¶
The contract¶
- At
-n 0: byte-exact. One vendored-pytest session over your arguments. Any behavioral difference at-n 0is a bug in rstest. - In parallel modes: outcomes preserved for parallel-safe tests. Identical per-test outcomes (setup/call/teardown, skips, xfails) for tests without hidden timing/ordering/shared-state assumptions. Tests with such assumptions can flake under concurrency — the same class of flake pytest-xdist produces — and the parallel safety rails exist for them.
What "verified" means¶
Compatibility is measured, not asserted: rstest's battery runs four real
suites — pandas (193,627 tests), aiohttp, django-allauth, rich — under
pytest and under rstest, and diffs per-test outcomes (every phase,
every skip reason class, xfail flags). All four measure 100% parity per run
at the worker counts in benchmarks (-n 8 for
the big suites, -n 4 for the small ones), re-run on every release, with
their real plugins loaded: pytest-django, pytest-asyncio, pytest-aiohttp,
hypothesis, pytest-mock, pytest-cov installed. (Two suites contain tests
that flake under plain pytest itself — rich, django-allauth — so on some
runs the baseline and rstest disagree at ~99.x%; every such case is
catalogued in Parity divergences.)
Summary-line accounting (passed/failed/skipped/xfailed/warnings counts) matches pytest's numbers on the same suites.
Vendored pytest version¶
rstest currently vendors pytest 9.1.1, unmodified. Policy:
- The vendored version is pinned per rstest release and stated in
License and
rstest_worker._vendor. - Upstream pytest minor releases are adopted by re-vendoring verbatim and re-running the full compatibility battery.
- Security fixes: when upstream pytest ships a security fix affecting the vendored code, an rstest release with the re-vendored core is expected within two weeks of the upstream release. Because the vendored tree is verbatim, re-vendoring is mechanical; the two weeks budget the compatibility battery, not the patch.
- Local modifications to the vendored tree are forbidden; integration
lives in
rstest_workeraround it.
If your suite is pinned to an older pytest
Adopting rstest implicitly adopts the vendored pytest's major version:
a suite (or plugin set) that isn't pytest-9-clean will see pytest 9
behavior inside rstest workers, whatever pytest version is installed.
There is currently no older-core build, and none is planned: one
vendored core, tracked forward. When a new pytest MAJOR ships, the
core is re-vendored after the early point releases stabilize — the
same timing a cautious team upgrades pytest itself. Minor releases
are folded in routinely; security fixes within two weeks.
Run rstest -n 0 first — it
surfaces version incompatibilities exactly as a pytest upgrade would.
Why 9, not 8¶
The 8→9 gap is unusually small for a major bump, which is why rstest
vendors 9. pytest 9 is a cleanup major, not a redesign: it removes
APIs that already emitted deprecation warnings throughout the 8.x line and
keeps the same collection model, fixture engine, _pytest.* import paths,
and plugin/pluggy hook contract. The runtime requirements are
effectively the same as 8.x — same supported-CPython line, same core
dependencies — so vendoring 9 doesn't raise the bar to adopt rstest beyond
what running pytest 8 already required.
What that means in practice:
- A suite that runs clean on a recent pytest 8.x with no deprecation warnings is almost always already pytest-9-clean — the removed APIs are exactly the ones 8.x was warning you about.
- The realistic migration cost is auditing those warnings, not rewriting
tests.
rstest -n 0(orpytest -W error::DeprecationWarningon your current pytest first) surfaces them. - Vendoring 8 would buy almost nothing — the same suites pass on both — while immediately leaving rstest a major version behind upstream. Tracking 9 forward keeps the vendored core current for the same near-zero cost.
If your suite is not yet warning-clean on pytest 8.x, treat the rstest switch as "clear pytest deprecations first, then change one command" — the same upgrade you'd owe pytest itself within a release or two anyway.
Measured at scale¶
Beyond the four-suite battery, the public-suite corpus runs rstest
against 31 well-known projects. The one that matters for advanced
xdist users: SQLAlchemy (25,300 tests) runs at -n 4 with its
master-side hooks exercised end-to-end — pytest_configure_node
filling follower_ident, follower databases provisioned per worker,
pytest_testnodedown dropping them — with outcomes identical to its
serial pytest run. Scope honestly stated: the default SQLite
backend at -n 4, crash-free; Postgres/MySQL backends and
crash-during-provisioning behavior are not yet in the battery (tests
requiring live services or absent optional dependencies fail
identically under vanilla pytest).
Known gaps¶
Honest list, maintained as things close:
| Gap | Status |
|---|---|
| Windows at corpus scale | supported — the full gate runs on windows-latest in CI every commit and wheels are smoke-tested there; the 31-suite public corpus, however, is run only on macOS/Linux, so large-real-world-suite validation on Windows is lighter than on the other platforms |
| Terminal-rendering plugins (pytest-sugar, pytest-rich UIs) | by design at -n ≥ 2 — rstest owns the terminal; data-level plugin behavior unaffected |
hypothesis's shared .hypothesis example database under many workers |
untested at high worker counts; hypothesis itself handles concurrent DB access, but rstest has not verified it beyond -n 8. Mitigation if you hit contention: in a settings profile give each worker its own DB — database=DirectoryBasedExampleDatabase(f".hypothesis/{os.environ.get('RSTEST_WORKER_ID', 'main')}") — or set database=None in CI to disable it entirely |
--sw (stepwise, --stepwise-skip, --stepwise-reset) |
runs in a single pytest session automatically (like --pdb/-s/--co) — the vendored stepwise plugin owns resume/stop and its cache/stepwise round-trips exactly as upstream. Sequential by nature: stop-at-first-failure + resume-from-a-single-cursor has no meaning under split, duration-ordered parallel dispatch, so it does not run at -n ≥ 2. Same constraint as xdist. |
xdist master-side hooks (pytest_configure_node and friends) |
emulated for hooks that are per-node-stateless (read gateway.id, fill node.workerinput — SQLAlchemy's pattern, measured). Structural divergences from a single xdist controller: the hooks run N times concurrently in N processes (controller-side shared state needs rework), and crashed-node pytest_testnodedown runs on a survivor without the dead node's configure-time state. Details: xdist hook emulation. |
| Plugins needing a controller-side service shared across all workers | rstest runs no central controller, so a plugin that needs one shared service for the whole pool isn't emulated. The known ecosystem cases are instead handled per worker: pytest-retry's branch self-provisions its own report server per worker (its server_port is set locally, no master needed) and pytest-rerunfailures is neutralized in favor of native --reruns — both work at -n ≥ 2. See parity divergences §8. |
Time-derived parametrize IDs (now() in @pytest.mark.parametrize) |
collection runs once per worker, so time-dependent IDs differ between workers; rstest detects the mismatch and refuses to dispatch rather than misattribute results — use stable IDs or -n 0 (same constraint as xdist) |
| Plugins that need a single master process to aggregate worker output into one artifact (pytest-html) | pytest-html registers its report writer only on a node without workerinput (its xdist master check); every rstest worker has one, so at -n ≥ 2 no writer is registered and --html silently produces nothing (no crash). Merging all workers into one file needs a master process rstest doesn't run. Generate the report at -n 0/-n 1. (Formerly this row also listed pytest-rerunfailures/sock_port and pytest-retry/server_port, both now handled, and claimed a pytest-html TypeError — that path is fixed by signature-aware node-hook dispatch; pytest-randomly's derivable randomly_seed is synthesized.) Full per-plugin table in Plugins |
Found a difference not listed here? That's a bug report we want.