pytestHTML Reporter
Home Docs Changelog
Project

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.

Current release 0.4.3, 6 September 2026 — the version on PyPI. It gives a suite's two shapes one name each: named profiles hold 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.

shell Pin a release, or move to the newest one
$ 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.

0.4.36 Sep 2026latest
  • 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] in pyproject.toml, or [pytest-html-reporter.profiles.ci] in a pytest.ini, tox.ini or setup.cfg — and selected with pytest --report-profile=ci. Both files are read, the one pytest chose for this run first, so a repository that keeps a pytest.ini does not have to grow a pyproject.toml to say ci in 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 = ci in [pytest], profile = "ci" in the tool table, or PYTEST_HTML_REPORTER_PROFILE — a bare pytest is then already the shape this repository agreed on, and --report-profile=none is how one run opts back out, which is why no profile can be called none. 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, addopts included: 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-logs and report_logs resolve exactly as they always did and answer the profile, an xdist worker 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, links and link_patterns add up rather than replace, the way --build-info has 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: two Branch rows saying main, 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 to all — 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's Environment panel carries a Profile row 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 with ci, 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, and pytest --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. --all also lists the settings nobody named, and --json prints 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 — Rerun to 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 to 0ms in the time in tests tile, and each phase in Test Steps read 0 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 Metrics Time column reads 0.44 ms, 250 ms or 1.23 s and 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.
0.4.25 Sep 2026
  • The Rerun count 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 said PASS  2 and 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, marked kept: 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 -n each 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, last and worst leave 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 0 the 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, the reruns ini key, @pytest.mark.flaky(reruns=n) and --only-rerun are all read the way the count already read them, by counting the attempts that actually happened.
  • The Environment panel 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-info flag before the run. The CI system is now detected from its own variables and a Pipeline row 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 sets CI is 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_ID deliberately 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.
  • Branch and Commit are read from the CI system where it publishes them and from git otherwise. That order matters: a CI checkout is a detached HEAD, where git itself answers HEAD, 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 — a branch, commit, ci or pipeline already named through --build-info or the build_info ini key is the answer shown, and the detected one is dropped rather than rendered beside it disagreeing.
  • Three rows say what actually ran the tests. Platform names 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 in Darwin 24.6.0, and the architecture is the difference between two machines that agree about every other row and installed different wheels. Python carries the implementation and word size, and a new Interpreter row names the python that actually ran — the row that ends an argument about which virtualenv was active. A parallel run gets a Workers row counting the xdist workers that reported results, counted rather than taken from -n, and it says 3 of 8 requested when 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-packages lists every installed distribution and its version, the way pip freeze reads, in a Packages row that carries the count in its label. report_packages in 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. The Plugins row 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.
0.4.14 Sep 2026
  • A marker holding an id can now be a link. report_link_pattern takes one MARKER=URL per line — jira = https://acme.atlassian.net/browse/{} — and every test carrying that marker gets a badge that opens the ticket. --report-link-pattern does 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 as PROJ-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.format reads every brace in a string, and a url is a place people put them, so a templated Confluence link threw KeyError instead 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 where smoke and slow catch the eye answers neither. Give owner a pattern of its own and it links too. Ownership is also a dimension you can filter on: once anything carries an owner, the Test Steps rail grows a second row of pills — one per team, counted, busiest first, with an Unowned pill 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 at trivial and two at blocker are 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, so severity("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 than normal, and a word outside the five is kept but sorts after trivial. 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 emit test_key writes @pytest.mark.test_key and gets exactly that. Only owner, severity and the markers named in report_link_pattern are 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 both normal and critical is one no consumer can rank. See CI integrations.
  • Both markers are registered with pytest, so --strict-markers accepts them and no run prints PytestUnknownMarkWarning for 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.
  • step now works in an async test the way it always has in a sync one: async with step(...), and @step on an async def. Nothing has to be installed and no setting turns it on — pytest-asyncio, anyio and trio are all driven the same way. async with was a TypeError before this, and @step on an async def was worse than unsupported: it reported a failing step green. Calling an async def only 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 through functools.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.local to a ContextVar holding 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 a yield in 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_requires says >=3.7, which is what contextvars needs. It said >=3.5 before, which no supported pytest has run on for years.
0.4.03 Sep 2026
  • 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 bare pytest that 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 than display: none, which cannot be transitioned, and take no clicks or tab stops while folded. Each copy raises one centred role="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 what file:// 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 reached item.funcargs, the only place the capture looked. Fixture values are now also read out of the request's own cache: read, not requested, because getfixturevalue on 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 bare assert reads as AssertionError, 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 tests while 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.
0.3.92 Sep 2026
  • 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 from screenshot() 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. A Mock is 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 (failed by default, all or none) with a report_screenshots ini key. An image handed to attach() 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 #b13635 sits 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 a var(), 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 %3C would be decoded by the URL parser.
