FAQ
Grouped by what you were doing when you asked. Every answer is the behaviour the plugin actually has, and every one links to the page that goes into it properly.
Getting going
Where did my report go?
With no --html-report at all, the report is written into the directory you ran
pytest from, as pytest_html_report.html, with output.json
beside it. The plugin is registered unconditionally and the path option defaults to
., so a plain pytest in a project with the package installed has
already written one.
When you do pass a value, one rule decides how it is read: a string containing
.html anywhere names a file; anything else names a folder, and the file
inside it is pytest_html_report.html. That is why a folder called
out.html_v2 sends the report into the working directory instead — the whole string
is read as a bare file name. Rename the folder.
$ pytest tests/ --html-report=./report
$ pytest tests/ --html-report=./report/report.html
One more thing that moves a report: percent signs are strftime placeholders,
expanded once when the run starts. %p is AM/PM, so ./100%pass/ becomes
./100PMass/. Write %% for a literal percent sign in front of a
letter.
The full path semantics are in the CLI reference, and Getting started shows what the folder looks like after two runs.
Do I need a conftest.py?
No. The package registers itself through pytest's pytest11 entry point, so the
next pytest you run already has the reporter attached. There is nothing to import,
no hook to write and no fixture to add.
That includes the screenshots. Release 0.3.9 deleted the repository's own
tests/functional/conftest.py: test_selenium.py and
test_playwright.py now say nothing about screenshots at all and are photographed
anyway.
A conftest.py is still the right home for one thing — taking a picture at a
moment of your own with attach(), from a fixture teardown or a
pytest_runtest_makereport hook. See the Python API.
Which Python and pytest versions does it need?
setup.py declares python_requires=">=3.5" and two
dependencies, neither of them version-pinned: pytest itself, so there is no ceiling
on the pytest you are already using, and Pillow, which the plugin uses for the
screenshots it captures.
coverage is the one optional package, imported lazily at the two places that
need it: --coverage-data on the merge command, and reading a .coverage
data file. Without it neither fails — the merge carries on with a notice, and the
Test Coverage tab says it could not read the file.
Everything else the plugin works with — pytest-xdist,
pytest-rerunfailures, pytest-bdd, pytest-cov — is
detected at runtime and never declared, so nothing is dragged into your environment for a
feature you do not use. See Getting started.
Does it replace pytest-html?
It is a separate plugin, not a fork: different package, different flags, different output file, no shared code. Nothing here reads, writes or disables another reporter's output, so installing this one does not take anything away from a project that already has one.
The two things it does that a single-run page cannot are worth naming. The first is
history — every build's output.json is kept in
archive/, and the Trends, Archives and
Analytics tabs are built by reading those back, so the report answers how does
this test behave and not only how did this run go. The second is
captured output: the project's own roadmap calls per-test stdout, stderr and
logging "the biggest single gap versus pytest-html", and it is the
Logs column now.
Features is the honest inventory of what is in the page.
Can I change the filename or the title?
Both, and they are separate options. --html-report decides the file: give it a
value containing .html and that is the name used. --title sets the
heading printed inside the page.
$ pytest tests/ --html-report=./report/nightly.html --title='PAYMENTS NIGHTLY'
Two limits to know. The displayed title is hard-cut at 20 characters — no
ellipsis; the tail fades out and the full string is kept as the heading's tooltip — and the
--environment badge beside it is cut at 10, with the full name in
the Environment panel.
--title is the one option in
the group with no ini key. It goes on the command line or in
addopts. The report's location does have one — html_report — which is
the way to set it without going through addopts at all. See
Configuration.How do I make a test link to its Jira ticket?
Write the id as a marker and name that marker in report_link_pattern, with
{} where the id goes. Any marker will do — jira, testcase,
test_key, whatever your tracker calls it.
[pytest]
report_link_pattern =
jira = https://acme.atlassian.net/browse/{}
Then @pytest.mark.jira("PROJ-123") on a test gives it a badge that opens
the ticket, and the id is written into the JUnit XML as a <property> on the
testcase — which is the half Xray, Zephyr and TestRail read, none of them having any way to open
an HTML report. Nothing is fetched and no token is needed; the badge is a link, not a lookup, so
it still renders in a report opened off a disk months later.
New in 0.4.1, along with the built-in owner and severity markers,
which need no pattern at all. See markers that
link.
pytest warns PytestUnknownMarkWarning for owner or severity
Upgrade to 0.4.1. Both markers, and every marker named in report_link_pattern, are
registered with pytest at configure time from that version on, so --strict-markers
accepts them and no run warns about them.
Before that they were ordinary user markers as far as pytest was concerned, and the plugin was in the position of telling somebody that the configuration it had asked them to write was a typo.
Nothing is showing up
My async test's steps all show 0 ms, and one that failed shows as passed
That is 0.4.0 and earlier, and 0.4.1 is the fix. @step on an async def
closed the step on the coroutine the call returned rather than on the work it went on to do —
nought milliseconds, PASS — and the work ran, and raised, long after the step said it
had finished.
async with step(...) did not work at all before 0.4.1 either: the object had no
__aenter__, so it raised TypeError, and the only spelling that worked was
a plain with block inside the coroutine.
Both work now, with nothing to install and no setting to turn on. If your gathered work used to come back nested three deep inside itself, that is fixed in the same release. See steps in an async test.
The Logs column is empty
Work down this list. The first one that applies is the answer.
- Is the run using
-sor--capture=no? Checkaddoptsinpytest.ini,pyproject.tomlortox.ini, not only the command you typed — a flag set there applies to every run. This is the most common cause, and the report says so above theTest Metricstable when it is happening. Removing the flag is the whole fix; there is no replacement flag to add. - Are you on
--report-logs=failedwhile the tests that produce output are passing? That mode keeps output for failed and errored tests only; a passing test shows a dash however much it printed. - Is the output
loggingbelowWARNING?log.info(...)andlog.debug(...)are not recorded until you pass--log-level=INFOor--log-level=DEBUG. - Do the tests actually produce any output? A suite of plain assertions
prints nothing, and a dash is then the correct answer — a failed test included, since its
message is in the
Error Messagecolumn rather than here. Add aprint(...)to one test and re-run to confirm the column is working.
The reporter can only keep what pytest hands it, and pytest's own capture mode decides what that is:
| Setting | Effect on the Logs column |
|---|---|
--capture=fd (default) | Everything, including output written by subprocesses and C extensions. |
--capture=sys | Everything Python itself writes; a subprocess's output is not captured. |
--capture=tee-sys | As sys, and it still prints live to the terminal. |
--capture=no / -s | logging only — stdout and stderr never reach the plugin. |
--log-level unset | Logging from WARNING up. |
--log-level=INFO / DEBUG | That level and above. |
The one thing -s gave you that plain capture does not is seeing output in the
terminal while the tests run. If you want both, --capture=tee-sys streams
it live and keeps it for the report:
[pytest]
addopts = -v --capture=tee-sys --log-level=INFO
Environment
panel states what the run kept and from which level — all tests: stdout, stderr and
logging, logging from WARNING — so you can always tell which of the four cases you are
in.The report tour shows the column and the overlay it opens.
The Test Coverage tab is empty
The plugin reads coverage; it never measures any. Work down this list, and again the first one that applies is the answer.
- Did anything measure coverage?
pytest-covhas to be installed and--covpassed. With neither, the tab shows the setup guide — which is the correct answer, not a fault. - Is
--covpointing at code that actually gets imported? This is the usual one.--cov=srcagainst a project with nosrcdirectory measures nothing at all, andpytest-covprintsModule src was never importedandNo data was collectedin among the rest of the run. The tab repeats it, naming the flag you typed. Pass your package instead —--cov=my_package, or a path like--cov=./app. - Is
--report-coverage=noneset? Checkaddoptsand thereport_coverageini key, not only the command you typed. - Is
--report-coverage-filepointing at something that is not a coverage report? The tab names the file it could not read.
$ pytest tests/ --cov=my_package --html-report=./report
When the tests run in one job and the report in another, three more things matter:
- Auto-discovery looks for a
coverage.jsonor a Coberturacoverage.xmlin the report directory, the repository root and the working directory. The kind is worked out from the file's contents, not its name. A.coveragedata file is not discovered — one is usually left over from an earlier run, so name it with--report-coverage-fileif you want it, and installcoveragein that job so it can be read. --report-coverage-fileis final. If the file named there cannot be read, the tab stays blank and the coverage the run measured is not used instead.- There is no freshness check. A
coverage.xmlrestored from a cache before the tests ran is published as this build's number, silently. Do not cache coverage files alongside the report.
coverage.xml parses with the standard library, so the job that writes the report
needs no coverage package installed at all. That makes it the useful format to pass
between jobs.Whichever source the numbers came from, the tab says which — Measured by pytest-cov
during this run, or Read from coverage.json, written 2026-08-31 20:23. A
Cobertura coverage.xml carries no generation time of its own, so that one reads
Read from coverage.xml and says nothing about when, which is the other half of the
freshness point above. The options are in the
CLI reference.
The Screenshots tab is empty
Four reasons, in the order worth checking:
- Nothing failed.
--report-screenshotsdefaults tofailed, so a green run photographs nothing.--report-screenshots=allphotographs every test, which is a baseline worth having. --report-screenshots=noneis set, inaddoptsor as thereport_screenshotsini key.- Nothing in the test could hand over a PNG at the moment it ended. A driver that has already quit has nothing left to photograph.
- The test is one of the two cases the automatic capture cannot cover — an
asyncPlaywright page, or aunittestsuite that quits its driver intearDown. Both are answered by callingattach()yourself.
Screenshots tab itself whenever a run captures nothing, so it is there when you
go looking for it. Screenshots has the whole story.The Analytics tab is empty on my first run
That is the correct answer, and the tab says so rather than drawing four empty axes.
Analytics reads every archived build and lines them up per test;
a first run has none to line up.
Two things are real from run one and are shown anyway: the duration panels — where the run's time went, and the slowest tests — and Why this run failed, which groups this run's failures by the exception each came out of. That panel reads the current build alone, so it is answerable on a first run, which is exactly when somebody wants it. A green run has nothing to group and the card is left out entirely.
Everything else needs a second build before it says anything. Trends and
Archives carry this run and nothing to compare it against; the pass-rate drift, the
four movement cards and the flaky verdict on a test are not drawn at all. If every run
looks like a first run, the archive/ folder is not surviving between runs — see
how to keep history between runs below.
output.json from 0.3.7, the release that shipped Analytics. Builds archived by an
earlier version have no such key and are read as not measured rather than as instant, so
an old archive thins the duration panels without corrupting them.Analytics and history explains every figure on the tab.
Screenshots
Why is there no picture of my failing test?
The automatic capture runs at the top of the reporter's own teardown wrapper — the last moment the browser is still open, before the fixture that quits it has run. Three things stop it producing an image:
- The browser was already closed. A
unittestsuite that callsself.driver.quit()intearDownhas shut it before the capture would run. Callattach()fromtearDown, before the quit. - The page is asynchronous. The capture is synchronous, so an
asyncPlaywright page has nowhere to await. Attach from the test body instead. - The test attached its own image. A test that hands over a picture is not photographed again on the way out, so a suite that already has a capture hook keeps exactly the images it always had rather than getting each one twice.
from pytest_html_reporter import attach
async def test_home(page): # Playwright, async API
try:
assert await page.title() == "Example Domain"
except AssertionError:
attach(data=await page.screenshot())
raise
def tearDown(self): # unittest
attach(data=self.driver.get_screenshot_as_png())
self.driver.quit() # after, never before
And check --report-screenshots: the default is failed, so a passing
test is not photographed unless you ask for all. Everything an image lands on — the
gallery, the Screens column on the row, and the step that threw — is in
Screenshots.
Does it work with Playwright, Appium, splinter or unittest?
Yes, and nothing imports Selenium or Playwright to do it. A browser is recognised by
what it can do rather than by what it is: anything answering
get_screenshot_as_png(), or handing bytes back from screenshot(), is
one. That covers Selenium, Playwright, appium, splinter and a driver wrapper of your own for
free.
-
The fixture name is a hint, not the test
page,driver,browserandcontextare looked at first, then every other fixture the test named, then the test class's own attributes — which is where aunittestsuite keeps its driver. -
Playwright contexts and browsersPhotographed through the pages inside them, and
a page reached through three fixtures at once is photographed once — asking for
page,contextandbrowsertogether is the ordinary way to write those tests. -
splinter
Browser.screenshot()writes a file and hands back its path, so the driver underneath the wrapper is tried before the wrapper's own call, and a return value that is not the image itself is not treated as one. - Two browsers in one testA picture of each.
A Mock is the one thing ruled out by what it is: it answers every call ever made
to it, so it would be photographed on the strength of a method it does not have — and calling it
records a call the test may be asserting on afterwards.
The two cases that still need attach() are the async API and a
unittest tearDown that quits the driver. See
Screenshots.
Can I turn screenshots off?
--report-screenshots=none, or report_screenshots = none in the ini
file for every run at once.
[pytest]
report_screenshots = none
An image you hand to attach() is kept whatever that setting says. The option
governs the pictures nobody asked for; an attached one was asked for.
Why was my pytest-bdd scenario not photographed?
pytest-bdd scenario was never photographed at all. Nothing errored — the run simply
produced no picture.The reason is worth knowing, because it is invisible from the test: the function
pytest-bdd generates takes no fixtures. Every step asks for what it needs through
request.getfixturevalue as it runs, so the page the scenario was driving never
reached item.funcargs, which was the only place the capture looked.
From 0.4.0 fixture values are also read out of the request's own cache. They are
read rather than asked for by name, because getfixturevalue on a
fixture the test never used would build it — which at teardown means starting a browser
in order to photograph it.
If you are on an older release, upgrading is the whole fix. The repository ships
tests/functional/test_gherkin_screenshot.py and
ui_features/heading.feature as a runnable demo: a Gherkin scenario that fails in
front of a browser, saying nothing about screenshots anywhere in it.
See Steps and BDD, and the changelog for the release notes.
Size and speed
My report is several megabytes
Retained builds first, per-test evidence second. A retained build costs roughly 5KB of the page, so an hourly run with no retention limit reaches a multi-megabyte report inside a couple of months and the page gets slow to open. Pruning the archive folder shrinks the page for free.
After that, three caps decide how much one test can contribute:
| Flag | Default | What it keeps |
|---|---|---|
--report-log-limit | 10000 | Characters of captured output per test. What survives is the end — the lines next to the failure — cut back to a whole line, with a note saying how much was dropped. |
--report-attachment-limit | 20000 | Characters per attached payload. What survives is the start, because a response puts its status, its error field and its first records at the top. |
--report-step-limit | 500 | Steps per test, so a step inside a loop over ten thousand rows cannot run away with the page. The cap is followed by a line saying the rest were dropped. |
And four options narrow whose evidence is kept at all — --report-logs,
--report-attachments, --report-steps and
--report-screenshots — each taking all, failed or
none.
0 means
no cap on all three limits, not "keep nothing", and a negative value is read as
0. To keep nothing, use the none mode of the matching option.--report-steps=none is the one exception worth knowing: the per-phase timings
stay, because they cost nothing. Every flag is in the
CLI reference.
How many builds are kept?
Every one, for ever, unless you say otherwise. Three limits control it and they intersect — a build has to satisfy every limit you set to survive.
| Flag | Takes | What it does |
|---|---|---|
--archive-count | text | Empty means no limit. 0 removes archive/ outright and the Archives section is not rendered. N keeps N-1 files on disk, because the build being reported now counts against the limit — so 1 keeps this build and nothing else. |
--archive-days | number | Keeps only builds from the last N days. Fractions are allowed: 0.5 is half a day. |
--archive-since | date | YYYY-MM-DD, optionally with HH:MM or HH:MM:SS, in local time. Everything older goes. Set alongside --archive-days, the stricter of the two wins. |
A build is dated by the moment its run started, kept in the name of its archive file rather than read from the file's mtime — a checkout into a fresh CI workspace gives every file the timestamp of the clone, and an age limit read from that would keep everything for ever.
What survives is what the tabs read: the Trends chart draws the current build
plus the five most recent archives, and Analytics reads every retained build while
charting the most recent twenty. See Analytics and history.
How do I stop the history growing?
Put a limit in the ini file rather than in every command. Retention is a property of the job, not of one run: set it once and every invocation, however it is started, keeps the same window.
[pytest]
html_report = ./report
archive_days = 30
archive_count = 60
The same three limits are flags on the merge command, with the same words on error, so a sharded pipeline prunes the same way:
$ pytest-html-reporter merge ./artifacts --html-report ./report \
--archive-days 30 --archive-count 60
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.
CI
How do I stop the local and CI settings drifting apart?
Write both shapes down under a name. From 0.4.3 a
named profile holds a whole set of these settings in
pyproject.toml as [tool.pytest-html-reporter.profiles.ci], or in a
pytest.ini, tox.ini or setup.cfg as
[pytest-html-reporter.profiles.ci], and the run picks one with
pytest --report-profile=ci.
Both files are read, so a repository that has no pyproject.toml does not need to grow
one. report_profile = ci in [pytest] pins the profile a bare
pytest uses, and --report-profile=none opts one run back out of it. A
PYTEST_HTML_REPORTER_LOGS=all in one job's environment overrides that one setting for that
job, and a flag on the command line still beats everything.
A profile that says something a flag would have refused — logs = "fail" —
fails the run at configure time with one line naming the file, rather than quietly falling through to
the default. And pytest-html-reporter config prints what a run here would resolve, setting
by setting, with the layer that decided each one.
Can the report say which pipeline, branch and commit it came from?
From 0.4.2 it does that on its own. The Environment panel gains a CI
row naming the system, a Pipeline row linking straight back to the build, and
Branch and Commit rows for the revision under test. Nothing has to be
passed and nothing is fetched — the values come from the CI system's own environment variables,
and from git when it is not a CI run.
GitHub Actions, GitLab CI, Jenkins, CircleCI, Buildkite, Azure Pipelines, Travis CI, AppVeyor,
Drone, Bitbucket Pipelines, Semaphore, AWS CodeBuild and TeamCity are named individually, and
anything else that sets CI is recorded as a build agent rather than passed off as
somebody's laptop. See the variables each
one is read from.
If you already publish these through --build-info, yours wins: a
branch, commit, ci or pipeline you have named
is the answer shown, and the detected one is dropped rather than rendered beside it disagreeing.
A flaky test says PASS 2. What did it fail with?
Click the count. From 0.4.2 a non-zero Rerun cell is a button, and the panel
behind it lists every attempt the test made — what each one did, how long it took and the full
error it raised — ending on the attempt the row itself is showing, marked kept. The
panel's Copy button hands the whole trail over in the shape you would paste into
an issue.
Nothing needs enabling: --reruns, the reruns ini key,
@pytest.mark.flaky(reruns=n) and --only-rerun are all read by counting
the attempts that actually happened. Under -n each attempt also says which xdist
worker ran it, and a test retried on one shard that then ran again on another machine reports
every attempt from both.
Two things have no trail and show a plain, disabled 0: a test that ran once, and 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. See the attempt
trail.
Can the report record everything that was installed?
Yes, with --report-packages (or report_packages = true in the ini
file). It adds a Packages row to the Environment panel holding every installed
distribution and its version, the way pip freeze reads, with the count in the label.
It is off by default, and worth leaving off unless you want it: it is a few hundred entries
nobody reads until the day the report is the only surviving record of what was installed — and it
publishes a full dependency inventory into a file that gets attached to tickets. The
Plugins row answers a much smaller question. See
the packages inventory.
It opens a browser on my build agent
It should not, and --report-open=auto — the default — needs all
three of these to be true before it opens anything. A build agent normally fails every
one:
| Checked | How |
|---|---|
| The run's output is a terminal | sys.__stdout__.isatty(). Output piped into a file or a log collector means nobody is watching it go past. |
| No CI variable is set | CI, CONTINUOUS_INTEGRATION, BUILD_ID, BUILD_NUMBER, GITHUB_ACTIONS, GITLAB_CI, JENKINS_URL, HUDSON_URL, TEAMCITY_VERSION, TF_BUILD, CIRCLECI, TRAVIS, BUILDKITE, APPVEYOR, DRONE, BITBUCKET_BUILD_NUMBER, CODEBUILD_BUILD_ID. A variable set to empty, 0, false, no or off counts as not CI. |
| There is a desktop to open into | Assumed on macOS and Windows; anywhere else DISPLAY or WAYLAND_DISPLAY must be set. Without the check, a headless box opens the report in a console browser, on top of the summary the run just printed. |
If your agent still looks interactive to all three, turn it off once for everybody rather than in every command:
[pytest]
report_open = none
The value is resolved while the run is being configured, so a typo fails the run before the
tests rather than after them. Opening itself can never take a run down — every failure path is
swallowed, and a machine with no browser on it is not an error. The merge command's own default
is none, so a merge cannot steal a browser tab either.
How do I keep history between runs?
The history lives on disk: output.json and the archive/ folder
beside the report. A fresh runner has neither, which is why Trends,
Archives and Analytics have only ever this one build to show on a build
agent that starts clean every time. Restore that directory before the run and save it after.
With the GitHub Action, history: 'true' does it for you through
actions/cache:
- uses: prashanth-sams/pytest-html-reporter-action@v1
with:
tests: tests/
history: 'true'
archive-days: '30'
It caches both archive/ and output.json: a build
joins the archive only when the next run rotates its output.json in, so
carrying the folder alone accumulates nothing at all.
Three things quietly defeat it:
- A per-run
report-path. A path with%Y%m%din it makes a new folder every run, so every run starts from nothing. Put the date inartifact-nameinstead; the action warns when it sees this combination. - A matrix sharing one history key. The default key already includes the
runner OS, but two Python versions writing into one history interleave builds that are not
comparable. Give each cell its own
history-key. archive-count: '1', which the plugin reads as "this build and no others".'0'deletes the archive entirely. Leave it empty for no limit.
And the first run with history on still shows one build. It takes two. See the GitHub Action page.
How do I merge shards from a matrix?
It depends on whether the run is spread across processes or across machines, and the two get different answers.
One machine, many processes — pytest-xdist — needs nothing at
all. Every worker sends its records back to the controller, which merges them and writes one
report: one build in Archives, one set of totals, one row per test, listed in
collection order.
Many machines, or many separate pytest invocations, cannot do
that: a report folder has exactly one writer per build. Each leg runs with
--report-shard and writes a bundle —
<report base>/shards/<id>/records.json plus that leg's screenshots — and
no report at all. One job then downloads them and merges once.
jobs:
test:
strategy:
fail-fast: false
matrix:
shard: [1, 2, 3, 4]
steps:
- run: pytest tests/ --html-report=./report --report-shard=${{ matrix.shard }}/4
- uses: actions/upload-artifact@v4
with:
name: shard-${{ matrix.shard }}
path: report/shards/
merge:
needs: test
if: always()
steps:
- uses: actions/download-artifact@v4
with: { path: artifacts }
- run: pytest-html-reporter inspect ./artifacts
- run: pytest-html-reporter merge ./artifacts --html-report ./report
- Upload the whole
shards/<id>/directory, not justrecords.json, or the merge reports every image as not in the bundle. - Run
inspectbeforemergeto see what arrived: one line per bundle — the shard id, its record counts and the machine it ran on — and the totals the merge would produce, writing nothing. It exits non-zero only when there is no bundle at all, so a pipeline that has to notice three shards out of four reads the output, or--jsonfor the same thing as a document. - Do not pass
--report-juniton a plain shard. It is ignored, with a notice: four shard XMLs plus a merged one would have a**/*.xmlglob counting every test in the matrix twice. Ask the merge for the XML instead. - Sequential legs on one machine can skip the fourth command:
--report-shard-reseton the first leg,--report-shard-mergeon the last.
pytest-args concern — -n auto with
pytest-xdist — and gives you one report.CI, xdist and scale has the whole pipeline, and the CLI reference has every merge flag.
Can I fail the build on a threshold?
The plugin does not gate a run — pytest's own exit code decides that, and the reporter never changes it. The gates live in the GitHub Action, which has four:
| Input | Default | Fails the job when |
|---|---|---|
fail-on-error | true | pytest exits non-zero for any reason other than code 5. |
fail-on-empty | true | pytest collected no tests at all (exit code 5). This input alone decides that case. |
min-pass-rate | unset | The pass rate falls below the percentage you set. |
min-coverage | unset | Coverage falls below the percentage you set. |
- uses: prashanth-sams/pytest-html-reporter-action@v1
with:
tests: tests/
min-pass-rate: '95'
min-coverage: '80'
Two design rules are worth knowing before you set a number. A threshold that cannot be
measured fails loudly — min-coverage on a run that produced no coverage is
an error, not a free pass. And the pass rate is deliberately narrow:
passed / (passed + failed + errors), with skipped, xfailed and xpassed tests in
neither half, because none of them is a pass-or-fail signal and counting them would quietly move
the line you set.
Every reason that applies is collected, so a failing run tells you everything that was wrong
rather than the first thing. fail-on-error: 'false' stops pytest's own
exit status failing the job, so you can decide with the step outputs yourself — the two
thresholds and fail-on-empty are still checked either way.
pytest-cov's own --cov-fail-under is the ordinary way to put a floor
under coverage — and it doubles as the line the coverage ring's colour is drawn at, replacing the
default thresholds, with the tab saying so. On a merge, --exit-code makes failing
tests the merge's exit status and --strict turns the quiet merge problems into
exit 1.Error messages
ERROR: --report-open takes auto, always or none, not 'off'
The value came from an ini file. On the command line the option is a fixed choice, so argparse refuses it first and in different words:
$ pytest tests/ --report-open=off
ERROR: usage: pytest [options] [file_or_dir] [file_or_dir] [...]
pytest: error: argument --report-open: invalid choice: 'off' (choose from 'auto', 'always', 'none')
Either way it is a usage error: exit code 4, and no test runs. The three
values are auto, always and none; off,
false and 0 are not among them. Write report_open = none.
It fails rather than falling back to auto on purpose: somebody who wrote
report_open = off has said they do not want a browser, and quietly opening one
anyway is the one outcome they were trying to avoid. See
Configuration.
ERROR: --report-junit-xpass takes pass, fail or skip, not 'ignore'
The same shape as the one above, and the same reason. The command-line option is an argparse
choice, so this exact wording means the value came from report_junit_xpass in an ini
file. Exit code 4, before the suite.
The three values are the three ways an xPASS can be written into the XML: pass
— the default, and what pytest's own --junitxml does — fail, or
skip. The default is not offered as a fallback because the only reason to set the
key at all is that the team disagrees with it. The CLI
reference has what each value writes.
ERROR: --archive-count takes a number of builds to keep, not 'nine'
The value has to be a whole number of builds. The message quotes back exactly what was read,
which is usually enough to spot a unit that came along for the ride (30d), a quoted
list, or a CI variable that never expanded.
Two values are worth telling apart: empty means keep everything, and
0 means keep nothing — not even the Archives section.
Both are answers, which is why the option is read as text rather than as a number with a
default.
pytest-html-reporter merge refuses the same value in the same words, checked
before anything is written, but with its own prefix and its own exit code:
pytest-html-reporter: --archive-count takes a number of builds to keep, not 'nine',
exit 2. It is asked of the same helper deliberately, so a value the run rejects is not a value
the merge accepts.
ERROR: --archive-count cannot be negative, got '-1'
It parsed as a number, and the number was below zero. There is nothing for it to mean:
0 already says keep nothing and an empty value already says keep everything, so a
negative count is a typo rather than a third instruction.
--archive-days answers a negative in the same words, and the merge command
answers both.
ERROR: --archive-days takes a number of days to keep, not 'lots'
Days, as a number. Fractions are allowed — 0.5 is twelve hours — but duration
strings are not: 14d, 2w and 1 month all land here.
--archive-count and --archive-days are not alternatives. Set both
and both hold, so a nightly job can say at most 50 builds, and none older than 14 days
and get whichever limit bites first.
ERROR: --archive-days cannot be negative, got '-3'
Same rule as the count, and checked in the same place — while the run is being configured,
which is why it costs you no suite. To keep no history at all, the option that says so is
--archive-count=0.
ERROR: --archive-since takes a date, YYYY-MM-DD or 'YYYY-MM-DD HH:MM', not 'last-tuesday'
Three forms parse and nothing else does: 2026-06-01, which means midnight local
time that day, 2026-06-01 09:30, and the same with seconds. No relative dates, no
other separators, no timezone offsets.
Quote the form that carries a time. Unquoted, the shell ends the value at the space and hands
09:30 to pytest as a path to collect, which is a different complaint about a
different thing:
$ pytest tests/ --html-report=./report --archive-since='2026-06-01 09:30'
Set alongside --archive-days, the stricter of the two wins, so neither can widen
the other.
ERROR: --report-shard takes a name for this shard, not '//'
The id names a directory under the report base and a folder of screenshots inside the report,
so it is cleaned before it is used: anything that is not a letter, a digit, .,
_ or - becomes a dash, runs of dashes collapse, dots are trimmed from
both ends, and what is left is cut to 64 characters. That is why
--report-shard=1/4 writes into shards/1-4/ while the report still
labels the leg 1/4.
This fires when nothing survives the cleaning. // is the obvious case;
.. is the one worth knowing about, because it is made entirely of characters the
pattern considers safe and would otherwise resolve to the report base itself, empty the report's
own screenshots folder and drop its bundle beside the report. A dot inside the id —
1.4, python3.11 — is left alone.
ERROR: --report-shard-merge needs --report-shard to name this shard
--report-shard-merge was passed and --report-shard was not, or was
empty. The merging leg is still a leg: it writes its own bundle before it reads everybody else's,
and it cannot file that bundle without a name. On a sequential run the last leg carries both
flags:
$ 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
Across machines there is no last leg to give the flag to. Every leg writes a bundle and one
later job runs pytest-html-reporter merge —
CI, xdist and scale has both pipelines.
pytest-html-reporter: --report-junit is ignored on a shard; pass --junit-xml to `pytest-html-reporter merge`, or run this leg with --report-shard-merge
A warning on stderr, not a failure: the leg runs its tests and exits on pytest's own verdict. A shard that is not also the merge leg renders nothing at all, so it would write no XML either.
It is said out loud rather than passed over because the alternative a CI author expects is
four shard XMLs plus a merged one, and a **/*.xml glob that found all five would
count every test in the matrix twice. Ask for the XML where the whole run exists: --junit-xml
on pytest-html-reporter merge, or --report-shard-merge on the last leg
of a sequential run, which writes the same document from the same bundles.
pytest-html-reporter: the shards beside this one could not be merged: <reason>
A --report-shard-merge leg finished its own tests and then failed to merge the
folder. Two lines are printed together, and the second one is the recovery:
pytest-html-reporter: the shards beside this one could not be merged: ./report/shards/e2e/records.json was written by a newer pytest-html-reporter (bundle version 3); this one understands 2
pytest-html-reporter: this leg's records were still written to ./report/shards/e2e; merge them with `pytest-html-reporter merge` once that is sorted out
Reported rather than raised, and that is the whole point of the second line. This runs after
every test in the leg has finished, so a stale bundle from a newer release sitting in the folder
would otherwise turn a run that passed into an INTERNALERROR traceback with no
report and no verdict at all. Every leg's bundle is on disk either way, so the answer is still
one command away once the folder is sorted out.
The usual cause is a folder holding more than this run: <base>/shards is
persistent, and a bundle left there by a different version of the plugin outlives the run that
wrote it. --report-shard-reset on the first leg empties it before this run writes a
byte.
pytest-html-reporter: --report-junit could not write <path>: <reason>
The tests are over, the XML path did not work, and the run carries on. The HTML report,
output.json and the archived build are all still written, and pytest's exit code is
whatever the tests earned.
Raising here would cost the run all of that over a mistyped path, in the one hook that runs after every test has already finished. An XML that is not there is still not there, and a CI step that fails on a missing file still fails — but it fails holding the report that says what the tests did.
The reason is the operating system's. A missing directory is not one of the causes: the writer creates the path's parent first, so what is left is permissions, a read-only mount, or a name already taken by a directory.
pytest-html-reporter: --junit-xml could not write <path>: <reason>
The same failure from pytest-html-reporter merge, and it happens after the report
is already on disk. The merge exits 1 rather than 2: something was produced, and
it was not a clean run either. Said on stderr rather than raised, because a traceback out of a
merge that succeeded reads as though the merge is what went wrong.
The summary still prints, and it still names the report. Exit codes and output streams in the CLI reference has the full table.
pytest-html-reporter: -o could not write <path>: <reason>
The same failure again, on the junit subcommand — and there it is fatal. That
subcommand exists to produce one document, so unlike the merge it really has produced nothing:
exit 2, and no summary of a file that is not there.
The three answers to one failure are the design rule the plugin follows everywhere. Nothing in
the report-writing path is allowed to take a run down, because the tests have already finished by
the time any of it happens. Bad input is the opposite case and fails immediately — a run
that took twenty minutes and then discovered its --archive-days was nonsense has
wasted twenty minutes.
The editor extension
The sidebar is empty
The sidebar has six states — loading, no report, unreadable report, no tests, all passed and the list of failures. Four of them look like an empty panel and each says something different, so read what it says before changing anything:
| What it says | What it means |
|---|---|
| No report found | No configured path exists and detection found nothing. Run pytest --html-report=./report, then Refresh — or use Configure report path. |
| Unable to load report | The output.json it resolved would not read or parse. The message is on the card. |
| No tests in this report | It parsed, and records no tests. |
| All tests passed | Not an empty sidebar. The sidebar lists failures, so a green run is a green panel. |
Two things narrow what it will offer. Every candidate is shape-checked: the
file has to parse and carry content.suites as an object, because
output.json is a common filename and rendering an unrelated one as test results
would be worse than finding nothing. And detection is bounded: the fallback
workspace search takes at most 20 hits and skips node_modules, .venv,
venv and site-packages. A report buried outside the conventional
locations in a very large repository may need Configure Report Path.
If the sidebar loads but carries no flake badges and no trend strips, that is history rather
than detection: without an archive/ directory the window is one build long, and a
test needs to appear in at least two builds before it gets a verdict.
The VS Code extension page covers every state and setting.
Where does the extension look for output.json?
Per workspace folder, best first: the workspace root, then report/,
reports/, test-reports/, .reports/ and
test-results/, and then a bounded workspace-wide search for
**/output.json. The conventional locations come before the search hits, and
duplicates are removed by absolute path.
Configured paths win outright: set pytestHtmlReporter.reportJsonPaths and only
those are considered. Entries that no longer exist are pruned back into workspace settings, so a
report deleted between runs stops haunting the switcher, and if every configured path is dead the
extension falls back to auto-detection. The entries are checked on disk as written, with no
variable expansion — absolute paths, which is what Configure Report Path writes
for you.
{
"pytestHtmlReporter.reportJsonPaths": ["/home/you/project/report/output.json"]
}
When two or more reports are available a dropdown appears at the top of the sidebar. Around
whichever one is active, the extension reads archive/ for history and finds the HTML
report by looking rather than by predicting: pytest_html_report.html
if that file exists, otherwise the single .html file in the folder. Recomputing the
name would disagree with the disk whenever --html-report renamed it or a
placeholder expanded across a minute boundary.
Project
Where do I file a bug?
On the repository that owns the behaviour:
- The plugin, for anything about the report's contents, a flag, an ini key or the merge command — github.com/prashanth-sams/pytest-html-reporter/issues.
- The GitHub Action, for anything about getting the report built and
published in a workflow —
github.com/prashanth-sams/pytest-html-reporter-action/issues.
Include the workflow YAML, the job log, and the
report-dirandstatusoutputs. If the problem is in the report's contents rather than in getting it built, it belongs on the plugin. - The VS Code extension files to the plugin's tracker too — that is the issues URL its manifest points at.
Where do I ask a question?
There is a Gitter room for the project: gitter.im/prashanth-sams/pytest-html-reporter. A question about behaviour is fine as an issue as well — plenty of the documented answers on this site started as one.
Before either, the CLI reference lists every flag with the default the source actually uses, and Configuration covers what overrides what.
What licence is it under?
MIT — the plugin, the GitHub Action and the VS Code extension alike. The plugin's
classifiers say the same: License :: OSI Approved :: MIT License, alongside
Framework :: Pytest and Operating System :: OS Independent.
How do I contribute?
Each repository runs its own tests, and all three are short to get going with.
$ pytest tests/unit # the unit suite
$ tox # the suite, plus a pre-commit linting environment
tests/functional holds the runnable demos rather than the unit suite — the
Selenium and Playwright tests that are photographed automatically, the step and Gherkin demos,
and the unittest suite that attaches its own screenshot. They are the fastest way to
see a change in a real report.
$ python -m pip install pytest pyyaml
$ python -m pytest
One rule there is worth knowing in advance: a new input needs a README row in the same commit.
tests/test_action_yml.py checks the metadata against the documentation and will tell
you. The composite wiring itself is only exercised for real by the self-test workflow, which runs
the action against a generated project on Linux, macOS and Windows.
$ npm run preview # renders the sidebar to HTML, no VS Code needed
$ PREVIEW_REPORT=/path/to/output.json npm run preview
The preview harness writes a light and a dark file into .preview/ with the real
VS Code theme variables stubbed in, so what you see is what the webview renders. Press F5 for an Extension Development Host when you need the real thing.
Released changes are listed on the changelog.