merge
The whole of it: the report, and the JUnit XML when asked. --html-report is the only required flag.
A report folder has exactly one writer per build. Everything on this page follows from that one rule — why a parallel run needs no configuration, why a matrix needs a merge step, and why the merge is its own command rather than a fifth pytest run.
A build is not only an HTML file. Writing one means rotating the previous output.json into archive/, writing a new output.json, appending a point to the trend chart, applying archive retention, recomputing Suite Highlights, and re-reading every retained build to rebuild the Analytics histories.
So four matrix legs each writing a report into one folder do not add up to a build. They overwrite each other's output.json and manufacture four builds out of one run: four trend points, four archive files, four Analytics entries, and Suite Highlights counting every suite four times — with nothing left on disk afterwards to say which four belonged together.
There are two ways a run can be spread out, and they get two different answers.
pytest-xdist. The workers are children of one controller and share its filesystem, so they hand their records back in-process and the controller renders once. Nothing to configure.
shards/<id>/records.json + pytest_screenshots/; three arrows into a box labelled "CI artifact store"; one arrow out of it into a box labelled "pytest-html-reporter merge"; and out of that exactly one of each: pytest_html_report.html, output.json, archive/output_<ts>.json, junit.xml, one trend point. Caption underneath: "one run, one build".
Two simpler designs were rejected in both directions, and the reasons are what decide the file formats.
output.json files does not work. That file keeps only status, message, test_name, rerun and duration per test — no node id, no logs, no steps, no attachments, no phases. A merge built on it would produce correct totals over an empty report, and could not name a single test in a JUnit file.So the merge happens at the level of records — the dicts the plugin builds as each test finishes — and nowhere else.
Environment panel fills itself in. The CI system, a Pipeline link back to the build, the branch and the commit under test, the operating system, the interpreter and the worker count all arrive without a flag.A report is a build artifact: it is read a week later, by somebody who cannot re-run it and cannot ask the machine anything. The panel could already name the host, the Python and the pytest — the easy half — while the half that explains a red build was left to whoever remembered to write a --build-info flag before the run.
| Row | What it says |
|---|---|
CI | The CI system, named from its own variables, with the build number where one is published. |
Pipeline | A link straight back to the build that produced the report. |
Branch, Commit | The revision under test, from the CI system where it publishes them and from git otherwise. |
Host | The machine that ran the tests. |
Platform | The operating system as its own users name it — Ubuntu 22.04.4 LTS · Linux 5.15.0 (x86_64), macOS 15.6 (arm64) — rather than the kernel string. |
Python | Version, implementation and word size: 3.11.7 (CPython, 64-bit). |
Interpreter | The python that actually ran — the row that ends an argument about which virtualenv was active. |
pytest, Plugins | The framework and every plugin version active for the run. |
Workers | How many xdist workers reported results. Only on a parallel run. |
Packages | Every installed distribution and its version, when --report-packages asked for it. |
Arguments, Root | The command line the run was started with, and where it ran. |
GitHub Actions, GitLab CI, CircleCI, Buildkite, Azure Pipelines, Travis CI, AppVeyor, Drone, Bitbucket Pipelines, Semaphore, AWS CodeBuild, TeamCity and Jenkins are each named individually, from the variable that actually identifies them — GITHUB_ACTIONS, GITLAB_CI, CIRCLECI, TF_BUILD, JENKINS_URL and so on. Jenkins is tested last on purpose: BUILD_NUMBER and BUILD_URL are set by several of the others, and JENKINS_URL is what actually says Jenkins. Anything else that sets CI or CONTINUOUS_INTEGRATION is still recorded as a build agent rather than passed off as somebody's laptop.
GITHUB_RUN_ID deliberately does not change on a re-run, so the bare run url opens the latest attempt — a report written by attempt 1 would have linked somebody else's failure.
HEAD, where git itself answers HEAD — true and useless — while the system knows the branch somebody clicked. A folder that is not a checkout gets no such rows rather than a traceback.
branch, commit, ci or pipeline already named through --build-info or the build_info ini key is the answer shown, and the detected one is dropped rather than rendered beside it disagreeing.
3 of 8 requested when the two differ: xdist quietly runs fewer workers than asked for when there are fewer tests than workers, and a report claiming eight when three ran reads as five crashes. A serial run gets no row at all.
--report-profile=ci, and what ci means is committed beside
the tests rather than kept in YAML.A workflow file is a bad home for a report's settings. It is edited by whoever is fixing the pipeline, it is not what anybody runs locally, and the day the two drift apart is the day somebody opens a CI report and finds it missing the logs they went there for. A profile puts both shapes in one file, side by side, where the difference between them is the thing you can see:
[tool.pytest-html-reporter]
title = "PAYMENTS"
[tool.pytest-html-reporter.profiles.local]
open = "auto"
logs = "all"
archive_count = 10
[tool.pytest-html-reporter.profiles.ci]
open = "none"
logs = "failed"
attachments = "failed"
screenshots = "failed"
junit = "report/junit.xml"
archive_days = 30
- name: Run the suite
run: |
pytest tests/ -n auto \
--report-profile=ci \
--build-info branch="$GITHUB_REF_NAME" \
--build-info commit="$GITHUB_SHA" \
--build-info run="$GITHUB_RUN_ID"
$ pytest tests/ --report-profile=local
The pieces a workflow legitimately owns — the branch, the commit, the run id — stay on the command line, because the shell has to expand them and because they are facts about this build rather than about the shape of it. They add to what the profile says rather than replacing it, and a label named twice is one row carrying the higher layer's answer.
A matrix leg that needs one thing different does not need a profile of its own:
PYTEST_HTML_REPORTER_LOGS=all in that job's environment overrides the profile for that job
alone. And when a leg's report comes out wrong, pytest --report-profile=ci --report-show-config
prints what it resolved and which layer decided each setting, into the pytest header — which is the
part of a CI log people paste into issues. See
pytest-html-reporter config for the same answer
without running the suite.
Runs distributed with pytest-xdist are gathered into a single report with no configuration at all — one build in Archives, one set of totals, one row per test, whichever way the tests were distributed.
$ pytest tests/ -n 2 --html-report=./report
$ pytest tests/ -n auto --dist loadfile --html-report=./report
config.workeroutput and renders nothing.Screenshots are written by whichever process ran the test — the workers share the controller's filesystem — into pytest_screenshots/ under the report base. An image is named <milliseconds>[-<worker id>]-<counter>: milliseconds alone collided because two tests can finish inside the same one, the counter settles that, and the worker id settles the cross-process case.
Two more consequences worth knowing. The controller never runs a test, so run-wide totals cannot be accumulated as tests go by; they are counted off the merged record list at the end. And an -n 8 run that is also one leg of a matrix writes its bundle once, from the controller, after it has merged all eight workers' records — never eight times with an eighth of the tests each.
$ pytest tests/ -n 8 --html-report=./report --report-shard=1/4
pytest-rerunfailures runs the whole setup/call/teardown protocol again for every retry, so a retried test arrives at the reporter once per attempt. The plugin keeps one record per test and uses the rerun field to say how many attempts it stands for. There is no rerun-specific flag: the plugin reacts to pytest-rerunfailures being installed rather than configuring it.
rerun becomes the superseded record's count plus its own plus one, because the record being replaced is an attempt itself and either side may already stand for several. From 0.4.2 the record also carries the trail of what each of those attempts did.
Counting attempts is the only reliable signal available: --reruns, the ini key and @pytest.mark.flaky(reruns=n) can each set a different budget, and --only-rerun can stop the retries early, so no single number says how many attempts a given test will take.
The count then travels everywhere — into output.json as a per-test string and a per-suite total, into the JUnit <properties> as reruns and into each testcase's <system-out> as a reruns: N line, and into Analytics, where a non-zero rerun count is by itself enough to mark a test flaky. A retry inside a single build is the least ambiguous flake evidence there is: same code, same build, two different answers.
Rerun count is a button, and the panel behind it lists every attempt the test made — status, message, duration and, under -n, the worker that ran it — ending on the attempt the row itself is showing, marked kept.One row carrying the outcome that stuck is the honest shape, but on its own it throws the interesting half away: the row shows the message of that attempt, and an attempt that stuck by passing has no message. A test that failed twice and then passed read PASS 2, and nothing anywhere in the report said what it had failed with — while two failures for two different reasons is a different bug report from the same failure twice.
Each attempt keeps four fields and not the record it came from: status, message, duration and worker. A shard bundle is the record list exactly as it stands in memory, so keeping whole attempts would multiply every bundle a matrix uploads by the number of times its flakiest tests were retried, and put a copy of each discarded attempt's logs and screenshots in it.
Under -n a worker sends back records it has already folded itself, so the controller keeps both sides' attempts and puts the record being replaced between them — it ran after its own attempts and before the one replacing it. The trail is parked outside the metrics table, so it is absent from the table's search index and from its CSV, Excel and print exports, and the Rerun column still exports the single number it always did.
A test that ran once has no trail and keeps the plain 0 the column has always shown, with the button disabled rather than only styled flat so it is not a tab stop either. So does a build archived before 0.4.2: the count was stored, the attempts behind it were not, and an empty panel would be worse than none.
merge, builds a trail of its own, so a test retried twice on one shard that then ran again on another machine reports four attempts and shows four.A shard writes no report: no pytest_html_report.html, no output.json, no archive rotation. It writes one file — its record list exactly as it stands in memory, plus everything only that machine knows about itself — and the screenshots those records name.
| Flag | Default | What it does |
|---|---|---|
--report-shard |
"" |
Name this process as one leg of a sharded run. Writes its records to <report base>/shards/<id>/records.json and renders nothing. ini key: report_shard. |
--report-shard-merge |
false |
After writing this leg's bundle, merge every bundle beside it under <report base>/shards and render one report. Needs --report-shard, or the run fails with a usage error. ini key: report_shard_merge. |
--report-shard-run |
the CI system's own run id | The token saying which CI run this leg belongs to. A merging leg merges only bundles carrying the same token, and names the ones it put aside. ini key: report_shard_run. |
--report-shard-reset |
false |
Delete <report base>/shards entirely before this leg writes into it. For the first leg of a sequential run with no run token. Never implied by anything else, because it deletes the other legs' work. ini key: report_shard_reset. |
$ pytest tests/ --html-report=./report --report-shard=1/4
$ pytest tests/ --html-report=./report --report-shard=2/4
$ pytest tests/ --html-report=./report --report-shard=3/4
$ pytest tests/ --html-report=./report --report-shard=4/4
The shard is a directory under the report base, never a bare file beside the report. That is what makes merging in place safe: the merge clears <report base>/pytest_screenshots, while every source image sits under <report base>/shards/<id>/pytest_screenshots and is untouched.
Ids are sanitised into directory names. Everything outside A-Z a-z 0-9 . _ - becomes a dash, runs of dashes collapse, dots and dashes are stripped from both ends, and the result is capped at 64 characters.
| You pass | The directory is |
|---|---|
1/4 | 1-4 |
python3.11 (ubuntu) | python3.11-ubuntu |
ubuntu 22.04 | ubuntu-22.04 |
The raw value is kept as the shard's label — 1/4 reads better than 1-4 in the report — while the sanitised value is the id, which names the directory and the screenshot folder.
--report-shard=.. cannot resolve to the report base; a dot inside the id is left alone.Before a leg writes, it removes its own directory outright — never the whole report folder, which would take a sibling leg's screenshots with it, and never only pytest_screenshots, because a records.json left by a previous run of the same leg would survive and, if that run collected more tests, would be what the merge reads. If two differently-named legs sanitise to the same directory, the second write prints a warning naming both labels; it is never fatal, because this runs after every test in the leg has finished.
report/
└── shards/
├── 1-4/
│ ├── records.json
│ └── pytest_screenshots/
│ ├── 1788426639123-1.png
│ └── 1788426639771-gw0-2.png
├── 2-4/
│ ├── records.json
│ └── pytest_screenshots/
├── 3-4/
│ └── records.json
└── 4-4/
└── records.json
records.json. Upload the whole shards/<id>/ directory, bundle and pytest_screenshots/ together, or the merge will report every image as one the bundle named but did not carry.One file per leg, written atomically — to a temporary file in the same directory, flushed, fsynced, then replaced over the top. A bundle is written from the terminal-summary hook, which is the last thing a CI leg does before the job may be cancelled or the runner reclaimed, so what is on disk is either the previous file or the whole new one, never half of one.
The file is identified by its schema string, not by its name: anything whose schema is not exactly pytest-html-reporter/records is walked past with a note, because a folder of CI artifacts is full of other JSON. A bundle whose version is higher than the reader understands stops the merge by name — silently dropping a quarter of a matrix produces a report that is wrong in a way nobody looking at it can see. Unknown keys survive a round trip untouched, at both the payload and the record level.
{
"schema": "pytest-html-reporter/records",
"version": 1,
"generator": "pytest-html-reporter 0.4.3",
"shard": { "id": "1-4", "label": "1/4", "assets": "pytest_screenshots" },
"run": {
"session_start": 1788426000.11,
"session_end": 1788426724.53,
"exitstatus": 1,
"token": "github:1234567-2",
"collected": 203,
"hostname": "runner-1",
"platform": "Linux 6.5.0-1024-azure",
"python": "3.11.9",
"pytest": "8.2.0",
"plugins": ["cov-5.0.0", "html-reporter-0.4.3", "xdist-3.5.0"],
"arguments": "tests/ -n 4 --html-report=./report --report-shard=1/4",
"rootdir": "/home/runner/work/proj/proj",
"environment": "staging",
"build_info": [["Commit", "a1b2c3d"], ["Branch", "main"]],
"xdist_workers": ["gw0", "gw1", "gw2", "gw3"]
},
"coverage": { "percent": 84.12, "statements": 5120, "covered": 4307 },
"counts": { "records": 203, "collect": 1 },
"records": [ { "nodeid": "tests/test_login.py::test_valid_user" } ]
}
The run block is the machine's own account of itself — host, platform, interpreter, pytest version, plugins, command line, rootdir, environment, build info, what it kept of each test's output — captured here because the merge often runs on a machine that ran none of the tests. counts is there for inspect and is never trusted by the merge, which counts the records it actually read.
Unit, then integration, then e2e, on one machine, is the case where a fourth command to merge the three is a fourth thing to remember. The last leg passes --report-shard-merge, and three commands do the work of four.
$ pytest tests/unit --html-report=./report --report-shard=unit --report-shard-reset
$ pytest tests/integration --html-report=./report --report-shard=integration
$ pytest tests/e2e --html-report=./report --report-shard=e2e --report-shard-merge
<base>/shards is persistent, so it also holds whatever the last run left in it. A leg that was renamed or deleted between two CI runs leaves its bundle sitting there, and the next merging leg picks it up and reports tests that did not run — four tests run and the build says six, with nothing on the page saying where the other two came from. A clock cannot tell the two cases apart, because every bundle beside a merging leg was written before it, whether ten minutes ago by this run or yesterday by the last one. There are two answers, for two situations:
--report-shard-reset. It is taken from the CI system's own variables when you do not pass one — GITHUB_RUN_ID plus GITHUB_RUN_ATTEMPT on GitHub Actions, CI_PIPELINE_ID on GitLab, BUILD_TAG on Jenkins, CIRCLE_WORKFLOW_ID on CircleCI, BUILDKITE_BUILD_ID, BUILD_BUILDID on Azure Pipelines. A leg with no token merges everything, which is the only thing it can do: an empty token says nothing about which run a bundle came from.--report-shard-reset on the first leg, which empties <base>/shards before anything is written.[pytest]
html_report = ./report
report_shard_run = nightly-42
A merging leg that cannot merge — a bundle from a newer release, a duplicate under an error policy — says so and carries on rather than raising. This runs after every test has finished, and a traceback there would leave the run with no report and no verdict. Its own bundle is on disk either way, and the message names the folder and tells you to run the merge command once it is sorted out.
The merge is its own process, not another pytest run. A fifth pytest started in the report folder to do the merging would clean the screenshots on the way in and delete the images it was sent to collect. It is installed as a console script by setup.py and is also reachable as a module, which matters in containers that pip-installed a wheel, in tox environments and in a checkout somebody is trying the feature out in.
$ pytest-html-reporter merge ./artifacts --html-report ./report
$ python -m pytest_html_reporter merge ./artifacts --html-report ./report
It uses argparse and the standard library only. The package's install requirements are pytest and Pillow, and a merge command that needed a dependency of its own would be a command CI could not run.
The whole of it: the report, and the JUnit XML when asked. --html-report is the only required flag.
Stops after the XML, for a pipeline that publishes test results without publishing a report. No report base is written, so no screenshots are copied and no archive is rotated. Takes -o.
Stops before anything is written at all, which is what you want when four artifacts were downloaded and only three of them are there.
All three take the same positional PATH arguments — default, the current directory — and the same three shaping flags: --on-duplicate, --order and --strip-path-prefix. Those are shared deliberately, because they decide the answer rather than the output: inspect reporting a different set of tests from the merge that follows it would make it useless as a pre-flight check.
Paths may be directories, which are walked, or records.json files named directly, for a step that unpacked one artifact and knows where it put it. The walk is sorted at every level and duplicates are removed by absolute path. A folder of CI artifacts holds JUnit files, logs and a coverage report beside the bundles, and every one of them is walked past with a note rather than treated as a failure.
# one folder holding the downloaded artifacts
$ pytest-html-reporter merge ./artifacts --html-report ./report
# report and JUnit XML in one pass
$ pytest-html-reporter merge ./artifacts --html-report ./report --junit-xml ./report/junit.xml
# merging back into the folder the shards were written in - safe by design
$ pytest-html-reporter merge ./report --html-report ./report
# bundles named directly
$ pytest-html-reporter merge ./a/records.json ./b/records.json --html-report ./report
# XML only, no report
$ pytest-html-reporter junit ./artifacts -o ./junit.xml
# pre-flight: what is there, and what merging it would come to
$ pytest-html-reporter inspect ./artifacts
$ pytest-html-reporter inspect ./artifacts --json
The complete list of every merge flag is in the CLI reference. These are the ones that change the shape of a CI job.
| Flag | Default | What it does |
|---|---|---|
--html-report | required | Where the merged report is written: a folder, or the .html file itself. |
--junit-xml | "" | Also write the merged JUnit XML here. |
--on-duplicate | merge | What to do when one node id ran in more than one shard. Every fold is reported whichever is chosen. |
--order | shard | Row order: shard keeps each suite's rows in shard order, name sorts by suite and test name. |
--strip-path-prefix | none | Strip this prefix from the front of every node id, so shards that ran under different checkout roots group into one suite. Repeatable. |
--start-time | earliest | earliest, now, or a unix timestamp. Names the file the next build archives this one as, stamps output.json, labels the trend point and orders the builds in Analytics. |
--strict | off | Exit 1 when anything was quarantined, unreadable, folded, superseded, missing or carried a status this version has never heard of, or when the JUnit writer warned. The report is still written. |
--exit-code | off | Exit 1 when the merged build has any FAIL or ERROR. |
--dry-run | off | Merge and print the summary, writing nothing. |
--report-open | none | Deliberately not the pytest run's auto: a merge run on somebody's laptop to look at four downloaded artifacts must not steal a browser tab. |
--html-report is refused up front if it contains .html without ending in it. The report path decides between "a folder" and "a file" on whether .html appears in it, so ./my.html.d would be read as a file name and the report would land somewhere else — and by the time anybody looks for it the run has already happened. The archive flags are validated before anything is written, in the same words a pytest run uses.Every records.json under the given paths, in a stable order, without repeats. A bundle carrying no shard id is given its own directory's name as a fallback — hand-assembled bundles and artifacts unpacked by a step that renamed the folder both turn up without one.
Sorted by the natural key of the shard id, never by load order: runs of digits read as numbers, so 1-4, 2-4, 10-16 sort in that order. Two jobs downloading the same artifacts in different orders have to produce the same report, or the diff between two builds is unreadable. If two files claim one shard id, the one that finished later is kept and both paths are named in a note.
Every key is filled in from defaults and every type coerced, which is the whole defence against a bundle written by another release. Coercion is tolerant rather than strict: a duration that arrives as the string "1.2" or as null becomes 0.0 rather than losing the row. Then the node id is normalised, the suite name recomputed from it, and the record tagged with the leg it came from.
On completely different grounds. Every process collects the whole suite, so a broken import is expected in all four bundles and is not a duplicate at all: one record is kept per collector, an ERROR supersedes a SKIP, and for the same status the longer message wins. Tests follow the duplicate policy.
Four shards each number their tests from zero. Collect rows are renumbered ahead of every test, test rows densely and uniquely, so the report's own sort can never fall back to a worker name that means nothing across machines. Under --order shard a suite split across shards stays contiguous instead of appearing twice.
One set of run facts assembled from every bundle's own account, never one machine's answer generalised to all of them; and coverage through five branches and no sixth.
Through the same render path a plain pytest run drives. A merged build assembled by different code is a build that drifts away from every other one on the report's own history.
A node id that ran in more than one shard means the matrix overlaps, and somebody has to be told. There is no single right answer — a matrix that overlaps by accident wants merge, a matrix that retries a whole leg wants last, a gate that must not be fooled wants error — so it is asked rather than guessed. Every fold is reported on stderr and counted in the summary whichever policy is in force.
--on-duplicate | What survives |
|---|---|
merge default | The last shard's record, with rerun set to the members' reruns plus one per dropped attempt, and empty screenshots, attachments and steps back-filled from the latest loser that has them. Never a concatenation. |
first | The first shard's record in the merge's deterministic order; the rest are dropped. |
last | The last shard's record. For a matrix that retries a whole leg. |
worst | The most severe outcome: ERROR, FAIL, xPASS, SKIP, xFAIL, PASS. A status this version has never heard of scores below every real one, so an unreadable value cannot outrank a genuine failure. Ties break on the earlier shard. |
error | Nothing. The merge stops, listing every clashing node id at once and the shards each ran in. |
Two machines can run the same test under different roots — a Windows runner's backslashes, a container that checked the repo out at /src and a runner that used /home/runner/work/proj/proj. The same test under two spellings is two rows, two histories in Analytics and two entries in the JUnit file.
Node ids are normalised in a fixed order: backslashes become forward slashes, repeated slashes collapse, a leading ./ is stripped, then each --strip-path-prefix value is removed from the front. A prefix that would swallow the whole node id is not applied — the original is worth more than nothing. When the node id changes, the suite name is recomputed from it, so grouping follows identity instead of drifting away from it.
$ pytest-html-reporter merge ./artifacts \
--html-report ./report \
--strip-path-prefix /home/runner/work/proj/proj/ \
--strip-path-prefix C:/actions-runner/_work/proj/proj/ \
--strip-path-prefix /src/
A record whose node id is empty after all this is quarantined: dropped from the merge, counted in the summary and named in a note. It cannot be grouped, sorted against a duplicate, or linked to. Dropped rather than fatal, because the merge runs after every test in the matrix has finished, which is the worst moment there is to raise.
The merge copies every PNG the merged records name into the report's own folder — by the names the records hold, never by globbing the shard's folder. A shard folder can still be holding images from a previous build of the same leg, and sweeping the folder would carry them into this report as tests that did not run in it. The merge owns <report base>/pytest_screenshots and removes it outright first: one build comes out of one merge.
Each shard's images land in a folder of their own, because the screenshot counter restarts at 1 in every process — two machines' first screenshots are both <ms>-1, and one would overwrite the other in a shared folder. An image a bundle named but did not carry is dropped rather than left pointing at nothing, counted as a missing screenshot, and named in a note that says which file the bundle promised.
report/
├── pytest_html_report.html
├── output.json
├── archive/
│ ├── output_1788425529.352927.json
│ └── output_1788426203.302967.json
├── pytest_screenshots/ <- owned by the merge, wiped and restaged
│ ├── 1-4/
│ │ └── 1788426639123-1.png
│ ├── 2-4/
│ │ └── 1788426639123-1.png (same name, different shard, no collision)
│ └── 3-4/
└── shards/ <- untouched when merging in place
├── 1-4/
├── 2-4/
└── 3-4/
Provenance is printed on every merge, with no threshold anywhere near it. There is no honest way to decide from inside a merge that a bundle is stale — every bundle beside a merging leg was written before it — so nothing is guessed and the times are shown. That is what turns yesterday's leg from a silent extra two tests in the totals into a line in the log of the run that merged it. Times are local, because this is read beside a CI log whose own timestamps are on the same clock.
Notes go to stderr and the summary goes to stdout, so a pipeline that captures one can discard the other, and inspect --json stays a clean document. -q suppresses the notes but never the summary and never an error.
pytest-html-reporter merge ./artifacts --html-report ./report --junit-xml ./report/junit.xml run: the stderr notes interleaved with the stdout summary, including the per-shard provenance lines, one folded duplicate, and the final report and junit paths.
merged 4 shards: 812 tests
shard 1-4: 203 tests, finished 2026-09-03 11:42:07
shard 2-4: 204 tests, finished 2026-09-03 11:44:31
shard 3-4: 203 tests, finished 2026-09-03 11:41:55
shard 4-4: 202 tests, finished 2026-09-03 11:45:02
PASS 780, FAIL 21, SKIP 11
3 collection records
duplicates folded: 2
missing screenshots: 1
bundles superseded by a newer copy: 1
ran on runner-1, runner-2, runner-3, runner-4 over 12 mins 04 secs
report: /home/runner/work/proj/proj/report/pytest_html_report.html
junit: /home/runner/work/proj/proj/report/junit.xml
There are two independent failure flags, because they answer two different questions. --strict is about the merge being complete; --exit-code is about the tests. A pipeline that wants the build to go red on a failing test uses one; a pipeline that only wants to know all four artifacts arrived uses the other. The report is written either way — a merge that exits 1 has still produced the page that explains why.
| Exit code | Means |
|---|---|
0 | The merge produced what it was asked for, and neither --strict nor --exit-code had anything to say. |
1 | A verdict about the tests or about the completeness of the merge — and it always comes with a report. Also returned when --junit-xml could not be written, since the report is already on disk by then. |
2 | Nothing was produced at all: a bad flag, no bundles under the given paths, a bundle from a newer release, a merge error, or junit -o failing to write the one thing that subcommand exists to produce. |
inspect answers "did all the artifacts arrive, and do they overlap". It writes nothing and always exits 0 unless it could not start at all, so a pipeline that wants to fail loudly on a missing shard reads the count out of --json.
1-4 203 records 1 collect runner-1 ./artifacts/shard-1/records.json
2-4 204 records 0 collect runner-2 ./artifacts/shard-2/records.json
3-4 203 records 1 collect runner-3 ./artifacts/shard-3/records.json
4-4 202 records 0 collect runner-4 ./artifacts/shard-4/records.json
pytest-html-reporter inspect ./artifacts where only three of four shards are present — three bundle lines and a summary showing three shards — so the reader recognises the failure this step is meant to catch.
The JSON document carries the same information: one object per bundle, and a summary holding shards, tests, collects, statuses, reruns, duplicates_folded, quarantined, unreadable, superseded, unrecognised, the matrix's start, end and wall span, the distinct hosts, the agreed environment, any coverage notice, and every note the merge made in order.
$ pytest-html-reporter inspect ./artifacts --json > merge.json
$ test "$(python -c 'import json;print(json.load(open("merge.json"))["summary"]["shards"])')" = 4
The shape of a sharded pipeline is always the same three moves: each leg writes a bundle, the bundles are uploaded as artifacts, one job downloads them all and merges once. Here it is with three legs, coverage, and the merged report published as an artifact.
jobs:
test:
strategy:
fail-fast: false
matrix:
shard: [1, 2, 3]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.11"
- run: pip install pytest-html-reporter pytest-xdist pytest-cov
- run: |
pytest tests/ -n 4 \
--cov=myapp --cov-report= \
--html-report=./report \
--report-shard=${{ matrix.shard }}/3
# keep this leg's coverage data beside its bundle
- if: always()
run: cp .coverage report/shards/${{ matrix.shard }}-3/.coverage
- uses: actions/upload-artifact@v4
if: always()
with:
name: shard-${{ matrix.shard }}
path: report/shards/
include-hidden-files: true
Three things about that job are worth saying out loud.
-n 4 and --report-shard compose. Each leg is itself a four-worker xdist run, and writes exactly one bundle from its controller.report/shards/, the whole directory. Uploading only the JSON leaves the merge with records naming pictures that are not there.if: always() on the copy and on the upload, so a leg with failing tests still contributes its half of the story: a red pytest step fails the step, and every later step without that condition is skipped — which would leave the shard with no coverage data and no artifact. And include-hidden-files, because .coverage is a dotfile and would otherwise be left out of the artifact.--report-junit on a plain shard. It is ignored, with a notice on stderr, on purpose: the alternative a CI author expects is three shard XMLs plus the merged one, and a **/*.xml glob that found all four would count every test in the matrix twice. Ask the merge for the XML instead.artifacts/
├── shard-1/
│ └── 1-3/
│ ├── records.json
│ ├── .coverage
│ └── pytest_screenshots/
│ └── 1788426639123-1.png
├── shard-2/
│ └── 2-3/
│ ├── records.json
│ └── .coverage
└── shard-3/
└── 3-3/
├── records.json
└── .coverage
The nesting does not matter to the merge: the paths it is given are walked, so ./artifacts finds all three bundles wherever the download step put them. It matters to --coverage-data, which reads a named directory one level deep — every entry in it whose name starts with .coverage — and does not walk subdirectories. So the data files are gathered into one flat folder first.
merge:
needs: test
if: always()
runs-on: ubuntu-latest
steps:
- uses: actions/setup-python@v5
with:
python-version: "3.11"
- run: pip install pytest-html-reporter coverage
- uses: actions/download-artifact@v4
with:
path: artifacts
- name: every shard arrived
run: |
pytest-html-reporter inspect ./artifacts --json > merge.json
test "$(python -c 'import json;print(json.load(open("merge.json"))["summary"]["shards"])')" = 3
- name: gather the coverage data files
run: |
mkdir -p covdata
i=0
for f in $(find artifacts -name '.coverage'); do
i=$((i + 1))
cp "$f" "covdata/.coverage.$i"
done
- name: merge
run: |
pytest-html-reporter merge ./artifacts \
--html-report ./report \
--junit-xml ./report/junit.xml \
--coverage-data ./covdata \
--coverage-target 80 \
--title "NIGHTLY" \
--environment staging \
--build-info "Commit=${{ github.sha }}" \
--build-info "Branch=${{ github.ref_name }}" \
--strict
- uses: actions/upload-artifact@v4
if: always()
with:
name: report
path: report/
What comes out is one build: one pytest_html_report.html, one output.json, one archived build, one trend point, one junit.xml with exactly one <testcase> per node id, and one coverage figure combined from the three legs' data.
./report, so there is no archive for it to rotate and the build has no history behind it. History needs the report folder to persist between builds — restore the previous folder from GitHub Pages, from a cache, or from wherever you publish it, and the merge will archive it and rebuild Analytics from what it finds.Every row in that panel is something a shard reported about itself, never the merging machine's answer — the merging machine ran no tests. Where the legs disagree the panel says so rather than picking: an Environment the shards do not agree on is left blank with a note listing the values, and captured-output settings that differ produce one row per leg instead of one summarised row that would be a lie. The run header shows the span of the matrix rather than the sum of the legs, so three shards that each took ten minutes in parallel are reported as ten minutes.
There is no JUnit input path anywhere in this plugin. Nothing reads a JUnit XML — not the plugin, not the merge command. The writer is a pure function over the record dicts, which is what makes a live pytest run and a merge produce the same document from the same input.
Every merging tool available gets one of four things wrong, and building from records answers all four at once.
tests and failures attributes they find, so a test that ran on two machines is counted twice while only one element survives — the header and the body of the same file then disagree. Here every count is recomputed from the elements actually emitted.<testcase> is read differently by every consumer. GitLab keeps the first and Jenkins keeps the last, so a flaky test that failed and then passed is reported red by one and green by the other. Here there is exactly one element per node id; two records that mangle to one address are renamed name [2], with a warning, rather than emitted twice.0.0, makes a forty-minute matrix read as instantaneous. Here testcase@time is the sum of the phase milliseconds, not the two-decimal duration the table shows.keepProperties on, so which shard ran a test and how many retries it took are written into <system-out> as well as into <properties>.It is one <testsuite>, not one per shard: Azure's run-level timing across several suites is ambiguous and every other consumer flattens them anyway, so shard identity lives in the properties and in each testcase's captured output instead. Writing is atomic — a temporary file beside the target, then a rename — because a CI collector very often watches the directory it will read from, and half a document is a parse error attributed to the tests rather than to the reporting.
$ pytest tests/ --html-report=./report --report-junit=./report/junit.xml
$ pytest tests/ --html-report=./report --report-junit=./report/junit.xml --report-junit-xpass=fail
$ pytest-html-reporter merge ./artifacts \
--html-report ./report \
--junit-xml ./report/junit.xml \
--junit-logging failed \
--junit-suite-name e2e
$ pytest-html-reporter junit ./artifacts -o ./junit.xml --no-junit-attachments
| Flag | Where | Default | What it does |
|---|---|---|---|
--report-junit | pytest | "" | Write a JUnit XML of this run. strftime placeholders are expanded, as with --html-report. Ignored on a shard that is not also the merging leg. ini key: report_junit. |
--report-junit-xpass | pytest | pass | How an xPASS is written down. An invalid value fails the run rather than falling back, because the whole point of setting it is that the team disagrees with the default. ini key: report_junit_xpass. |
--report-link-pattern | pytest, merge | none | Names a marker that carries an id rather than a label. On top of turning it into a link in the html report, it puts the id in the XML as a <property> on the testcase. Repeatable; ini key: report_link_pattern. |
--junit-xml | merge | "" | Also write the merged XML here. |
-o, --output | junit | required | Where the XML is written. This subcommand exists to produce it, so failing to write it is exit 2. |
--junit-suite-name | merge, junit | pytest | The <testsuite> name; the root element is named after it. |
--junit-hostname | merge, junit | the shards' host | Unset means the shards' single host, or the literal merged when they ran on more than one — never the machine doing the merging, which Azure would read as the agent name. |
--junit-xpass | merge, junit | pass | pass, fail or skip. |
--junit-logging | merge, junit | no | no, all or failed — which tests carry their captured output. The spelling and the default are pytest's own junit_logging ini key. |
--junit-attachments | merge, junit | on | Write [[ATTACHMENT|path]] lines for screenshots — the one attachment convention GitLab and Azure both understand. --no-junit-attachments leaves them out. |
<?xml version="1.0" encoding="utf-8"?>
<testsuites name="pytest tests" tests="3" failures="1" errors="1" skipped="0" time="724.000">
<testsuite name="pytest" tests="3" failures="1" errors="1" skipped="0" time="724.000"
timestamp="2026-09-03T13:00:00" hostname="merged">
<properties>
<property name="pytest-html-reporter" value="0.4.3" />
<property name="shards" value="4" />
<property name="shard.ids" value="1-4,2-4,3-4,4-4" />
<property name="shard.hosts" value="runner-1,runner-2,runner-3,runner-4" />
<property name="reruns" value="2" />
<property name="duplicates-folded" value="0" />
</properties>
<testcase classname="tests.test_login" name="test_valid_user" time="1.204">
<properties>
<property name="owner" value="payments-team" />
<property name="jira" value="PROJ-123" />
<property name="jira" value="PROJ-987" />
<property name="severity" value="blocker" />
</properties>
<system-out>[[ATTACHMENT|pytest_screenshots/1-4/1788426639123-1.png]]
shard: 1/4 (runner-1)
reruns: 2</system-out>
</testcase>
<testcase classname="tests.test_cart" name="test_add[qty=2]" time="0.400">
<failure message="AssertionError: assert 1 == 2">AssertionError: assert 1 == 2</failure>
<system-out>shard: 2/4 (runner-2)</system-out>
</testcase>
<testcase classname="tests.test_api" name="(collection error)" time="0.000">
<error message="collection failure">ImportError: No module named 'requests'</error>
<system-out>shard: 3/4 (runner-3)</system-out>
</testcase>
</testsuite>
</testsuites>
The order inside <system-out> is fixed: attachments first, because a collector scans for them; then the shard and the rerun count, the two facts that would otherwise only live in properties nobody reads; then the captured output, which is the longest and least machine-read part. There is at most one <system-out> and one <system-err> per testcase, because a document with two of them is one whose second some parsers drop and others concatenate.
owner, severity or a marker named in report_link_pattern gets a <properties> block of its own, written ahead of the outcome, which is where pytest's own writer puts a record_property block and therefore where every consumer that reads one already looks.This is the machine-readable half of traceability. Xray, Zephyr and TestRail all ingest a test's issue key from a testcase property, and none of them opens an html report, so an id that only ever reaches a badge is invisible to exactly the tools that were supposed to consume it.
test_key for Xray writes @pytest.mark.test_key("ACME-42") and gets test_key, rather than a vocabulary this plugin chose.owner, severity and the markers you named are written. A document that started listing every user marker would change what a collector reports for suites that asked for nothing.jira markers are two properties. Every traceability marker is a set — a test that closes two tickets belongs in both.normal and critical is one no consumer can rank; the report has already picked between them by the time the XML is written.owner and severity need no configuration, so they are written by the merge and junit subcommands as well. Pattern markers need to be named, and only merge takes --report-link-pattern — it reads them off its own argv, having no ini file to ask. See markers that link for the html side of the same configuration.
The mapping is explicit and closed on purpose. A canonical CI file is the last place that should be guessing, so a status this version has never heard of is written as an <error> and warned about loudly rather than absorbed into a plausible total.
| Status | Element | Counted as |
|---|---|---|
PASS | A bare <testcase> with no child element. | passed |
FAIL | <failure>, its message the first non-blank line, or test failed when there is none. | failures |
ERROR | <error> naming the phase — a record that never got as far as a call never ran the test, so that is a setup error; anything else fell over on the way out. | errors |
SKIP | <skipped type="pytest.skip"> with the reason and the location parsed back out of the stored message. | skipped |
xFAIL | <skipped type="pytest.xfail"> with an empty body, as pytest's own writer leaves it. Never a <failure>: mapping an expected failure onto one turns every suite that documents its known bugs red across Jenkins, GitLab and Azure at once. | skipped |
xPASS | A bare passing testcase by default; <failure> under --junit-xpass fail; <skipped type="pytest.xpass"> under skip. | depends |
| collection error | <error message="collection failure">, with the classname set to the dotted path of the module that would not load — pytest's own answer leaves it empty, and every consumer that groups by classname then files every broken module under one nameless heading. | errors |
output.json and its archived build as well as its XML, over a mistyped path, in the one hook that runs after every test has finished.Coverage is read, never re-measured. Nothing in that path is allowed to take a test run down: every branch either returns data or returns nothing, and a failure to read is reported into the page rather than raised. By the time it runs the tests are over, and failing the run over a decoration would turn a green build red.
On a live run the sources are tried in this order, stopping at the first that yields data.
--report-coverage none — nothing at all, and no tab.--report-coverage-file PATH — read that file. If nothing there can be read as coverage data, the tab shows a notice naming the path. It does not fall through to the other sources, because you said where to look.Measured by pytest-cov during this run.coverage.json, then coverage.xml, looked for beside the report, at the rootdir and in the working directory, first hit wins. Only those two deliberate report artifacts are looked for: a stray .coverage data file is often left over from a run days ago, and quietly reporting yesterday's number is worse than reporting none.The format is sniffed from the first byte, not the extension — < is Cobertura XML, { is coverage.py JSON, anything else is tried as a .coverage SQLite data file. CI hands these files whatever name the pipeline felt like.
| Source format | What it needs | How the percentage is arrived at |
|---|---|---|
coverage.json | nothing at read time | coverage.py's own totals.percent_covered, taken as given rather than recomputed. With branch coverage on it already folds branches in, and it is the same total pytest-cov printed to the terminal. |
coverage.xml | nothing at all | Cobertura: covered lines plus covered branches over valid lines plus valid branches — the same formula coverage.py uses, so an XML-read report and a live one agree on the headline number. The one path that needs no coverage package installed, which makes it the useful one when the reporting job is not the job that ran the tests. |
.coverage | the coverage package | The SQLite data file, loaded and turned into the same shape. Without the package this returns nothing rather than raising. |
# the ordinary case: pytest-cov measures, the reporter reads
$ pytest tests/ --cov=myapp --cov-report=html --cov-fail-under=80 --html-report=./report
# read an artifact another step produced
$ pytest tests/ --html-report=./report --report-coverage-file=./coverage.xml
# no Coverage tab at all
$ pytest tests/ --html-report=./report --report-coverage=none
Files are listed worst first, by percentage then by missing lines then by name. That is both the useful default and the only defensible way to cut the list: a cap that kept the alphabetical head would hide exactly the files the tab is opened to find. The full count is always recorded, so the cap note can say how many were left out and how to see them all.
Each row carries the file's percentage, statements, covered, missing, excluded, branches, partial branches and the missing line numbers written the way coverage.py writes them — 12-15, 88, 91-140 — trimmed at a comma and never mid-range, because 91-14 reads as a line range that does not exist. Paths are relativised against the rootdir, since four directories of CI checkout in front of src/api.py identify nothing. A file with no branches shows an em dash rather than 0/0: having no branches to cover is not the same statement as covering none of them, and the Branches and Partial tiles appear only when branch coverage was actually switched on.
| Flag | Default | What it does |
|---|---|---|
--report-coverage | auto | auto builds the tab from whatever coverage is there; none switches it off, including the entry in output.json. ini key: report_coverage. |
--report-coverage-file | "" | Read coverage from this file instead of looking for one. ini key: report_coverage_file. |
--report-coverage-limit | 500 | Files listed on the tab, least covered first; 0 lists every one. ini key: report_coverage_limit. |
--coverage-target | unset | Merge only. The percentage this project set for itself, which colours the ring. It is the merge's answer to --cov-fail-under, and is never read as a coverage source. |
--coverage-data | none | Merge only. .coverage data files, or directories holding them, combined with the coverage package. Repeatable. |
The ring is green at 90% and above, amber at 75%, red below — unless the project has stated its own bar with --cov-fail-under (or --coverage-target on a merge), in which case that is the line the colour is drawn at and the tab says so. A report should not disagree with the build that just passed or failed beside it, so a hair of slack keeps 79.999999999 against a target of 80 from reading as a failure in the report while the build itself passed.
The percentage is written into output.json alongside the test counts, so it travels with the archived builds. That is what gives the tab its +0.8 since the last build chip and its trend line. The delta compares this build against the most recent earlier build that measured any coverage, skipping builds that ran without it rather than counting them as a drop to zero; a movement under 0.05 reads as no change. A build that ran without coverage leaves a gap in the line rather than a drop to zero, because the coverage key is simply absent from that build's output.json — and absent means "not measured", which is a different answer from zero and the true one.
The one thing a summary cannot replace is the source, line by line, with the missed lines marked. Generate it with --cov-report=html and the tab links to it.
It is linked, never embedded. An iframe would break the property the whole reporter is built on — one file you can mail, publish as a CI artifact or open off a stick — and it would break silently, showing an empty frame wherever the folder did not travel. The link is offered only when the index file exists and was written after this run started: coverage.py names that folder whether or not the HTML report was asked for, so a stale htmlcov sits there looking exactly like a fresh one, and annotations that disagree with the summary beside them are worse than no link.
The merge has five branches, in this order, and no sixth.
--report-coverage none — nothing. Reachable only through a --report-shard-merge leg, which reads the pytest flag; the merge command has no such flag, so coverage there is auto unless you name a source.--report-coverage-file — read it. If nothing there can be read, the build gets no coverage and a notice naming the path.--coverage-data — collect every named file, and every entry named .coverage* directly inside every named directory, and combine them with the coverage package. The source line reads N combined data files. A missing coverage package, no files matched, or a combine that raised are each reported as a notice rather than a failure.coverage.json would become the build's headline number, and output.json would archive it into the trend chart for as long as the archive is kept.The data files are copied into a temporary directory before being combined, because coverage.py's own combine() deletes the files it reads — and these are somebody's CI artifacts, which are very often the only copy.
# let the merge combine the data files itself
$ pytest-html-reporter merge ./artifacts \
--html-report ./report \
--coverage-data ./covdata \
--coverage-target 80
# or combine first with coverage.py and hand over one report
$ coverage combine ./covdata/.coverage.*
$ coverage json -o ./coverage.json
$ pytest-html-reporter merge ./artifacts \
--html-report ./report \
--report-coverage-file ./coverage.json \
--coverage-target 80
Work down this list. The first one that applies is the answer.
pytest-cov has to be installed and --cov passed. With neither, the tab shows the setup guide — which is the correct answer, not a fault.
This is the usual one. --cov takes the import name or the path of the code under test — your package, not the tests, and not a folder that is not there. --cov=src against a project with no src directory measures nothing, and pytest-cov prints Module src was never imported and No data was collected among the rest of the run. The tab repeats it, naming the flag you typed, rather than showing you a guide to what you have already done.
Check addopts and the report_coverage ini key in pytest.ini, pyproject.toml or tox.ini, not only the command you typed.
Naming a file switches off every other source, so a file that cannot be read leaves the tab empty rather than falling through. The tab names the file it could not read.
Then there is no coverage on the build by design, and the notice on the tab names the fix: combine the data first and pass the result with --report-coverage-file, or pass the shards' data files with --coverage-data.
Whichever source the numbers do come from, the tab states it — Measured by pytest-cov during this run, or Read from coverage.xml, written 2026-08-31 20:23 — so you can always tell which of these you are in.
Five things are worth getting right whatever the platform.
report/shards/<id>/, bundle and screenshots together — not only records.json.
--report-junit on a plain shard is ignored with a notice, and a glob that collected one XML per leg plus the merged one would count every test twice.
inspect --json.
--strict catches the quiet failures — a folded duplicate, a missing image, a bundle superseded by a second copy. --exit-code catches failing tests. They are independent.
The full three-shard workflow is the worked example above. For a single unsharded job there is a dedicated composite Action that installs Python, runs pytest with the plugin, and publishes the result to the workflow job summary, a self-editing pull request comment, a downloadable artifact and GitHub Pages — with step outputs a later step can gate on.
- uses: actions/checkout@v4
- uses: prashanth-sams/pytest-html-reporter-action@v1
GitLab's parallel keyword hands each job a CI_NODE_INDEX and a CI_NODE_TOTAL, which is exactly a shard id. The run token is derived automatically from CI_PIPELINE_ID, and the merged XML goes into reports:junit: so the pipeline's own test tab reads it — one entry per test, because the document has exactly one <testcase> per node id.
test:
parallel: 4
script:
- pip install pytest-html-reporter
- pytest tests/ --html-report=./report --report-shard=$CI_NODE_INDEX/$CI_NODE_TOTAL
artifacts:
when: always
paths: [report/shards/]
merge:
needs: [test]
script:
- pip install pytest-html-reporter
- pytest-html-reporter merge ./report
--html-report ./report
--junit-xml ./junit.xml
--exit-code
artifacts:
when: always
paths: [report/]
reports:
junit: junit.xml
The merge reads ./report and writes ./report, which is safe by design: the shards live in a subtree the merge never clears, and only report/pytest_screenshots is wiped and restaged.
Jenkins is the sequential case — several stages on one agent, in one workspace — so it is the one that --report-shard-merge was written for. The run token is derived from BUILD_TAG, which is jenkins-${JOB_NAME}-${BUILD_NUMBER} and so is unique across jobs, unlike the bare build number, which only counts within one. The first stage passes --report-shard-reset so a stage that was renamed or removed since the last build cannot leave a bundle behind for this build to merge.
# stage: unit
$ pytest tests/unit --html-report=./report --report-shard=unit --report-shard-reset
# stage: integration
$ pytest tests/integration --html-report=./report --report-shard=integration
# stage: e2e - the last leg merges the three and writes the one report and the one xml
$ pytest tests/e2e --html-report=./report \
--report-shard=e2e --report-shard-merge \
--report-junit=./report/junit.xml \
--archive-count=60
Publish the results from the pipeline's post block with Jenkins' own steps: junit 'report/junit.xml' for the test result page, and archiveArtifacts artifacts: 'report/**' to keep the HTML report against the build. Because the workspace persists between builds, report/output.json and report/archive/ survive, which is what gives this job a real trend chart and a populated Analytics tab rather than a one-build view.
--report-junit writes the merged document — the shard properties, the matrix's timestamp and span, the reruns and the folds — not just its own stage's tests. Only the xPASS mapping comes from the run's own flag, because --report-junit-xpass is the one shaping flag a pytest run has.Set no retention at all and every build is kept for ever, which is what eventually makes a report slow to open: a retained build costs roughly 5KB of the page, so an hourly job reaches a multi-megabyte report inside a couple of months.
An archived build has to satisfy every limit you set in order to survive. Retention runs after the trend has been drawn and before the Archives section and Analytics are built, so the history Analytics reads is exactly the history the report ships with.
| Flag | Default | What it does |
|---|---|---|
--archive-count | unlimited | Builds to keep. Empty keeps every build. 0 deletes archive/ outright and removes the Archives section. N keeps N - 1 files on disk, because the build being reported now is shown alongside the archived ones and counts against the limit. |
--archive-days | unset | Keep only builds newer than now minus D days. Fractions are allowed — 0.5 is half a day. For a job on a schedule, where "the last 30 days" does not have to be retuned every time the schedule changes. |
--archive-since | unset | Delete every archived build older than this moment. YYYY-MM-DD means midnight local time that day; 'YYYY-MM-DD HH:MM' and with seconds are also accepted. |
When both a days limit and a since date are given, the stricter of the two wins, so neither can widen the other. All three are validated by name: a non-numeric or negative count, a non-numeric or negative days value, or an unparseable date each fail the run rather than being ignored — and on the merge command they are checked before anything is written, so a typo cannot abort a build that had already rotated the archive.
# a nightly job: keep a month of history, never more than 60 builds
$ pytest tests/ --html-report=./report --archive-days=30 --archive-count=60
# the same limits applied by a merge
$ pytest-html-reporter merge ./artifacts --html-report ./report \
--archive-days 30 --archive-count 60
# keep nothing but this build
$ pytest tests/ --html-report=./report --archive-count=0
Retention is a property of the job rather than of one run, so the ini file is usually the better place for it: set it once and every invocation, however it is started, keeps the same window.
[pytest]
html_report = ./report
archive_count = 60
archive_days = 30
The other half of the size question is how much each test carries. Four settings decide it, and all four have an all / failed / none narrowing plus a numeric cap where a cap makes sense.
| Setting | Default | What it costs you |
|---|---|---|
--report-logs | all | Captured stdout, stderr and logging, per test. failed is the usual choice on a large suite. |
--report-log-limit | 10000 | Characters kept per test. What survives is the end of the output — the lines next to the failure — cut back to a whole line, with a note saying how much was dropped. 0 keeps everything. |
--report-attachments | all | Text, JSON and API payloads. Held outside the metrics table, so they are never swept into its search box or its exports. |
--report-attachment-limit | 20000 | Characters per payload. What survives is the start — the opposite of the log limit, because a response puts its status, its error field and its first records at the top. |
--report-steps | all | Step trees. none still keeps the phases and their timings, which cost nothing. |
--report-step-limit | 500 | Steps per test, so a step inside a loop over ten thousand rows cannot run away with the page. |
--report-screenshots | failed | When a live Selenium driver or Playwright page is photographed without the suite asking. Images handed to attach() are always kept. |
--report-coverage-limit | 500 | Rows in the coverage file table, least covered first. |
$ pytest tests/ --html-report=./report \
--report-logs=failed --report-log-limit=5000 \
--report-attachments=failed \
--report-steps=failed --report-step-limit=100 \
--archive-days=30 --archive-count=60
Job summary, a self-editing pull request comment, an artifact and Pages — in one step.
Read moreWhat the retained builds turn into: flaky tests, standing failures, drift and movement.
Read moreThe complete surface of the pytest plugin and the standalone command, with defaults.
Read more