Monorepos¶
A Python monorepo — many packages, each with its own pytest configuration and test tree — is something pytest cannot run from the root: one invocation means one rootdir, one ini file, and colliding conftest trees. The usual workaround is N serial pytest invocations with no shared scheduling and no merged result.
rstest runs the whole repo in one command:
$ cd my-monorepo && rstest
rstest 0.4.0 — monorepo: 3 projects, 8 workers (libs/cli:-n2, libs/core:-n4, services/api:-n2)
=============== project: libs/core ===============
...
=============== monorepo summary ===============
libs/cli ok
libs/core ok
services/api FAILED (exit 1)
3 projects in 41.20s (exit 1)
How projects are found¶
Monorepo mode engages automatically when the current directory has no
pytest configuration of its own but subdirectories do — rstest discovers
each package by its pytest config and runs them all. To restrict or pin the
set, list globs in the root pyproject.toml:
Passing an explicit path (rstest libs/core) opts out of monorepo mode and
runs that project alone, exactly as before. The full discovery rules (search
depth, which config files count, what's pruned) are in
Monorepo mode.
How it runs¶
Each project is an isolated child run — its own rootdir, ini, and conftests, no cross-project leakage. Projects run concurrently under one worker budget, split across them by each project's last-known suite time, so a repo dominated by one package finishes in roughly that package's wall time:
$ rstest # langgraph monorepo, 14-core machine
rstest 0.4.0 — monorepo: 6 projects, 14 workers (libs/langgraph:-n9, libs/checkpoint:-n1, libs/cli:-n1, libs/sdk:-n1, libs/prebuilt:-n1, libs/checkpoint-sqlite:-n1)
...
6 projects in 245.7s # cold run; six serial pytest invocations: 880.4s (3.6×)
The 245.7s figure is the measured cold (first) run. A warm run — planned from the duration caches the first run writes — is projected at 121–133s (6.6–7.3×); see Benchmarks.
A project can pin its own [tool.rstest] (numprocesses = 0 for byte-exact
mode, its own dist/reruns/worker-timeout); root command-line flags
override everywhere. Results merge into one exit code and one
--report-json; JUnit/coverage are written per project. --changed is
monorepo-aware — it skips packages no change can reach. For the exact
per-flag behavior (exit merge, report-json shape, JUnit slug rules,
--changed dependency edges, --output json refusal), see
Monorepo mode.
Environments¶
Projects share the active virtualenv by default — the uv-workspace
layout, and editable installs of sibling packages into a single venv,
both work naturally. A project with its own .venv automatically
uses it (the project-local interpreter beats the inherited
environment); an explicit --python overrides everything.
tox / nox¶
rstest replaces the pytest invocation, not the environment manager:
inside a tox or nox env, rstest works as a drop-in for the pytest
command (workers use that env's interpreter). Replacing the matrix
itself — one rstest invocation spanning multiple Pythons — is not
supported; keep the matrix in tox/CI and put rstest inside each cell.
Validation¶
The reference target is langchain-ai/langgraph: 8 libs/* packages,
each with its own [tool.pytest] config. rstest at the repo root
discovers all 8 (the JS package is correctly skipped — no Python
config). The measured subset below is the six libs that need no live
services; the other two (postgres-backed checkpoint stores) require a
running database under any runner. One command at the root replaces six
serial pytest invocations and cuts wall time several-fold, with per-lib
outcomes matched to the digit — including the dominant package's
fail/pass/error signature, which its service-dependent tests produce
identically under vanilla pytest. The corpus run measured 100% per-test
parity across all 4,284 tests. The one fragile spot is a TTL timing test
that langgraph's own source marks @pytest.mark.flaky; it lives in
checkpoint-sqlite, a small suite the corpus runs single-worker (-n 0).
That pin was once forced by a pytest-retry limitation (server_port); it is
now resolved — pytest-retry runs its @pytest.mark.flaky marker correctly
under the pool too (see Benchmarks for
the wall times and the policy).