Changelog
Twenty-nine releases, read from the project's own CHANGELOG.txt. The plugin shipped fifteen small releases between July 2020 and February 2022, went quiet for four and a half years, then shipped fourteen releases in nine days to reach 0.4.3.
Where releases are published
Every version is published to PyPI as pytest-html-reporter. The project is MIT
licensed (Copyright © 2020 Prashanth Sams), setup.py declares
python_requires=">=3.7" as of 0.4.1, and the only runtime dependencies are
pytest and Pillow — since 0.3.7 everything the report draws with ships
inside the package, so a rendered report has no JavaScript or CDN dependency at all.
local and
ci in pyproject.toml or a pytest.ini, selected with
--report-profile=ci, overridable per job through PYTEST_HTML_REPORTER_*
variables, and printed back with provenance by
pytest-html-reporter config. On the page,
the Dashboard's seven counters became the way into the rows they count — Test Metrics filtered, and
the filter in the address as #test-metrics?status=FAIL. Durations stopped rounding fast tests
to nothing, and the footer now names the version that wrote the file.
Upgrades within the 0.3.x line change behaviour as well as adding features, so pin the version if your CI depends on the report's shape. See what changes when you upgrade below before moving off 0.2.x.
$ pip install pytest-html-reporter==0.4.3
$ pip install --upgrade pytest-html-reporter
CHANGELOG.txt writes its dates as DD/MM/YYYY — entries such as
0.2.9 (13/02/2022) and 0.2.6 (25/04/2021) settle which component is which.
This page spells the dates out. The file has no entries for 0.1.0–0.1.4 or 0.2.0; it jumps from
0.0.1 to 0.1.5 and from 0.1.9 to 0.2.1, and nothing in the repository says whether those numbers were
released and undocumented or never used, so they are absent here rather than reconstructed.
Release timeline
The 2026 line, newest first. Fourteen releases between 29 August and 6 September 2026, several landing on the same day, each of them larger than anything in the plugin's first era.
- A suite's two shapes are now two names. On a laptop you want the browser to
open, every log kept and a handful of builds archived; on CI you want no browser, logs only where
something failed, a JUnit xml beside the report and a month of history. That was two long
invocations living in a Makefile target and a workflow file, drifting the moment one of them was
edited, and the drift stayed invisible until somebody read a report missing the thing they went
looking for. Both shapes are now written down once, under a name —
[tool.pytest-html-reporter.profiles.ci]inpyproject.toml, or[pytest-html-reporter.profiles.ci]in apytest.ini,tox.iniorsetup.cfg— and selected withpytest --report-profile=ci. Both files are read, the one pytest chose for this run first, so a repository that keeps apytest.inidoes not have to grow apyproject.tomlto sayciin a single word; when both answer to one name the ini file's definition is used whole rather than merged, since half a profile from each file is a shape nobody composed. See Configuration. - The profile can be pinned rather than typed.
report_profile = ciin[pytest],profile = "ci"in the tool table, orPYTEST_HTML_REPORTER_PROFILE— a barepytestis then already the shape this repository agreed on, and--report-profile=noneis how one run opts back out, which is why no profile can be callednone. What is true of every shape of a run — the title, whether the Coverage tab is built — goes in the[tool.pytest-html-reporter]table itself, which applies to a run naming no profile too, so it is a layer rather than something that only sometimes exists. - Every setting also answers to a
PYTEST_HTML_REPORTER_variable, under both its short name and its ini spelling, and it sits above the profile. That is what an override is for: the profile is what the repository committed, and the variable is one job, one machine or one debugging session saying otherwise without editing a file everybody else reads. A variable set to nothing is not an answer — a matrix leg that left its value blank means "I am not saying", not "write nothing". A flag typed on the command line still wins over all of it,addoptsincluded: that line was written for this repository about every run of it, and a profile quietly overriding it would leave two config files disagreeing with nothing on the page saying so. - Everything a profile sets lands on the very option the flag lands on, so nothing
else in the plugin needed to learn that profiles exist.
--report-logsandreport_logsresolve exactly as they always did and answer the profile, anxdistworker is shaped by the same profile the controller is, and a suite that names none runs byte for byte the report it ran yesterday.build_info,linksandlink_patternsadd up rather than replace, the way--build-infohas always added to the ini key — and a label named by more than one layer is now one row carrying the highest layer's answer, at every layer rather than only against the ini key. The obvious way to write a profile is to copy a block of ini keys under a name and leave the originals where they are, and adding up meant every one of those rows was rendered twice: twoBranchrows sayingmain, with nothing to say which the run had used. - A profile that says something the flag would have refused fails the run at configure
time, with one line, before a test is collected.
logs = "fail"is a typo rather than a setting, and without the check it would land on the option, fail the helper's own test and fall through toall— a run that kept every log because six letters were typed instead of seven. An unknown key fails the same way and names the file it is in; an unknown profile lists the ones that are defined and offers the nearest. The report'sEnvironmentpanel carries aProfilerow naming the profile the run was built with, so a report found on a CI server months later can answer "where did the logs go" with "this was built withci, which keeps them only on failures". - Six layers is more precedence than anybody holds in their head, and getting it
wrong is silent: the run is green and the report is simply not the one that was configured. Two
commands print the answer in provenance rather than in values —
pytest-html-reporter config, which resolves the files and runs nothing, andpytest --report-show-config, which prints the same table into the pytest header of a real run, where it lands in the part of the output that gets pasted into an issue.--allalso lists the settings nobody named, and--jsonprints the same document for a CI step to assert on. - The Dashboard's seven counters are now the way into the rows they count. They
counted the run and left you to go and find what they counted: read "4 failed", open Test
Metrics, filter the table down by hand. Each is now a button that opens Test Metrics already filtered
to that outcome —
Rerunto every test that ran more than once, since rerun is a count in a column rather than a status a row carries. A counter standing at zero is disabled rather than merely styled flat, so it is not a tab stop and cannot be clicked into No matching records found, which is not what the figure it was read off said. See Report tour. - The filter is in the URL, as
#test-metrics?status=FAIL, so "the failures in last night's build" is an address rather than a set of instructions. Opening one lands on the filtered table directly, and the chips over the table keep the address in step as they are clicked, so a link copied off a filtered table opens filtered. Toggling chips replaces that address rather than pushing a new one each time: Back goes back to the dashboard you came from rather than walking out through every chip you tried on the way. A filtered table also carries a Clear filter control beside the chips — clicking the chip that is holding has always cleared it, but that is only discoverable once you know it, and a table that arrived already filtered was never clicked into a chip at all. - A fast test stopped reading as an untimed one. Durations were kept to two decimal
places of seconds, which is a 10ms floor: every unit test quicker than that reached the page as a flat
0.0, a whole suite of them summed to0msin the time in tests tile, and each phase in Test Steps read0 ms— which says a test never ran rather than that it was quick. Records now carry six decimal places, and the page prints the unit that fits the figure: the Test MetricsTimecolumn reads0.44 ms,250 msor1.23 sand sorts on the raw seconds behind the cell, and attachment, step and attempt durations follow the same ladder. - The report says which release drew it. The footer under every page carries the plugin's version beside its name — a report outlives the run it came from, and the first question asked of an old one is which version wrote it.
- The
Reruncount is now a button, and behind it is every attempt the test made. A retried test has always been reported as one row carrying the outcome that stuck — which is the honest shape — but the row shows the message of that attempt, and an attempt that stuck by passing has no message, so a test that failed twice and then passed saidPASS 2and nothing anywhere in the report said what it had failed with. The panel lists the attempts in order with what each one did, how long it took and the full error it raised, and ends on the attempt the row itself is showing, markedkept: ending on the last attempt that was thrown away would leave a panel whose final line disagrees with the row that opened it. Two failures for two different reasons is a different bug report from the same failure twice, and neither is visible from a count, so the panel's Copy button hands the trail over as one line per attempt followed by that attempt's message — the shape somebody pastes into an issue. See Report tour. - The count and the trail always agree, across both folds. A row saying three
reruns beside a panel showing two attempts is worse than either alone: it says the report lost one.
A retry inside one process and a node id that ran in two shards are separate mechanisms — the
second deliberately does not go through the first, since which plugins are installed on the merging
machine is not a fact about the matrix — so both now keep what they drop, and a test retried
twice on a shard that then ran again on another machine reports four attempts and shows four. Under
-neach attempt says which xdist worker ran it; 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, where it belongs.first,lastandworstleave the trail alone — those policies mean this shard's answer rather than a fold, and manufacturing a trail under them would report attempts the chosen record never made. See CI integrations. - An attempt keeps four fields and not the record it came from — status,
message, duration, 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.
The count is still rendered exactly once in its cell, because the cell's text is what the table's
search index and every CSV, Excel and print export take. A test that ran once keeps the plain
0the column has always shown, and so does a build archived before this version: the count was stored, the attempts behind it were not, and an empty panel would be worse than none. The button is disabled rather than only styled flat, so it is not a tab stop either. Nothing needs enabling —--reruns, thererunsini key,@pytest.mark.flaky(reruns=n)and--only-rerunare all read the way the count already read them, by counting the attempts that actually happened. - The
Environmentpanel now fills itself in. It could name the host, the Python and the pytest — the easy half of what a report is asked a week later — while the half that explains a red build was left to whoever remembered to write a--build-infoflag before the run. The CI system is now detected from its own variables and aPipelinerow links straight back to the build that produced the report. GitHub Actions, GitLab CI, Jenkins, CircleCI, Buildkite, Azure Pipelines, Travis CI, AppVeyor, Drone, Bitbucket Pipelines, Semaphore, AWS CodeBuild and TeamCity are named individually; anything else that setsCIis still recorded as a build agent rather than passed off as somebody's laptop. Where a system publishes its own build url it is used as given rather than assembled, so a self-hosted install, a reverse proxy or a renamed domain is linked correctly and not to a page on the vendor's hosting that does not exist. A re-run GitHub Actions workflow links its own attempt:GITHUB_RUN_IDdeliberately does not change on a re-run, so the bare run url opens the latest attempt, and a report written by attempt 1 would have linked somebody else's failure. See CI integrations. BranchandCommitare read from the CI system where it publishes them and fromgitotherwise. That order matters: a CI checkout is a detachedHEAD, where git itself answersHEAD, which is true and useless, while the system knows the branch somebody clicked. A folder that is not a checkout gets no rows rather than a traceback. Detection never overrules you either — abranch,commit,ciorpipelinealready named through--build-infoor thebuild_infoini key is the answer shown, and the detected one is dropped rather than rendered beside it disagreeing.- Three rows say what actually ran the tests.
Platformnames the operating system the way its own users do —Ubuntu 22.04.4 LTS · Linux 5.15.0 (x86_64),macOS 15.6 (arm64)— rather than the kernel string: nobody reading a report thinks inDarwin 24.6.0, and the architecture is the difference between two machines that agree about every other row and installed different wheels.Pythoncarries the implementation and word size, and a newInterpreterrow names thepythonthat actually ran — the row that ends an argument about which virtualenv was active. A parallel run gets aWorkersrow counting thexdistworkers that reported results, counted rather than taken from-n, and it says3 of 8 requestedwhen they 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, since "1 worker" answers a question nobody asked. --report-packageslists every installed distribution and its version, the waypip freezereads, in aPackagesrow that carries the count in its label.report_packagesin the ini does the same, and is usually where it belongs: an inventory is a property of the job rather than of one run. It is off by default on purpose — 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 dependency inventory into a file that gets attached to tickets. ThePluginsrow answers a much smaller question, and the library whose new minor version broke the suite last night is almost never a pytest plugin. See Configuration.- All of it survives a merge, because a shard collects it on the machine that ran the tests. A merging job that asked its own environment which CI run this was would answer with the merge job, and one running on a laptop would answer with nothing at all. The merged panel says it once when the legs agree and says both when they do not: two legs naming two pipelines link neither — a link to the first would be read as the whole build's — and two legs that ran different commits are both named, since that is the one fact explaining why half a matrix failed. Each shard's row now carries the distribution it ran on and how many workers split it. Bundles written by an earlier version merge unchanged: every new key reads back as "this leg never said", which is exactly what an older leg means by leaving it out, so a matrix half-upgraded mid-migration needs no version bump and loses nothing.
- A marker holding an id can now be a link.
report_link_patterntakes oneMARKER=URLper line —jira = https://acme.atlassian.net/browse/{}— and every test carrying that marker gets a badge that opens the ticket.--report-link-patterndoes the same from the command line and adds to the ini rather than replacing it. The ids are grouped under the marker they were written as, so a Jira row and a Testcase row each say which system their ids belong to while the badges themselves stay as short asPROJ-123; a test that closes two tickets gets two badges in one row. Nothing is fetched and no token is needed — a report is a static file that gets mailed, published and opened off a disk months later, and a badge that needs credentials is blank in exactly those cases. Ids are percent-encoded on the way into the url, and the template is substituted rather than formatted:str.formatreads every brace in a string, and a url is a place people put them, so a templated Confluence link threwKeyErrorinstead of rendering. A scheme the report will not render —javascript:,data:— is dropped rather than linked. A marker with no pattern is untouched. See Configuration. @pytest.mark.owner("payments-team")is a built-in marker, shown ahead of the tags in a row of its own. It answers who do I tell, which is not the question the rest of the markers answer, and burying it in a row wheresmokeandslowcatch the eye answers neither. Giveownera pattern of its own and it links too. Ownership is also a dimension you can filter on: once anything carries anowner, the Test Steps rail grows a second row of pills — one per team, counted, busiest first, with anUnownedpill at the end. The counts are counted inside the current kind, so Failed then a team gives that team's failures and the pill's number is what the rail will show. The row is not drawn at all for a run with no owners.@pytest.mark.severity("blocker")is the second built-in marker, and it answers the question asked before whose is this: forty failures attrivialand two atblockerare the same number on every other tab and are not remotely the same run. The five levels are Allure's, worst first —blocker,critical,normal,minor,trivial— and case is ignored, soseverity("Critical")is not a sixth level splitting a suite's counts in half. One test, one level: where two markers claim one test the nearest wins, and two at the same scope are read as the worse of them. The overridden one is still shown, struck through beside the one that won, its tooltip saying where each was written. A test nobody rated is unrated rather thannormal, and a word outside the five is kept but sorts aftertrivial. The rail grows a third row of pills for it, drawn in ladder order and counted inside both the kind and the owner above it.- Two new Analytics panels read those markers across builds. Who owns what
is one row per owner, worst first, with the tests they hold, the share of the suite that is, their mean
pass rate, how many are failing now, how many are flaky and where their minutes go — a run with
forty failures spread over six teams and a run with forty in one team read identically everywhere else
on the page. A test with two owners counts for both; only tests this run actually ran are counted;
ownership is read from the most recent build that named one rather than unioned across history, because
a test that moved teams last month should page the team that has it today. The pass rate is the mean of
the tests' own rates, so a team holding one test that has run two hundred times does not have it decide
their number. How much it matters sits beside it: one row per level in ladder order with
Unrated last, led by what somebody came to the tab to find out —
1 critical test failing. See Analytics. - The ids reach the JUnit xml as
<property>elements on the testcase itself, which is the half Xray, Zephyr and TestRail actually read, none of them having any way to open an html report. The property name is the marker name, so a suite that must emittest_keywrites@pytest.mark.test_keyand gets exactly that. Onlyowner,severityand the markers named inreport_link_patternare written, so nothing starts appearing in a file your CI parses without having been asked for, and a marker with empty brackets writes no property. Severity is written once and already resolved: every other traceability marker is a set, but a testcase carrying bothnormalandcriticalis one no consumer can rank. See CI integrations. - Both markers are registered with pytest, so
--strict-markersaccepts them and no run printsPytestUnknownMarkWarningfor markers this plugin asked you to write. The pattern markers are registered from your configuration rather than from a list in the plugin, because which names mean something is your decision. stepnow works in anasynctest the way it always has in a sync one:async with step(...), and@stepon anasync def. Nothing has to be installed and no setting turns it on —pytest-asyncio,anyioandtrioare all driven the same way.async withwas aTypeErrorbefore this, and@stepon anasync defwas worse than unsupported: it reported a failing step green. Calling anasync defonly builds a coroutine, so the wrapper closed the step on that — nought milliseconds, PASS — and the work ran, and raised, long after the step said it had finished. It is now timed across the call, and the coroutine is found throughfunctools.wraps, so a retry or a rate limiter stacked above the step no longer hides it.- Work run concurrently comes back as the siblings it was. Steps were kept on a
per-thread stack and asyncio runs every task on the one thread, so three gathered legs came back nested
three deep inside one another — a tree that never existed. The stack moved from a
threading.localto aContextVarholding an immutable tuple, and every step now records the step it is a step of, which fixes the same misreading for threads, where it has been wrong since steps shipped. An attachment made inside one leg is filed under that leg rather than on whichever sibling happened to be open. A step left open by a cancelled task is swept at the end of the test; a step held open across ayieldin an async fixture closes where the fixture resumes; and a pooled worker thread that leaked an open step no longer indents the next test underneath it. See Steps & BDD. python_requiressays>=3.7, which is whatcontextvarsneeds. It said>=3.5before, which no supported pytest has run on for years.
- Copy the command that reruns one test. A third button beside a failure message
copies
pytest tests/test_a.py::test_one. It is built from the test's node id rather than from the suite and test names on the row — those are what a person reads, and a test inside a class is listed under its own name where pytest wants the class in front of it. A node id a shell would not read as one word is quoted, so a parametrised test's brackets are not a zsh glob; a row with no node id of its own, such as a file that failed to import, offers no command rather than a barepytestthat would run the whole suite. - Every Test Metrics row now has an address. A fourth button copies this report's URL with the row's anchor on the end, read off the row itself so link and id cannot drift apart. The anchor comes from the node id, not the row's position in the table — a positional link opened hours later would still resolve, to whichever test had since taken that place. It stays readable: the node id flattened to letters, digits and dashes, cut back to a whole word, with six hex digits of the whole id after it. Repeats of the same node id are numbered. Opening a link shows the Test Metrics tab, clears any search box or status chip hiding the row, pages the table to it through the DataTables API, and flashes it.
- The button strip folds, and every copy says what it did. All four buttons (error,
command, link, expand) hide behind a
...control and come back out along the row a tenth of a second apart, so forty failures rest as forty single buttons rather than 160. They fold by width and opacity rather thandisplay: none, which cannot be transitioned, and take no clicks or tab stops while folded. Each copy raises one centredrole="status"panel reading Error copied, Command copied or Link copied, showing what went onto the clipboard with anything past four lines faded out; a browser that refuses the clipboard, which is whatfile://gets in some of them, says Press Ctrl+C to copy instead. - A failure's screenshot now sits on the step that threw, in the
Test Steps tab, beside the error it explains. Automatic captures run
from the teardown hook with no step open, so they are filed against the step carrying the error; a
test that named no steps shows its picture under Test body. pytest-bdd scenarios
were never photographed at all — the generated test function takes no fixtures, every
step pulling what it needs through
request.getfixturevalue, so the page it was driving never reacheditem.funcargs, the only place the capture looked. Fixture values are now also read out of the request's own cache: read, not requested, becausegetfixturevalueon an unused fixture would build it, which at teardown means starting a browser in order to photograph it. See Screenshots. - Analytics opens on Why this run failed. This run's failures grouped by
the exception each came out of, biggest group first —
12 failures, 9 are TimeoutException— each group carrying its share of the run, the tests in it by name, and its movement since the last build. Nothing new is collected: the exception is read back out of the message pytest already stored. The exception that surfaced wins over the one it came from, a bareassertreads asAssertionError, and messages naming nothing land in Unclassified, held at the bottom however large it grows. A green run leaves the card out rather than showing it empty. See Analytics. - Every and N more is now a link, opening the card's own hidden items in a
scrollable dialog that searches as you type over test name and suite together and says
3 of 41 testswhile a search is on. Separately, a cut error message now fades out at its tail instead of ending in an ellipsis — the literal ellipsis stays in the cell, hidden, so a CSV, an Excel sheet and a print-out still say the message was cut.
- Screenshots are taken automatically. A test that fails while holding a Selenium driver or a Playwright page is photographed with no hook, no fixture, no import and no conftest — the recipe this plugin has documented for years is now the default. The capture runs at the top of the reporter's own teardown wrapper, before the fixture finalizers it wraps: the last moment the browser is still open, and late enough that anything the suite attached for itself has already arrived.
- A browser is recognised by what it can do, not by what it is, so nothing imports
selenium or playwright. Anything answering
get_screenshot_as_png()or handing bytes back fromscreenshot()qualifies, which covers appium, splinter and a driver wrapper of your own for free. Fixture names (page,driver,browser,context) are a hint rather than the test, and a unittest suite's driver is found on the class. AMockis the one thing ruled out by type: it answers every call ever made to it, so it would be photographed on the strength of a method it does not have. --report-screenshots(failedby default,allornone) with areport_screenshotsini key. An image handed toattach()is kept whatever the setting says, and a test that took its own picture is not photographed again.attach()now keeps every image a test hands over rather than only the last, which is what lets one test carry a picture of each of two browsers.- A Screens column on Test Metrics carries the pictures themselves: a thumbnail on the row beside the error it explains, opening full size in the same overlay the gallery uses. It sorts on the count, since every one of those cells has the same empty text.
- A dark theme, with a switch at the foot of the side nav. The choice is remembered
per reader; until it is touched the report follows the operating system, and the theme is settled by
a script in the head before the stylesheets, so a report opened in dark never flashes a
white frame. Light mode is unchanged, verified by expanding every
var()back and diffing the CSS, and by comparing the rendered colour of 838 elements across all nine tabs — worst difference 1.94 CIEDE2000. Status colours are carried across deliberately (pass#98cc64, fail#fc6766, skip#ffd050); only ERROR moves, because#b13635sits at 2.7:1 on a dark card. - Three things fought the theme. Chart.js copies the colours it is handed into its
own model at construction, so every chart is built through
themedChart(), which re-resolves its tokens when the theme changes. The vendored light-mode Bootstrap and DataTables stylesheets are undone for dark only. The sort arrows, search glyph and select caret are SVG data URIs, which cannot hold avar(), so each theme carries its own copy — base64 rather than percent-encoded, because the PDF export serialises the page into an SVG data URL and a%3Cwould be decoded by the URL parser.
- New Test Steps tab: the named, timed pieces a test is actually made of, drilling
down from suite to test to what it did. A status column says a test failed; this says where,
and how long it had been running when it got there.
step()is both a decorator and awithblock, and steps nest by being called from inside one another, with nothing threaded between them; a decorated function's arguments fill the{placeholders}of its title and are kept as the step's parameters. - The tab is never empty. Every test has a set up, a body and a tear down, each
timed from pytest's own per-phase report, plus its markers, parameters, fixtures and docstring.
Markers are read with
iter_markers_with_node, so a module-levelpytestmarkand a class marker are no longer invisible; askipifshows its reason rather than the bareTrue/Falseit evaluates to at import; and the fixtures listed are the ones the test asked for rather thanitem.fixturenames, the whole transitive closure, where a test naming two fixtures read as a test naming nine. - pytest-bdd needs no changes at all. Given/When/Then arrive as steps
automatically, badged as Gherkin, with the feature, scenario and feature file named above the tree.
The bdd hooks are declared
optionalhook, which is the whole of the defence: pytest does not warn or skip an unknown hook, it refuses to start, so declaring them plainly would have broken every run without pytest-bdd installed. See Steps & BDD. - Correctness of the tree. Anything attached while a step is open is filed under
that step. A failure's message is kept on the step that raised, and the steps it walked out through
are marked failed without repeating it — one exception was printing the same traceback four
times. A yield fixture's open step no longer swallows the test that used it; steps are recorded per
thread; a
withblock that never exited is closed at the drain and marked failed; and durations are summed from per-phase milliseconds rather than seconds rounded to two places, where every test faster than 5ms arrived as a flat 0. --report-steps(all/failed/none) and--report-step-limit(default 500), with matching ini keys; the cut is followed by a line saying the rest were dropped, because a tree that stops halfway reads as a test that stopped there. A Steps column on Test Metrics crosses to the tab with that test already open, and step trees are parked outside the metrics table, so they never reach its search index or its CSV, Excel and print exports.- Layout. The tab opens on a failure that has steps rather than on the first test in collection order, which is almost always one that named none. The rail opens with every suite shut, with Expand all / Collapse all beside the filters. API Logs gets the same card-sizing fixes and the same type sizes, its rail entries, status, heading, meta strip and payload all having been a point or two smaller.
- New Analytics tab, reading every archived build. The Dashboard is one run seen from every angle; Analytics is every archived build seen from the test's angle. Nothing extra is collected — every archive already holds a status per test, and that history had simply never been read across files. Six figures across the top, a pass-rate chart on an axis deliberately not pinned to 0–100 (a suite living between 96% and 99% is exactly the one whose two-point drops matter), build-to-build movement, a duration histogram, a slowest-ten chart, four cards naming what changed since the last build, and a row per test showing recent outcomes as one coloured block per build.
- The scoring rules are opinionated and documented. The stability score starts at
the mean per-test pass rate and is charged half the mean flip rate. Flaky and always-failing are
counted apart, because a test that only ever fails is a bug with an owner. A retry inside
one build counts as flaky on its own. Skips are left out of the pass/fail arithmetic rather than
counted against a test, and
xfail/xpasscount as passes. - Robustness of the history read. Tables count every build still on disk within the
retention limits while the charts draw the most recent twenty. Archives are ordered on the timestamp
inside the file rather than the file name, where
1788194287.2sorts after1788194287.271306as text; builds stamped to the same minute are numbered; one unreadable archive is skipped rather than costing the whole tab.output.jsonnow records each test's duration alongside its status. - The finished report opens in a browser when the run ends, with
--report-open/report_opento turn it off. It only happens on a run somebody is sat in front of: output going to a terminal, no CI variable set, and a desktop to open into.CI=falsecounts as "not CI". Areport_openthat is not one of the three modes fails the run with a usage error while it is still being configured. - Everything the report draws with now travels inside it. jQuery, DataTables and its Buttons, Bootstrap, Chart.js, jsPDF, dom-to-image and JSZip ship with the plugin; the Font Awesome glyphs ship as SVGs inlined as CSS masks, with the markup unchanged; the favicon, rail logo and spinner were being fetched from imgur and are now data URIs. fancyBox is gone — GPLv3 or a paid commercial licence, never something an MIT-licensed package could ship — replaced by a lightbox of the report's own. Run offline before this and the report was a blank white page. The report grew from about 300KB to about 1.3MB, which is one file per report folder, not per archived build.
- Dashboard and Test Metrics. The Highlights card gains a
▲ +3 failurestile over aSINCE LAST BUILDcaption, read off the same per-build list the chart under it is drawn from; no change is written±0 failuresand a first build leaves the entry out. Test Metrics gains a per-test Rerun column, a copy button on every row with an error, and an expand button where the blue(...)link was, opening the full error in the report's own monospace panel. The full error now travels in a data attribute, so it no longer reaches the search box or the CSV, Excel, Copy and print exports.
- Retention by time, not just by count.
--archive-dayskeeps only the builds from the last N days;--archive-sincetakes a date, or a date and a time, for a one-off cut.--archive-count,--archive-daysand--archive-sinceintersect: a build has to satisfy every limit that is set.archive_countgains an ini key, having been the one option with no ini equivalent — retention belongs to the job rather than to one invocation. (#223) - Four retention bugs.
--archive-countdid nothing at all when--html-reportnamed the html file itself, so those setups had been keeping every build for ever. A build's age is now read from the moment in its archive file's name rather than the file's mtime, so retention still measures the right thing in a fresh CI workspace where every mtime is the checkout. Retention only ever deletes the plugin's ownoutput_*.jsonarchives. A nonsense value now fails the run with a usage error instead of being silently ignored. - New Test Coverage tab: the overall percentage as a ring, the counts beside it, a
row per file with its missing lines, and the percentage plotted across the retained builds. The
figure is coverage.py's own, read through its public
json_reportAPI, so the tab and the terminal beside it can never print different totals. Coverage is read after the run rather than measured, so a parallel run reports one combined percentage. Files are listed least-covered first, which is also how the list is cut when the limit applies. - Reading coverage from elsewhere.
--report-coverage-filereads acoverage.json, a Coberturacoverage.xml(needing no coverage package installed at all) or a.coveragedata file. A json or xml file beside the report or at the project root is found without being named; a.coveragefile is not, because one is usually left over from an earlier run and publishing last Tuesday's number is worse than publishing none. The coverage html report is linked, never embedded. --report-link LABEL=URLadds entries to the report's side nav — the annotated coverage source, a CI job, anything else worth reaching from here — dropping any scheme other than http, https or mailto. (#203)
- Report text that looked like markup was being written into the page as markup. A
parameter id or an assertion message holding angle brackets —
assert <Foo object at 0x7f> == 3is what an ordinary failure looks like — mangled its own row and swallowed the rows after it, so tests went missing from Test Metrics. Names, failure messages, captions, the report title and the environment badge are now escaped on the way into the page, and the chart labels are written as a JSON literal.output.jsonstill carries its text unescaped, because the archive and trend views read that file back. - Two ways a run could under-report itself. A file that fails to import produced no test items and was missing from the report entirely; it is now a row of its own, reported once rather than once per xdist worker. And a test whose fixture raised while cleaning up was reported as a plain pass, so a failing run could read as green.
- Text attachments:
attach_text(),attach_json(),attach_api()andattach_file()keep a payload against the test that produced it.attach_api()takes a response object apart into the response body, the request body, both sets of headers and the curl command that repeats the call; response objects are read by duck typing, sorequestsandhttpxboth work with no new dependency. (#191) See the Python API. - Credentials are blanked out by default —
Authorization,Cookieand anything named like a token, secret, password or api key, in headers, in a URL query string, in the curl line, and in the fields of a JSON body at any depth.redact=Falsekeeps them. - New API Logs tab: a rail of everything the run attached with the payload open
beside it, filterable by kind and searchable, whose summary counts what the rail is currently
showing so the numbers cannot disagree with the list under them. Test Metrics gains a Data column.
--report-attachmentsnarrows whose attachments are kept and--report-attachment-limitcaps the characters per payload (default 20000); a trimmed payload keeps its start, unlike a trimmed log. - Smaller fixes.
attach()raises aTypeErrornamingattach_text()when handed a string, instead of Pillow'scannot identify image file. The report and its templates are read and written as UTF-8 regardless of the machine's locale.
html_reportini key, so the report location can be set inpytest.iniwithout going throughaddopts. (#228) See Configuration.- The report path expands strftime placeholders —
--html-report=./reports/%Y%m%d/report_%H%M.htmlgives each run a folder or filename of its own with no conftest hook. Placeholders are expanded once while the run is configured, so an xdist run and a run that crosses a minute boundary still write a single report; a%that is not a placeholder is left alone, and%%writes a literal one. - A screenshot attached from a fixture's teardown never reached the report. The record is now built after the fixture finalizers have run, so the capture-on-failure recipe from an autouse fixture works.
- A screenshot attached by a test that did not fail was handed to the next test that did, which was then illustrated with a picture of a test it never ran. The image is now discarded, with one warning per run.
- Screenshots are no longer limited to failures: every image passed to
attach()is kept and shown in the gallery whatever the test did, and a tile for a test that did not fail is captioned with its outcome rather than the blank space where a failure message would be.attach()works with Playwright as well as Selenium — it takes PNG bytes, not a browser.
- Captured logs per test. stdout, stderr and logging output from setup, call and teardown are kept against the test that produced them and opened from a new Logs column in Test Metrics. Captured output is held outside the table, so it is not swept into the metrics search box or the CSV, Excel and print exports, and it survives an xdist run intact.
- The report explains an empty Logs column. Under
-s/--capture=nopytest captures no stdout or stderr at all, only logging output, and the report now says so; the Environment panel states what the run kept and from which log level. The bundledpytest.iniswitched from-sto--capture=tee-sys. --report-logs(all/failed/none) narrows whose output is kept and--report-log-limitcaps the characters per test (default 10000); a trimmed test keeps the end of its output, cut to a whole line.- Reruns were counted as extra failures when the retry count came from
@pytest.mark.flaky(reruns=n)rather than--reruns. Attempts are now collapsed into one record, so--reruns, the ini key, the flaky marker, a marker that disagrees with--reruns, and--only-rerunall report the same totals pytest itself prints. (#212)
- pytest-xdist support: a parallel run now produces one report instead of
one per worker, with every test, suite and total in it, and a single build in Archives. Tests are
listed in collection order, so a parallel report reads the same as a serial one whatever
--distmode is used. (#178, #55) See CI, xdist & scale. --rerunswas silently ignored under xdist — workers are started with an empty argv, so the value is now read from the pytest config instead.- Screenshot fixes:
attach()raisedAttributeErrorinstead of saving the image, and each screenshot is now written once, under a name that cannot collide between workers. Run times over a minute were being reported as seconds. - Environment overlay on the dashboard: host, platform, python and pytest versions,
active plugins, invocation arguments, rootdir and generation time.
--environmentnames the environment under test, shown as a badge beside the report title, and--build-info KEY=VALUEcarries branch, commit or CI job into the same panel.
- Dashboard, suite highlights and test metrics tables redesigned with a cleaner, consistent layout, and the Archives view reworked — fixing the archive build count, which was silently dropped by a typo, and the chart sizing.
- The PDF download now renders the desktop dashboard at a fixed viewport, waits for the charts to settle and keeps a white background.
- An empty-state UI for the Screenshots tab, and charts that re-fit on window resize instead of reloading the page, so the open tab, the scroll position and the table filters are preserved.
- pytest 9.x compatibility. Older versions reached into pytest's internal
_sessionstarttimeattribute on the terminal reporter, which raisesAttributeError: 'TerminalReporter' object has no attribute '_sessionstarttime'on pytest 9. Session timing is now self-managed. KeyError: 'status'inappend_suite_metrics_row— both raising it and reaching test results through it — is fixed, with improved safety checks around test-data access generally.- Templates are shipped as package data and resolved from the installed
package, so template lookup no longer depends on a source folder named
pytest-html-reporterbeing present. Plus responsive fixes, a fix for URL reset on page load, and unit-test fixes.
Earlier releases, 2020 to 2022
Fifteen releases across the plugin's first era: single fixes, single features, and long stretches between them. The whole foundation went in over ten weeks in 2020; the last of them, 0.2.9, shipped in February 2022 and was followed by four and a half years of silence before 0.3.0. Several of these entries were superseded outright in the 0.3.x line — the 0.2.6 error dialog became the report's own monospace panel in 0.3.7, the 0.1.7 screenshots-on-failure recipe became automatic capture in 0.3.9, and the 0.1.6 pytest-rerunfailures support was rewritten in 0.3.3.
- Fixed desktop CSS inconsistencies.
- Fixed all the code-related inconsistencies.
- Added a custom title on the Dashboard.
- Dashboard execution time in H:M:S format; long error text shortened to open in a dialog box.
- Fixed the incorrect execution time in the test metrics.
- Added support for downloading the current report (dashboard) as a PDF.
- Fixed UI bugs in the dashboard and the Archives layout; added error details on failed test cases.
- Codecov; unit tests for the plugin; UI alignment fixes.
- Hot fix for a CSS issue.
- Optimised the library size; added unit tests.
- Replaced the report path CLI; coverage measurement.
- Screenshots on failure; updated loader.
- pytest-rerunfailures library support.
- Added a report icon; updated the trend tooltips; added a reusable CSS file; fixed the test suite details.
- First release.
What changes when you upgrade
Everything below is drawn from the entries above. Upgrading from anywhere in the 0.2.x line lands you in a substantially different plugin, so the notes are grouped by what will actually change under you.
Behaviour that changes without you asking
| Since | What changes | How to keep the old behaviour |
|---|---|---|
0.4.3 |
The Test Metrics Time column carries its unit — 0.44 ms, 250 ms, 1.23 s — where it used to hold a bare number of seconds, and the column header lost its (s). The cell's text is what the CSV, Excel and print exports take, so a step that parsed that column as a float needs the unit stripped. Sorting is unaffected: the raw seconds sit in a data-order behind the cell. |
— |
0.4.3 |
A profile named in an ini file or pyproject.toml and pinned with report_profile or profile shapes a bare pytest run, and a PYTEST_HTML_REPORTER_* variable left in a shell now decides a setting that used to come from the ini key. Both are new layers between the flag and the ini file. |
--report-profile=none, and unset the variable |
0.4.2 |
The Rerun cell is a button on any test that was retried, and opens a panel of the attempts behind it. The number in the cell is unchanged, and so is the cell's text, so search and the exports read exactly as before. |
— |
0.4.2 |
The Environment panel grows rows nobody asked for: CI, Pipeline, Branch, Commit, Interpreter and, under -n, Workers. Platform changes wording, naming the operating system rather than the kernel. A published report therefore now names the branch, the commit and the pipeline it came from. |
Name them yourself with --build-info, which wins |
0.4.1 |
python_requires rose from >=3.5 to >=3.7, which is what contextvars needs. pip on 3.5 or 3.6 will resolve to 0.4.0 rather than refusing. |
pytest-html-reporter==0.4.0 |
0.4.1 |
The JUnit xml gains a <properties> block on any testcase carrying owner, severity or a marker named in report_link_pattern. A collector that reports unknown properties will start reporting these. |
Drop the markers, or the pattern that names them |
0.4.1 |
owner and severity are registered markers, so a suite already using either name for something else now inherits this plugin's meaning of it — a badge, a filter row and an Analytics panel. |
Rename the marker |
0.4.1 |
A step tree built by concurrent work changes shape: gathered legs that used to nest three deep inside one another now sit beside each other, and threads that interleaved into one buffer are no longer drawn under whichever step was last opened. | — the old shape was wrong |
0.4.1 |
@step on an async def used to report a failing step as passing in nought milliseconds. Such steps now show their real duration, and a step that raises is recorded failed — so a run that read green may now read red. |
— |
0.3.9 |
A test failing with a live Selenium driver or Playwright page is photographed automatically. A test that took its own picture is not photographed again. | --report-screenshots=none |
0.3.9 |
attach() keeps every image, not only the last, so a test that calls it twice now shows two. |
— |
0.3.7 |
The report opens in a browser when the run ends. Guarded three ways, so build agents are unaffected. An invalid report_open now fails the run at configure time. |
--report-open=none |
0.3.6 |
Retention actually deletes. --archive-count had been doing nothing whenever --html-report named the html file itself, so such setups had kept every build for ever. A nonsense retention value now fails the run. |
Raise or drop the limit |
0.3.4 |
The report path is strftime-expanded, so a % that happens to form a placeholder is expanded. |
Write %% |
0.3.3 |
Rerun totals change to match what pytest itself prints, including when the count comes from @pytest.mark.flaky(reruns=n). Suites reading the old inflated failure counts will see different numbers. |
— |
0.3.2 |
An xdist run produces one report, not one per worker, and one Archives build rather than several. | — |
Report content and exports that change shape
- 0.4.3 — the
Timecolumn's text changes, and it is the one column whose text an export is likely to be parsed for:0.25is now250 ms, and a test measured in microseconds says0.44 msrather than0.0. The column sorts on the raw seconds behind the cell, so the slowest test of the run is still the first row. Nothing else in the table moves: the dashboard's counters and the status chips filter the rows that are already there, and the filter lives in the page's hash rather than in any cell. - 0.4.2 — nothing in the metrics table's text changes: the attempt trail is parked
outside the table the way step trees (0.3.8), captured logs (0.3.3) and attachments (0.3.5) already are, so
it is absent from the table's search index and from its CSV, Excel and print exports, and the
Reruncolumn still exports the single number it always did. TheEnvironmentpanel gains the detectedCI,Pipeline,Branch,Commit,InterpreterandWorkersrows, plus aPackagesrow when--report-packagesasked for one. - 0.4.1 — the JUnit xml gains a
<properties>element on each testcase that carriesowner,severityor a marker named inreport_link_pattern, written ahead of the outcome where pytest's own writer putsrecord_property. Markers holding ids are grouped by the marker they were written as rather than shown as one flat row of badges. - 0.4.0 — Test Metrics rows gained
idanchors derived from node ids, and the four per-row buttons fold behind a...control by default. Cut messages fade at the tail; the ellipsis remains in the cell text, hidden, so CSV, Excel and print exports still show that a message was cut. - 0.3.8 — step trees are parked outside the metrics table, so they are absent from its search index and its CSV, Excel and print exports, as captured logs (0.3.3) and attachments (0.3.5) already were.
- 0.3.7 — the full error text moved out of the table cell into a data attribute.
The exports used to carry the cut message, the
(...), the whole error again and the wordCloserun together in one field; they now carry the cut message and an ellipsis. - 0.3.5 — a file that fails to import is now a row of its own, so reports gain rows that previously vanished silently; a test whose fixture raised during cleanup is now reported as an error rather than a pass, so a run that read green may now read red — correctly.
- 0.3.3 — the bundled
pytest.iniuses--capture=tee-sysinstead of-s. Under-sthe Logs column holds logging output only, and the report says so.
Archives written by older versions
The longitudinal views — Archives, Analytics and the coverage trend
line — are built by reading back the output.json each build writes, and a folder of
builds is usually written by more than one version of the plugin.
- 0.4.3 —
output.jsonkeeps each duration to six decimal places rather than two. A build archived by an earlier version stored its fast tests as0.0, and it is read back exactly as it was written — the precision arrives with the builds written from this version on, and no trend line is redrawn retroactively. - 0.4.2 — the attempt trail is not archived. A build written before this version stored the rerun count and not the attempts behind it, so its rows keep the count and offer no panel — an empty one would be worse than none. Shard bundles written by an earlier version merge unchanged: every key added in this version reads back as "this leg never said", which is what an older leg means by leaving it out.
- 0.4.1 —
output.jsongained each test'sownerandseverity, which is what lets the two new Analytics panels read across builds at all. Builds archived by an earlier version carry neither key and are read as unclaimed and unrated rather than as anything invented. - 0.3.6 —
output.jsongained the coverage percentage. A build that measured nothing leaves a gap in the trend line rather than a drop to zero. - 0.3.7 —
output.jsongained each test's duration. Builds archived by an earlier version have no such key and are read as "not measured" rather than as instant. - 0.3.5 —
output.jsondeliberately keeps its text unescaped. Escaping belongs to the page; the archive and trend views read this file back.
output_*.json archives, and reads a build's age from
the timestamp in the archive filename rather than the file's mtime — which is what keeps it correct
after reports have been copied into a fresh CI workspace.
Following releases
Releases are cut by tagging vX.Y.Z and publishing the built distribution to PyPI, with
CHANGELOG.txt as the GitHub release description. The canonical copy of everything on this page
is that file in the repository.
PyPI
Every published version, and the one pip install resolves to.
GitHub releases
Tags, release notes and the source archive for each version.
Open releasesIssue tracker
Where the numbered fixes on this page (#228, #212, #191) came from. Report a bug or ask for a feature here.
Open issues