pytestHTML Reporter
Home Docs VS Code extension
Integrations

VS Code extension

The extension reads the same output.json the plugin already writes and renders it beside your code: failures grouped by suite, a readable error on every card, and one click from a failure to the def that produced it.

What it is

Pytest HTML Reporter for VS Code is a read-only viewer for reports produced by the pytest-html-reporter pytest plugin. Its single data source is the plugin's output.json, plus the rotated copies the plugin keeps in archive/. It never runs pytest, and it never scrapes the generated HTML for data.

The problem it solves is the walk between the report and the code. A generated report lives in a browser tab: you read a failure there, remember the suite and the test name, switch windows, open the file, and search for the function. The extension collapses that into a click. The same run summary, the same failures, the same error text — in the activity bar, one click from the def.

What it deliberately does not do

It does not run your tests. The Python extension's Test Explorer already does that, and this extension never shells out to pytest. It does not reproduce Steps, Attachments, logs, BDD scenarios or per-file coverage either — those are the HTML report's job, and the sidebar links out to it rather than rebuilding it. Every number in the sidebar comes from output.json; the HTML panel is display only and feeds nothing back.

NoteThe extension is a viewer, not a runner. If the sidebar is out of date, it is because no new output.json has been written — run pytest again.

Installing

The extension is published as prashanth-sams.pytest-html-reporter-vscode — publisher prashanth-sams, display name Pytest HTML Reporter, category Testing. It requires VS Code ^1.90.0 and activates on onStartupFinished, so nothing else is needed after install: open a folder that contains a report and the view populates itself.

From the Extensions view

  1. Open the Extensions view

    Ctrl+Shift+X on Windows and Linux, Cmd+Shift+X on macOS.

  2. Search for Pytest HTML Reporter

    Install the entry published by prashanth-sams. The icon is the reporter's own bar-chart mark.

  3. Open the view

    Click the Pytest HTML Reporter icon in the activity bar. The view inside it is titled Test Results.

From the command line, Quick Open, or a VSIX

shell Install from the Marketplace without leaving the terminal
$ code --install-extension prashanth-sams.pytest-html-reporter-vscode

The shipped artifact is a single esbuild bundle (dist/extension.js) plus the icons and docs; src/, tests/, tools/ and .preview/ are kept out of the package.

Requirements: a report to read

The extension displays what the plugin writes, so the plugin has to have run. Install pytest-html-reporter into the environment your tests run in and pass --html-report. The full path from nothing to a report is on Getting started, and every flag is on the CLI reference.

shell Produce a report the extension can read
$ pip install pytest-html-reporter
$ pytest --html-report=./report

Around the output.json it resolves, the extension expects — and reads — exactly this layout. <report dir> is simply the directory containing that file; nothing about the location is hard-coded beyond the discovery order below.

shell What sits next to output.json
<report dir>/output.json                       the current run
<report dir>/archive/output_<timestamp>.json   past runs, the history window
<report dir>/pytest_html_report.html           the full HTML report
<report dir>/pytest_screenshots/               screenshots referenced by the HTML
TipOnly output.json is required. A missing archive/ just means the history window is one build long, and a missing HTML report only removes the footer button.

The view is a webview called Test Results, inside the Pytest HTML Reporter activity bar container. It keeps its context when hidden, so switching away and back does not re-parse the report.

Screenshot: assets/img/shots/vscode-sidebar-results-dark.png The results state, dark theme — open .preview/preview-results-dark.html from the extension repo and capture it at sidebar width (roughly 340px). It must show the summary panel with the 98% pass-rate ring, the proportional distribution bar, the tinted count chips (664 passed, 14 failed, 1 skipped, 1 xpassed, 1 xfailed), the "14 failing tests" heading, and at least the first two suite groups with their failure cards and error snippets.

1. Report switcher

A <select> at the very top, rendered only when two or more reports are available. Each option is labelled with the last two path segments — report/output.json — so report/ and reports/ stay distinguishable; the value is the full path. Choosing one sets it as the active report and reloads.

2. Summary panel

A bordered card holding five things.

The ring rounds plainly, so a run one test short of perfect reads 100% on the dial, with the tone and the count chips carrying the rest of the truth beside it. The percentage that is guarded in both directions is the pass rate on a flaky · P% badge: it renders 99% rather than a 100% the badge itself contradicts, and 1% rather than a 0% a test that has passed never earned.

