Monorepo mode¶
How rstest runs a multi-package repo as one command. For the task-oriented quickstart — running it, pinning the project set, environments, tox/nox — see the Monorepos guide. This page is the reference for what the mode guarantees and how each flag behaves across projects.
Discovery¶
Monorepo mode engages when the current directory has no pytest
configuration of its own but subdirectories do. Discovery descends at most
five directory levels below the root (projects nested deeper are not found —
list them in projects explicitly), looking for any of pytest's config files
(pytest.ini, pyproject.toml with [tool.pytest.ini_options], tox.ini,
setup.cfg) — a pyproject.toml without a pytest section does not count.
Hidden directories, virtualenvs, node_modules, and site-packages are
pruned, and a found project owns its subtree (nested configs belong to it).
Restrict or pin the set with projects globs in the root pyproject.toml:
Passing an explicit path (rstest libs/core) opts out of monorepo mode and
runs that project alone.
Session isolation¶
Each project runs as its own full session group — an isolated child run with the working directory set to the project, so rootdir, ini options, and conftest loading behave exactly like running pytest inside that directory. There is no cross-project fixture or conftest leakage by construction, and caches (durations, lastfailed) live in each project where they belong.
Per-project [tool.rstest] settings are honored: a project that pins
numprocesses keeps it — numprocesses = 0 runs that project in
single-worker byte-exact mode (the escape
hatch for order-sensitive suites) while its siblings split the remaining
budget. dist, reruns, and worker-timeout set in a project apply to that
project; flags given on the root command line override everywhere.
Worker budget and scheduling¶
Projects run concurrently under one worker budget: your -n (or auto)
is split across projects weighted by each project's last-known suite time (its
duration cache), minimum one worker each. A repo where one package dominates
finishes in roughly that package's own wall time — the small ones ride along
on spare workers.
First runs (no duration caches yet) split the budget evenly; from the second run on, the weights kick in. Output is printed per project, in completion order, each block whole.
Scale note: every project gets at least one worker and all projects launch
concurrently — a 40-package repo on a 2-core CI runner means 40 concurrent
single-worker children, which oversubscribes. On small runners, shard with
[tool.rstest] projects (or path arguments) until a project-level concurrency
cap exists.
Output and artifacts¶
- Exit code is the merge of per-project exits (pytest semantics: failures dominate; "no tests collected" only if every project says so). See Exit codes.
--report-jsonwrites ONE merged document at the requested path: test keys are root-relative nodeids (libs/core/tests/test_x.py::test_y— what pytest would call them from the root),meta.exitstatusis the merged exit, andmeta.projectsmaps each project to{"exitstatus": N, "counts": {...}}or{"skipped": true}(skipped by--changed). No globbing, no client-side merging. See Report JSON for the exact shape.--junitxmland--doctor-jsonare written per project with the project slug inserted before the extension (JUnit consumers want one testsuite file per package). The slug is the project's path relative to the root with separators replaced by-:libs/core->junit.libs-core.xml,services/api->junit.services-api.xml. Files anchor at the invocation directory. A project skipped by--changedwrites no files.- Output files are written as each project finishes (each project is an isolated child run) — a hang in one package does not cost you the completed packages' JUnit/report files.
--outputstyle is forwarded to every project, sodots,verbose,bar, andgithuball apply per package (each project's block is captured and reprinted under its header;github::errorannotations are rewritten with the project's root-relative path so they land on the right file in the PR diff).--output jsonis refused at a monorepo root — the per-project banners make a single clean NDJSON stream impossible; use the merged--report-jsondocument, or run--output jsoninside one project.- Coverage works per project: workers write their data files in each project's directory and the combined report renders inside that project's output block; projects cannot cross-contaminate (verified by the test gate).
--pdb/-s/--collect-onlyneed a single pytest session: run them inside one project.
Changed-aware runs¶
--changed is monorepo-aware. Changed files are classified once at the root:
projects containing changes run with --changed (their own import graph
narrows further); projects depending on a changed project — via
[project].dependencies, optional dependencies, or [dependency-groups],
transitively — run their full suite (their own files didn't change, so there
is nothing to narrow by); everything else is skipped outright. Changes
outside every project (root configs, shared scripts) conservatively run
everything in full. Dependency-group edges count on purpose: a package whose
dev group installs a sibling runs that sibling's code in its tests.
Plain --changed edges come from declared metadata only — a package
importing a sibling without declaring it would be skipped incorrectly. For
gating (merge queues), use
--changed-strict: it scans each
project's imports and counts undeclared sibling imports as edges (warning
loudly), forces a full run for any changed file the graph can't connect to a
test, and exits 5 when nothing ran. The one residual hole is imports built
from runtime strings — if your repo does that across packages, keep full runs
on the gating path.