Run the tests of Python projects on every core, with the same coverage gate¶
- Status: accepted
- Date: 2026-10-14
Context¶
scripts/check.sh ran the tests of every Python project with coverage run -m pytest, one test after the other, on one core of the four that a runner of GitHub has:
- Home Assistant integrations: the test tools of Home Assistant pin pytest-xdist and pytest-cov, which no step used. ha-autodarts runs 1702 tests; with coverage, they took 152 seconds of its required check in the CI, and 89 instead of 304 seconds on four cores in a container. Once its end-to-end tests run only when they can change a result, this check is the longest one of a pull request.
- The blueprint: its 1400 tests took 92 seconds with coverage on four cores in a container of Linux, and 18 minutes on a Windows machine with other work, where Copier spends seconds on each project that it renders: it clones the blueprint with Git and asks Git about each file that it writes.
pytest -n 8alone gave little, because each process rendered all seven kinds of projects again. - No time limit: a test that hung held the check until the job ran out of its time.
Options¶
- Keep the tests serial, and make the slow ones faster one by one.
pytest -n autowith pytest-xdist and pytest-cov inscripts/check.shwhere both are installed, else the serial run as before; the plugins and pytest-timeout in the lock files of new Python projects; the rendered projects of the tests of the blueprint made once for the whole run.- Option 2 with
-n autoinaddoptsofpyproject.toml, so that a plainpytestruns in parallel too.
Decision¶
Option 2.
- The step: when the Python of the project imports pytest, pytest-cov and xdist,
python -m pytest -n auto --cov --cov-report= --cov-fail-under=0runs the tests in a process for each core, and pytest-cov measures each process and brings their coverage together in.coverage, which the CI shows as before.coverage reportthen applies[tool.coverage]ofpyproject.toml,fail_underamong them, and lists the lines that no test runs, as it did aftercoverage run. Without the plugins, the step runscoverage run -m pytestas before, so a project that keeps its own lock file without them keeps working.PYTEST_XDIST_AUTO_NUM_WORKERSsets the number of processes. - Not in
addopts: mutmut runs pytest inside its own process with the settings ofpyproject.toml, and a project without the plugins would fail on an option that it doesn't know;-nbelongs to the step. - New projects:
requirements-dev.txtof the Python stack pins pytest-xdist, pytest-cov and pytest-timeout, as the test tools of Home Assistant do already, andpyproject.tomlgives each test 30 seconds,timeout = 30, where only Home Assistant had it. Both files belong to the project, so an existing one adds them itself, as the changelog says. - The tests of the blueprint render from a copy of its files as Git sees them, without Git, so Copier doesn't clone it for each project; they don't ask Git about the index of a destination that is no repository. Each kind is rendered when a test first asks for it, once for the whole run: the first process that needs it renders it, and the others wait for it (
tests/shared.py). The tests only read these projects, and one that changes a project works on a copy; the Python that a test starts keeps its bytecode in a folder of the run. Each test of the blueprint gets five minutes.
---
config:
flowchart:
wrappingWidth: 400
---
flowchart TB
accTitle: How the tests run on every core
accDescr: scripts/check.sh asks the Python of the project for pytest-xdist and pytest-cov. With both, pytest starts a process for each core, which share the tests between them, and pytest-cov brings the coverage of every process together. Without them, coverage runs the tests one after the other. Either way, coverage report applies the settings of the project, such as fail_under, and lists every line that no test runs.
check["scripts/check.sh<br>step Tests, with every line and branch covered"]
ask{"pytest-xdist and pytest-cov<br>installed?"}
workers["python -m pytest -n auto --cov<br>a process for each core"]
combine["pytest-cov brings the coverage<br>of every process together"]
serial["coverage run -m pytest<br>one test after the other"]
report["coverage report<br>fail_under and the missing lines of the project"]
check --> ask
ask -->|yes| workers --> combine --> report
ask -->|no| serial --> report
classDef you fill:#f59e0b2e,stroke:#f59e0b,stroke-width:2px
classDef bot fill:#526cfe2e,stroke:#526cfe,stroke-width:2px
classDef done fill:#16a34a2e,stroke:#16a34a,stroke-width:2px
class check,ask,workers,combine,serial bot
class report done
Consequences¶
The tests of the blueprint take 41 instead of 92 seconds on four cores in a container of Linux, and 27 on sixteen; ha-autodarts' take about a third of their time once it has the release. Each test stands on its own: a test that relies on the state or the order of another one fails now and then, and the flaky-test bot reports it. A test that hangs fails after its time limit instead of holding the check. Each process imports the tests and their tools once more, which costs memory: on a machine with many cores and little free memory, PYTEST_XDIST_AUTO_NUM_WORKERS=4 bash scripts/check.sh runs fewer processes. A project without the plugins sees no change until it adds them.