0.3.81 Sep 2026
  • 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 a with block, 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-level pytestmark and a class marker are no longer invisible; a skipif shows its reason rather than the bare True/False it evaluates to at import; and the fixtures listed are the ones the test asked for rather than item.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 with block 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.
0.3.71 Sep 2026
  • 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/xpass count 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.2 sorts after 1788194287.271306 as text; builds stamped to the same minute are numbered; one unreadable archive is skipped rather than costing the whole tab. output.json now records each test's duration alongside its status.
  • The finished report opens in a browser when the run ends, with --report-open / report_open to 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=false counts as "not CI". A report_open that 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 failures tile over a SINCE LAST BUILD caption, read off the same per-build list the chart under it is drawn from; no change is written ±0 failures and 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.
0.3.631 Aug 2026
  • Retention by time, not just by count. --archive-days keeps only the builds from the last N days; --archive-since takes a date, or a date and a time, for a one-off cut. --archive-count, --archive-days and --archive-since intersect: a build has to satisfy every limit that is set. archive_count gains 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-count did nothing at all when --html-report named 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 own output_*.json archives. 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_report API, 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-file reads a coverage.json, a Cobertura coverage.xml (needing no coverage package installed at all) or a .coverage data file. A json or xml file beside the report or at the project root is found without being named; a .coverage file 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=URL adds 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)
0.3.531 Aug 2026
  • 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> == 3 is 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.json still 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() and attach_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, so requests and httpx both work with no new dependency. (#191) See the Python API.
  • Credentials are blanked out by defaultAuthorization, Cookie and 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=False keeps 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-attachments narrows whose attachments are kept and --report-attachment-limit caps the characters per payload (default 20000); a trimmed payload keeps its start, unlike a trimmed log.
  • Smaller fixes. attach() raises a TypeError naming attach_text() when handed a string, instead of Pillow's cannot identify image file. The report and its templates are read and written as UTF-8 regardless of the machine's locale.
0.3.430 Aug 2026
  • html_report ini key, so the report location can be set in pytest.ini without going through addopts. (#228) See Configuration.
  • The report path expands strftime placeholders--html-report=./reports/%Y%m%d/report_%H%M.html gives 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.
0.3.330 Aug 2026
  • 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=no pytest 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 bundled pytest.ini switched from -s to --capture=tee-sys.
  • --report-logs (all/failed/none) narrows whose output is kept and --report-log-limit caps 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-rerun all report the same totals pytest itself prints. (#212)
0.3.230 Aug 2026
  • 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 --dist mode is used. (#178, #55) See CI, xdist & scale.
  • --reruns was 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() raised AttributeError instead 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. --environment names the environment under test, shown as a badge beside the report title, and --build-info KEY=VALUE carries branch, commit or CI job into the same panel.
0.3.130 Aug 2026
  • 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.
0.3.029 Aug 2026
  • pytest 9.x compatibility. Older versions reached into pytest's internal _sessionstarttime attribute on the terminal reporter, which raises AttributeError: 'TerminalReporter' object has no attribute '_sessionstarttime' on pytest 9. Session timing is now self-managed.
  • KeyError: 'status' in append_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-reporter being 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.

0.2.913 Feb 2022
  • Fixed desktop CSS inconsistencies.
0.2.811 Feb 2022
  • Fixed all the code-related inconsistencies.
0.2.711 Feb 2022
  • Added a custom title on the Dashboard.
0.2.625 Apr 2021
  • Dashboard execution time in H:M:S format; long error text shortened to open in a dialog box.
0.2.520 Apr 2021
  • Fixed the incorrect execution time in the test metrics.
0.2.419 Apr 2021
  • Added support for downloading the current report (dashboard) as a PDF.
0.2.328 Sep 2020
  • Fixed UI bugs in the dashboard and the Archives layout; added error details on failed test cases.
0.2.213 Sep 2020
  • Codecov; unit tests for the plugin; UI alignment fixes.
0.2.112 Sep 2020
  • Hot fix for a CSS issue.
0.1.912 Sep 2020
  • Optimised the library size; added unit tests.
0.1.812 Sep 2020
  • Replaced the report path CLI; coverage measurement.
0.1.78 Sep 2020
  • Screenshots on failure; updated loader.
0.1.629 Aug 2020
  • pytest-rerunfailures library support.
0.1.523 Aug 2020
  • Added a report icon; updated the trend tooltips; added a reusable CSS file; fixed the test suite details.
0.0.113 Jul 2020
  • 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

SinceWhat changesHow 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

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.

Careful Retention deletes only the plugin's own 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.

Tip New to the plugin? Start at Getting started. If a release note above named a flag you have not met, every option is listed in the CLI reference with its ini equivalent in Configuration.