3. Failing tests, grouped by suite

A section title reads N failing tests, with a muted N builds of history on the right when more than one build loaded. Below it, only suites containing at least one FAIL or ERROR are rendered, in report order. Each group header is clickable, shows the suite path and a red pill with the count of failing tests in it, and toggles the group body. Long suite paths elide at the front, because the filename is what identifies a suite.

4. Test cards

One card per failing test — FAIL and ERROR only; an expected failure is not a failure. The whole card is the jump target. It carries the test name as pytest wrote it, parametrization suffix included (test_heading[chromium]), a status badge, a meta row of badges, and the error snippet on a neutral plate with a red left edge.

BadgeWhen it appearsWhat it says
DurationAlwaysnot measured when the record carries no duration, <1ms under a millisecond, 10ms under a second, 3.42s under a minute, 1m 0s above.
RetriesRerun count above zeroN retries on an orange plate.
brokenFailures and zero passes across at least 2 buildsbroken · 0/N on a red plate; tooltip "Never passed in N builds".
flakyThe test both passed and failed in the window, or was retried within a build while also passingflaky · P% on a yellow plate; tooltip "F flips across N builds".
Trend stripAt least 2 recorded outcomes, and not brokenThe last 12 outcomes as coloured ticks, oldest to newest.
copy errorAlwaysA text button; puts the raw message on the clipboard without triggering the card's jump.
Notenot measured is not zero. Archives written before plugin 0.3.7 carry no duration at all, and defaulting those to zero would report a suite of instantaneous tests. A measured zero is real and renders <1ms.

An Open full HTML report button, rendered only when an HTML report was actually found next to output.json.

Theming

Every colour in the panel is a VS Code theme variable with a hard-coded fallback, so it follows light, dark and high-contrast themes rather than carrying its own palette. Status colours come from the --vscode-testing-* family — the same one the built-in Test Explorer uses — plus the chart colours for xPASS, xFAIL, reruns and warnings. prefers-reduced-motion disables the loading spinner's animation and the donut transition.

Screenshot: assets/img/shots/vscode-sidebar-results-light.png The same results state in the Light+ theme — .preview/preview-results-light.html at the same sidebar width and the same scroll position as the dark capture, so the two can sit side by side and show the panel following the editor theme.

Every load picks exactly one of these and renders it whole.

StateWhen it is shownWhat the user sees
loadingAt the start of every load, auto-refresh includedA spinner and "Loading test results…".
no-configNo configured path exists and detection found nothingClipboard icon, "No report found", the hint pytest --html-report=./report in a code chip, and the buttons Configure report path and Refresh.
errorReading or parsing the resolved output.json threwRed alert icon, "Unable to load report", the exception message, and the buttons Retry and Configure report path.
emptyThe report parsed but records zero testsReport switcher, clipboard icon, "No tests in this report", and a Refresh button.
all-passedParsed, has tests, no FAIL or ERRORReport switcher, the full summary panel, a green check, "All tests passed", "N tests completed with no failures.", and the footer.
resultsParsed, has tests, at least one FAIL or ERRORReport switcher, summary panel, failing-test count, collapsible suite groups of failure cards, and the footer.
Screenshot: assets/img/shots/vscode-sidebar-all-passed-dark.png The all-passed state, dark theme, at sidebar width: the summary panel above a green check icon, "All tests passed" and "N tests completed with no failures.", with the "Open full HTML report" footer button visible. Regenerate it first with PREVIEW_REPORT pointed at a genuinely green run — the checked-in .preview/preview-all-passed-dark.html renders the green panel over the failure fixture's summary, which reads 14 failed.

Jump to the test

This is the one feature the report format makes hard, and it is worth knowing why. output.json records no line numbers and no nodeid. Each result carries only suite_name — a workspace-relative .py path — and test_name, which is pytest's item.name: the function name plus any [param] suffix, with the class segment of the nodeid already discarded. So the file comes from the suite name, and the line is recovered by scanning that file.

  1. Click anywhere on a failure card

    The card posts its index back to the extension host, which runs the jump command with the suite name and test name.

  2. The suite path resolves against each workspace folder

    Absolute paths and Windows drive paths are used as-is; a leading ./ is stripped. If none of them stat, the basename is looked up with a bounded workspace search and the first hit is used. If nothing is found: Could not find <suite> in this workspace.

  3. The file is scanned for the matching def

    The test name is cut at the first [, because a parametrized id can itself contain brackets — test_a_status_is_grouped_by_its_class[200-2xx]. Every def name( and async def name( at any indentation is collected in source order.

  4. One match jumps; several offer a pick list

    Two or more definitions open a Quick Pick titled <name> is defined N times in this file, placeholder "The report does not record which one ran — pick a definition", each entry labelled Line N with the trimmed source line beside it. Cancelling cancels the jump.

  5. The editor opens and the line flashes

    Non-preview, cursor at column 0, revealed in the centre if it was outside the viewport, and the whole line highlighted with the find-match colour for 1800 ms.

Matching is exact on the name, so test_heading never picks up test_heading_mismatch. Decorators are handled for free: only a line whose first non-whitespace token is def or async can match, so a @pytest.mark.parametrize(...) above the function is skipped and the def is what comes back.

CarefulThe scan is a line scan, not a Python parse, so a def written inside a triple-quoted string can match. That is an accepted trade against shipping a Python parser: a mis-jump into a string literal costs one keystroke.

If the test has been renamed or deleted since the run, you get Could not find <test> in <suite> — it may have been renamed since this run. and the file still opens at line 1, because the file is still the right place to land.

Copy the real error

The plugin stores message exactly as pytest rendered it for a terminal, ANSI escape codes included — a real record contains runs like [91m and [39;49;00m in the middle of an assertion diff. So each parsed result keeps both forms: the raw message and a stripped one.

The card shows the stripped text, because a sidebar full of literal escape codes is unreadable. The copy error button puts the original on the clipboard, so pasting it into a terminal reproduces pytest's own red-and-green colouring. A toast confirms with "Error copied to clipboard." A test with no message copies nothing.

The snippet on the card is chosen, not truncated blindly: whitespace is condensed to single spaces, then the first SomeError: / SomeException: / SomeFailure: line is preferred, falling back to the head of the message for a bare assert False or a collection error. Anything longer than maxErrorLength is hard-cut with a trailing ellipsis.

Build history, flaky and broken

This is the part no single report can give, and the reason the extension reads archive/ at all. Every *.json in <report dir>/archive is read, plus the current output.json, through a cheap parse that keeps only counters and per-test outcomes — messages and snippets, by far the biggest part of a record, are dropped so a window of builds is cheap to hold. Files that cannot be read, or are not reports, are counted and skipped; they never fail the load. A missing archive/ directory is fine: a first run has a history of exactly itself.

Ordering is by each file's inner start_time, never by its filename. The plugin names an archived file for the run that displaced it, not the run inside it, so sorting on the name shifts the whole history by one build and scrambles any trend read off it. The extension has a dedicated regression test for exactly this.

After sorting oldest-first, the newest history.maxBuilds builds are kept and the rest are reported as omitted rather than silently dropped. Cross-build identity is suite_name::test_name — the only identity the archives carry.

The verdict rules

They are ported from the plugin's own analytics, so a verdict in the sidebar and a verdict on the Analytics tab agree.

A flake badge is drawn only when history actually says something: the test appears in at least 2 builds and is either broken or flaky. A stable test gets no badge, so the ones that do mean something. The trend strip needs at least 2 recorded outcomes and is skipped entirely for a broken test, whose 0/N badge and row of identical red ticks would state the same fact twice.

CarefulThe failures-per-build strip in the summary panel needs at least two builds handed to the renderer. In the shipped extension the sidebar sets the flake verdicts and the per-test trend strips but not that build list, so as of 0.1.2 the strip appears in npm run preview output and not in the sidebar itself. Badges and trend strips are unaffected.
TipIf history loading throws for any reason the sidebar swallows it. History is an enhancement; a report still renders fully without it.

The status bar item

One left-aligned status bar item mirrors the headline count and updates on every load.

The full HTML report, in an editor tab

Pytest HTML Reporter: Open Full HTML Report — the browser icon in the view title bar, or the footer button — opens the plugin's generated report inside VS Code. It exists because the generated report has tabs the sidebar deliberately does not reproduce: Steps, Attachments, Analytics, Coverage and screenshots. See the report tour for what is on each of them.

Nothing in this panel feeds the sidebar. It is display only.

Command reference

Five commands, all under the palette category Pytest HTML Reporter — so in the Command Palette they read as Pytest HTML Reporter: <title>. Three of them are also pinned to the view's title bar, in this navigation order: refresh, open HTML report, configure path.

Command idPalette titleWhat it does
pytestHtmlReporter.refresh Refresh Test Results Re-resolves the report path and reloads the sidebar. Title-bar icon $(refresh). Also reachable from the Refresh buttons in the no-config and empty states, and from Retry in the error state.
pytestHtmlReporter.openHtmlReport Open Full HTML Report Opens the generated HTML report in a reusable editor-tab webview. Warns when no HTML report sits next to output.json. Title-bar icon $(browser).
pytestHtmlReporter.configureReportPath Configure Report Path Opens a Quick Pick of detected reports plus Browse… and, when paths are already configured, Clear configured paths. Title-bar icon $(gear).
pytestHtmlReporter.jumpToTest Jump To Test hidden Takes a suite name and a test name, opens the suite file and moves the cursor to the matching def. Invoked by clicking a failure card; hidden from the palette because it needs arguments.
pytestHtmlReporter.focusSidebar Focus Test Results View hidden Focuses the Test Results view. Hidden from the palette; it exists so the status bar item has something to click.

The three visible commands are gated on there being at least one workspace folder open.

Configuration reference

Six settings, all under the pytestHtmlReporter section and all scoped to a resource, so they can be set per workspace folder. The Settings UI groups them under Pytest HTML Reporter. Changing any of them reloads the sidebar.

SettingTypeDefaultWhat it does
pytestHtmlReporter.reportJsonPaths array of string [] Paths to output.json files written by pytest-html-reporter. Empty means detect them automatically; non-empty makes this list the only source of reports.
pytestHtmlReporter.autoRefresh boolean true Reload the sidebar when the report file changes on disk. When false no watcher is installed at all; the Refresh command still works.
pytestHtmlReporter.showErrorSnippets boolean true Show a truncated error message on each failing test. False suppresses both the computation of the snippet during parsing and the block on the card.
pytestHtmlReporter.maxErrorLength number, 50–500 150 Maximum length of the error snippet on a failing test. Clamped both by the manifest and again at read time, so a hand-edited settings.json cannot push it out of range.
pytestHtmlReporter.history.enabled boolean true Read past builds from archive/*.json to flag flaky and consistently broken tests. False means no flake badges, no trend strips and no build-count annotations.
pytestHtmlReporter.history.maxBuilds number, 2–200 25 How many recent builds to read from archive/ when working out flakiness. The window keeps the newest N after sorting by inner start_time.
json .vscode/settings.json — every option, set explicitly
{
  "pytestHtmlReporter.reportJsonPaths": ["/Users/you/project/report/output.json"],
  "pytestHtmlReporter.autoRefresh": true,
  "pytestHtmlReporter.showErrorSnippets": true,
  "pytestHtmlReporter.maxErrorLength": 200,
  "pytestHtmlReporter.history.enabled": true,
  "pytestHtmlReporter.history.maxBuilds": 50
}

How it finds output.json

Per workspace folder, best candidate first:

  1. <workspace>/output.json — the plugin's default, because it writes relative to pytest's working directory
  2. <workspace>/report/output.json
  3. <workspace>/reports/output.json
  4. <workspace>/test-reports/output.json
  5. <workspace>/.reports/output.json
  6. <workspace>/test-results/output.json
  7. A bounded search for **/output.json, excluding node_modules, .venv, venv and site-packages, capped at 20 results

Duplicates are removed by absolute path and the order above is kept, so conventional locations always come before search hits.

Every candidate is shape-checked before it is offered. The file is read, parsed, and required to have a content.suites object. output.json is a common filename and a Python repo may hold several with nothing to do with pytest; rendering an unrelated one as test results would be worse than finding nothing. A file that fails the check, or fails to parse, is silently not offered.

Which report is active is the currently active one if it is still available, otherwise the first available one. When two or more are available the switcher dropdown appears at the top of the sidebar. When there are none at all, the sidebar shows the no-config state.

Screenshot: assets/img/shots/vscode-sidebar-no-config-dark.png The no-config state, dark theme, at sidebar width — .preview/preview-no-config-dark.html. It must show the clipboard icon, the "No report found" heading, the pytest --html-report=./report code chip, and both buttons: Configure report path and Refresh.

Overriding detection

pytestHtmlReporter.reportJsonPaths
array of string default: []

When this list is non-empty, only these paths are considered — detection is skipped entirely. Each entry is checked for existence, and any that no longer exist are pruned back into workspace settings, so a report deleted between runs stops haunting the switcher. If every configured path is dead, the extension falls back to auto-detection rather than showing nothing. Entries are used exactly as written: nothing expands ${workspaceFolder} and nothing resolves a relative path against the workspace folder, so write each one in full — Configure Report Path writes them that way itself.

json Two suites, two reports, one switcher
{
  "pytestHtmlReporter.reportJsonPaths": [
    "/Users/you/project/report/output.json",
    "/Users/you/project/e2e/report/output.json"
  ]
}

The Configure Report Path flow

Pytest HTML Reporter: Configure Report Path — also the gear icon in the view title bar, and the primary button on the no-report and error states — opens a Quick Pick titled pytest-html-reporter: report path with:

Whichever branch runs, the setting is written as a single-element array — or an empty one for Clear — targeting workspace settings when a folder is open and user settings otherwise, and the sidebar refreshes. Dismissing the pick changes nothing.

Finding the HTML report beside it

The HTML report is located by looking, never by predicting: pytest_html_report.html in the same directory if it exists, otherwise the single *.html file there if there is exactly one. Zero or two-plus candidates mean no HTML report and the footer button is not rendered.

The reason is that --html-report can rename the file, and a path holding strftime placeholders is expanded inside the pytest process against that process's clock — so recomputing the name here would disagree with disk whenever a run crosses a minute boundary.

Auto-refresh and file watching

The sidebar reloads when the view is first resolved, when the Refresh command runs, when the active report file changes on disk, when you switch reports in the dropdown, when workspace folders change, and when any pytestHtmlReporter setting changes.

The watch. Once a report path resolves, a watcher is installed on that one file, replacing any previous one. It is skipped entirely when autoRefresh is false. Each change event resets a 300 ms debounce; when the timer fires, the sidebar reloads. Watcher errors stop the watch silently — watching is a convenience, and the manual refresh still works.

The settle poll. Before every read, the extension waits for the file to stop growing: it stats the file every 120 ms, up to 40 times, and reads as soon as two consecutive stats report the same non-zero size. The plugin writes output.json in one pass at the very end of a run and a real suite's file runs to megabytes, so a watcher that read on the first change event would routinely catch a half-written file and report a parse error for a run that was perfectly fine.

Re-entrancy. A load already in flight makes the next one return immediately, so a burst of change events cannot stack up parses of the same file.

ConstantValueWhat it controls
WATCH_DEBOUNCE_MS300Delay after the last file event before reloading.
SETTLE_POLL_MS120Interval between size checks while waiting for the file to stop growing.
SETTLE_MAX_POLLS40Maximum size checks — about 4.8 s — before reading anyway.

What the extension reads, and what it recounts

Tolerance is a stated design goal. The plugin has been through releases that added fields (duration in 0.3.7, coverage in 0.3.6), so an archive directory holds records of mixed vintage, and a run interrupted partway leaves a file that is valid JSON but internally inconsistent. A file that cannot be made sense of is skipped rather than allowed to take the sidebar down.

Two coercions worth knowing: suite and test maps are JSON objects keyed by stringified integers, so keys are sorted numerically to restore the run's real collection order; and an unrecognised status reads as PASS, on the grounds that inventing a failure the run never reported is the worse error.

Report text is never executed

Every string that came out of a report — suite name, test name, error text, file path — is HTML-escaped before it reaches the markup, in both quote forms, so it is equally safe inside an attribute. No handler is ever written with report data interpolated into it: rows carry a numeric index and listeners are attached by delegation, so nothing from the report is ever parsed as code. The webview's content policy is default-src 'none' plus inline style and script, which are generated in the same string and shipped with it, so no report content can pull in a remote resource.

Rendering the sidebar without VS Code

The extension repo ships a preview harness that renders the exact markup the webview gets into standalone HTML under .preview/ — one dark file and one light file per scenario. It is how the screenshots on this page are produced, and how a style change is checked without launching an Extension Development Host.

shell All four scenarios, dark and light
$ npm run preview                # results    -> .preview/preview-results-{dark,light}.html
$ npm run preview -- all-passed  # all-passed -> .preview/preview-all-passed-{dark,light}.html
$ npm run preview -- no-config   # no-config  -> .preview/preview-no-config-{dark,light}.html
$ npm run preview -- error       # error      -> .preview/preview-error-{dark,light}.html

The real Dark+ and Light+ theme variables are stubbed in, so what the browser shows is what the webview renders. Point the harness at one of your own runs to preview that run's real archive/ too — which is how you get captures that include flake badges, trend strips and the failures-per-build chart.

shell Preview a real run instead of the bundled fixture
$ PREVIEW_REPORT=/path/to/report/output.json npm run preview

Troubleshooting

Screenshot: assets/img/shots/vscode-sidebar-error-dark.png The error state, dark theme, at sidebar width — .preview/preview-error-dark.html. It must show the red alert icon, the "Unable to load report" heading, the message "Invalid report format: Unexpected token", and both buttons: Retry and Configure report path.
The sidebar is empty — it says "No report found"

That state means no configured path exists and detection found nothing, so start by confirming a report exists at all: is there an output.json anywhere under the folder you opened? If not, run pytest --html-report=./report — see Getting started.

If the file does exist, there are three usual reasons detection missed it. It lives outside the six conventional locations and outside the bounded search, which skips node_modules, .venv, venv and site-packages and stops at 20 hits. Or your report directory sits outside the workspace folder entirely — pytest was run from elsewhere. Or the file is not a pytest-html-reporter report: every candidate is required to have a content.suites object, and anything else is silently not offered.

In all three cases the fix is the same: run Pytest HTML Reporter: Configure Report Path and browse to the file.

The sidebar shows "No tests in this report"

The file parsed, but the recounted total is zero — the run collected nothing. That is a pytest collection problem rather than an extension one: check the paths and markers you passed to pytest, and that the run you are looking at is the one you think it is (the summary panel prints the run's date, and the switcher shows which report is active).

"Unable to load report" with a parse error

Reading or parsing the resolved output.json threw, and the message under the heading is the exception. If it appeared during a run, press Retry: the plugin writes the file in one pass at the end of a run, and although the extension waits for the size to stabilise before reading, a file still being written is the most common cause.

If it persists, the file itself is truncated or corrupt — an interrupted run, or a partial copy. Re-run pytest to write a fresh one.

The sidebar does not update when a run finishes

Check pytestHtmlReporter.autoRefresh — when it is false, no watcher is installed at all. Check next that the run wrote to the report the sidebar has active: the watch follows one file, so a second suite writing to a different directory will not trigger it; pick it in the dropdown or with Configure Report Path. Some network and container filesystems do not deliver change events either. In every case Pytest HTML Reporter: Refresh Test Results, or the refresh icon in the view title bar, reloads on demand.

Clicking a failure opens the wrong function, or a pick list

Expected in one case: when a single file defines the same test name twice — two classes with a method of the same name — the record has nothing to tell them apart, so you get a pick list rather than a silent guess at the wrong one. The scan is also a line scan, not a Python parse, so a def written inside a triple-quoted string can match.

"Could not find … it may have been renamed since this run" means the report is older than the code. Re-run pytest.

No flaky or broken badges anywhere

Badges need history, and history needs an archive/ directory beside output.json with past runs in it — a first run has a history of exactly itself. A test also has to appear in at least 2 builds before it gets a verdict, and a stable test never gets a badge by design. Finally, check pytestHtmlReporter.history.enabled is not false, and that history.maxBuilds is wide enough to include the runs you care about.

"No HTML report found next to output.json"

The extension looks for pytest_html_report.html in the report directory, and failing that accepts a single *.html file there. Two or more HTML files in the same directory are ambiguous, so it offers none. Either clear the extras out, or point reportJsonPaths at the output.json that sits beside the report you want.

The pasted error has strange bracket sequences in it

That is working as intended. The card shows the message with ANSI escape codes stripped for reading; copy error deliberately copies the original, colouring intact, so pasting it into a terminal reproduces pytest's own red-and-green assertion diff. Paste it into a terminal rather than a plain-text field.

Where next