QA How-To
pytest HTML Report Tutorial with pytest-html
Follow this pytest HTML report tutorial to install pytest-html, generate a standalone report, add failure details, verify results, and save the artifact in CI.
23 min read | 3,237 words
TL;DR
Install pytest-html, then run `python -m pytest --html=reports/report.html --self-contained-html` to generate a portable result page. Use pytest's exit code to gate CI and save the HTML as a diagnostic artifact, including on failed runs.
Key Takeaways
- Install pytest-html in the same environment that runs pytest.
- Use --html to write a browsable report without changing test outcomes.
- Add --self-contained-html when the artifact needs to travel as one file.
- Attach concise failure identifiers through report.extras, not the deprecated report.extra.
- Keep the pytest exit code as the CI gate and upload the report with an always condition.
- Check downloaded artifacts for missing external images and sensitive captured data.
A pytest HTML report tutorial should leave you with a report you can open, interpret, and share after a real test run. Install pytest-html, run python -m pytest --html=reports/report.html, and inspect the result table and captured failure details. The steps below add a self-contained file, useful customization, and a CI artifact without changing your test assertions.
You will build a tiny receipt-calculation suite so every example has a known outcome. A deliberate failure is enabled only for the diagnostic run; ordinary runs stay green. If you need a wider introduction to discovery, fixtures, and assertions first, read the pytest tutorial for beginners. This guide focuses on the report itself, including what it can and cannot tell you about test quality.
What You Will Build
- A five-test Python suite with passing, skipped, and expected-failure outcomes.
- A normal HTML report and a single-file report for sharing.
- A failure attachment, descriptive title, and small CSS override.
- A repeatable CI run that uploads the report even when a test fails.
- Verification commands for the generated file and its important content.
The final directory has receipt.py, tests/test_receipt.py, conftest.py, report.css, requirements.txt, and generated files under reports/. The report is evidence from one run, not a replacement for the pytest exit code. Keep the terminal result and the artifact together when you triage a failure.
Prerequisites
Use Python 3.11 or another supported Python 3.10+ interpreter, pytest 9.1.1, and pytest-html 4.2.0 for the exact commands in this tutorial. PyPI lists those package releases and their Python requirements: pytest and pytest-html. The workflow below sets up Python 3.11; if your organization pins a different supported interpreter, match that version in local development and CI. These are verified example pins, not claims that a newer release cannot work.
You need a terminal, Python with venv and pip, and a browser to open local HTML. On macOS and Linux, activate the virtual environment with source .venv/bin/activate; on Windows PowerShell, use .venv\Scripts\Activate.ps1. Commands after activation use python -m ... so the selected interpreter and installed packages stay aligned. No web server, account, database, or networked test target is required after installation.
| Output | Best use | Trade-off |
|---|---|---|
Default --html report |
Local run with adjacent asset files | Copy the asset directory with the HTML file. |
--self-contained-html report |
A single artifact for CI download or review | File or URL images added as extras may still need external resources. |
| Terminal pytest result | Automation gate and fastest feedback | Less convenient for browsing individual test details later. |
The commands follow the pytest-html user guide, including its warning about externally linked images in self-contained reports. Read the report as a navigation aid, then use the original failure and surrounding logs to establish cause.
Step 1: Create the project and install the reporting plugin
Start in an empty directory. Create a virtual environment and pin the two top-level packages so a teammate can reproduce the CLI behavior. pytest-html brings its declared dependencies through pip; do not hand-install a second HTML reporter just to make --html appear.
mkdir pytest-html-demo
cd pytest-html-demo
python3.11 -m venv .venv
source .venv/bin/activate
python -m pip install --upgrade pip
printf 'pytest==9.1.1\npytest-html==4.2.0\n' > requirements.txt
python -m pip install -r requirements.txt
python -m pytest --version
python -m pip show pytest-html
If python3.11 is unavailable, replace only that executable with the supported Python you installed. Keep the package pins until you intentionally test an upgrade. A virtual environment prevents a global pytest installation from masking a missing plugin in this project. The package is named pytest-html in pip and exposes the --html pytest option; you do not call a separate report generator.
Verify: python -m pytest --help | grep -- '--html=' should show the HTML option on macOS or Linux. On Windows, run python -m pytest --help and search its output for --html=PATH. If it is absent, inspect python -m pip show pytest-html inside the activated environment before editing tests. A successful install alone does not prove that the pytest process uses that environment.
Step 2: Add tests with distinct outcomes
Create a small production function and tests that exercise normal arithmetic, validation, a known pending behavior, and a controlled diagnostic failure. The example stays deterministic: no clock, API, browser, or shared service can change its result. That makes the report categories easier to learn before applying the plugin to a larger suite.
# receipt.py
from decimal import Decimal
def receipt_total(price: str, quantity: int) -> Decimal:
if quantity < 0:
raise ValueError("quantity must be nonnegative")
return Decimal(price) * quantity
# tests/test_receipt.py
import os
from decimal import Decimal
import pytest
from receipt import receipt_total
def test_two_items_total() -> None:
assert receipt_total("12.50", 2) == Decimal("25.00")
def test_negative_quantity_is_rejected() -> None:
with pytest.raises(ValueError, match="nonnegative"):
receipt_total("12.50", -1)
@pytest.mark.skip(reason="Refund contract is not available in this demo")
def test_refund_receipt() -> None:
assert False
@pytest.mark.xfail(reason="Rounding policy has not been implemented", strict=True)
def test_fractional_cent_rounding() -> None:
assert receipt_total("0.005", 1) == Decimal("0.01")
@pytest.mark.skipif(
os.getenv("REPORT_DEMO_FAIL") != "1",
reason="Enable only while inspecting a failure report",
)
def test_diagnostic_failure() -> None:
assert receipt_total("12.50", 2) == Decimal("24.00")
Create tests/ before saving the test file. The expected failure documents unfinished rounding behavior; it is not a pass. strict=True makes an unexpected pass fail the suite, so a future implementation cannot silently leave an obsolete expectation behind. The guarded assertion deliberately expects a wrong total and runs only when REPORT_DEMO_FAIL=1 is set.
Verify: Run mkdir -p tests reports and then python -m pytest -q. Expect 2 passed, 2 skipped, 1 xfailed and exit code zero. The skipped count includes the diagnostic test because its environment switch is off. If import discovery fails, run from the directory containing receipt.py; do not copy the module into tests/ as a workaround.
Step 3: Pytest HTML Report Tutorial Command and Output
Run the suite with an explicit output path. The plugin writes a report even when test execution later returns a nonzero code. The path is relative to the current directory, so create reports/ before running or verify that your chosen destination is writable.
python -m pytest tests -q --html=reports/report.html
python -c "from pathlib import Path; p=Path('reports/report.html'); assert p.is_file() and p.stat().st_size > 0; print(p.resolve())"
Open reports/report.html in your browser. The summary should account for two passes, two skips, and one expected failure. Expand an individual result and inspect the captured information. A skip explains why a test did not execute; an xfail tells you the expected assertion still fails. Neither is proof that the associated feature works. The HTML is generated from pytest's collected result reports and does not change collection, fixtures, or assertion semantics.
Verify: The second command prints an absolute path only if the file exists and is nonempty. Compare the table counts with the terminal summary. If the browser opens an old copy, inspect the file modification time or generate a uniquely named report for the new run. The report's filename, not the page title, identifies this first artifact.
A default report may rely on neighboring asset files for styling and behavior because of the plugin's content security policy design. If you plan to upload a single file, make the next run self-contained. Keep both forms briefly so you can see the portability difference instead of assuming every HTML file can travel alone.
Step 4: Make a portable single-file report
Pass --self-contained-html with the same --html option. This bundles the report's own assets into one HTML document. Give it a separate name so the two outputs are easy to compare. This option is especially useful when a CI system stores one file per artifact or a reviewer downloads the report without its original directory.
python -m pytest tests -q --html=reports/standalone.html --self-contained-html
python -c "from pathlib import Path; p=Path('reports/standalone.html'); assert p.is_file(); print(f'{p.name}: {p.stat().st_size} bytes')"
Open standalone.html directly from another folder or attach that one file to a review. The status filters and expandable details should still function. Do not infer that every future attachment is embedded: the pytest-html guide warns that images passed as file paths or URLs remain external in a self-contained report. If you add browser screenshots later, use an embedded image payload or publish the image beside the HTML and test the downloaded artifact.
Verify: Move a copy of standalone.html to a temporary folder and open it there. Check that the result table still renders and that you can expand a test. If a screenshot is missing while the table works, inspect how that screenshot was supplied to pytest_html.extras.image(); this is an attachment portability issue, not a failed pytest run.
The ordinary report is often enough inside a build workspace; the standalone variant is simpler for handoff. In both cases, avoid posting raw reports publicly when captured output may contain tokens, customer data, internal URLs, or stack traces. Artifact access should match the sensitivity of the tests that generated it.
Step 5: Add a title and failure-specific evidence
Put plugin hooks in conftest.py at the project root. The title hook changes the browser-visible report heading. The pytest hook runs after each test phase and adds a short text extra only for a failed call phase. Use report.extras, the current attribute documented by pytest-html, rather than the deprecated singular report.extra.
# conftest.py
import pytest
import pytest_html
def pytest_html_report_title(report) -> None:
report.title = "Receipt validation run"
@pytest.hookimpl(hookwrapper=True)
def pytest_runtest_makereport(item, call):
outcome = yield
report = outcome.get_result()
if report.when == "call" and report.failed:
extras = getattr(report, "extras", [])
extras.append(
pytest_html.extras.text(
f"Test node: {item.nodeid}", name="Triage identifier"
)
)
report.extras = extras
The node ID is useful when a reviewer must rerun one case: it includes the test module and function name. It contains no application payload in this demo. For real suites, prefer a stable ticket or sanitized request identifier to dumping the entire environment. The usual captured traceback already describes the assertion; attaching the same traceback again would add noise. The hook does not alter whether a test passes or fails.
REPORT_DEMO_FAIL=1 python -m pytest tests -q --html=reports/failure.html --self-contained-html
python -c "from pathlib import Path; s=Path('reports/failure.html').read_text(encoding='utf-8'); assert 'Receipt validation run' in s and 'Triage identifier' in s; print('title and extra found')"
The first command is expected to exit with code 1 because the diagnostic assertion is intentionally wrong. Run the second command after that expected result. The report should show two passes, one failure, one skip, and one xfail. If your shell stops on a nonzero exit, temporarily disable that behavior for this deliberate demonstration or run the verification as a separate terminal command.
Verify: Expand test_diagnostic_failure and look for Triage identifier with its node ID. Confirm the failure still contains the Decimal('25.00') versus Decimal('24.00') assertion evidence. Then run python -m pytest -q without the environment variable and confirm the suite returns to green. See how to reduce flaky tests in a CI pipeline for the distinction between diagnostic evidence and a rerun policy.
Step 6: Apply restrained report styling
The --css option appends custom CSS to the report. Use it for a small visual cue, not to hide failed results or replace the summary. Keep the styling independent of private HTML structure where possible; plugin upgrades may adjust classes or layout. A title is more durable than a large stylesheet that assumes particular table cells.
/* report.css */
body {
font-family: system-ui, sans-serif;
}
h1 {
color: #173a5e;
}
python -m pytest tests -q --html=reports/styled.html --self-contained-html --css=report.css
python -c "from pathlib import Path; s=Path('reports/styled.html').read_text(encoding='utf-8'); assert '#173a5e' in s; print('custom CSS included')"
Verify: Open reports/styled.html and confirm the heading color changed while status counts and result details remain visible. The command-line option is --css=report.css; it does not require a CSS import in your Python code. If nothing changes, inspect the generated file with the second command and refresh the browser without cache. When several stylesheets are passed, pytest-html applies them in command-line order according to its guide.
Visual polish should not obscure a failure. Avoid reducing contrast, removing focus indicators, or styling expected failures as passes. Keep the report navigable for someone using only a keyboard. If the suite serves multiple teams, a stable report title and a clear test name usually improve handoff more than extensive branding.
Step 7: Pytest HTML Report Tutorial in GitHub Actions
A CI job must preserve the pytest exit status for gating and still upload the HTML when tests fail. The example below uses GitHub Actions with pinned action majors and the same Python and package versions as the local setup. Store it as .github/workflows/pytest-report.yml in the demo repository. The workflow only runs for pushes and pull requests; adjust triggers for your team's branch policy.
name: Pytest HTML report
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v7
with:
python-version: '3.11'
- name: Install dependencies
run: python -m pip install -r requirements.txt
- name: Run tests and write report
run: |
mkdir -p reports
python -m pytest tests -q --html=reports/ci.html --self-contained-html
- name: Upload report
if: always()
uses: actions/upload-artifact@v4
with:
name: pytest-html-report
path: reports/ci.html
if-no-files-found: error
The if: always() condition is on the upload step, not the test step. If pytest fails, the job still fails, while the report remains available for diagnosis. A missing file also becomes visible because if-no-files-found: error rejects an empty artifact. This is useful when a test process dies before the plugin can finish writing a report. For larger suites, consider naming artifacts with a matrix axis or run identifier so parallel jobs do not produce ambiguous downloads.
Verify: Before pushing, run python -m pytest tests -q --html=reports/ci.html --self-contained-html locally and check that reports/ci.html opens. After a GitHub run, inspect the job summary and download pytest-html-report from the workflow artifacts. Its counts should match the terminal log. When you need broader pipeline patterns, use the test automation CI/CD guide and GitHub Actions for Playwright as companion guides.
Do not use continue-on-error on the pytest step merely to obtain the report; that can mark a broken test run as acceptable. The upload condition preserves the diagnostic file while leaving the failure signal intact. Apply your organization's artifact retention and access rules, especially when the report captures URLs or application output.
Step 8: Make report generation repeatable for teammates
Once the explicit command works, put the stable defaults in pytest.ini so a plain python -m pytest emits the same artifact. Keep the destination directory predictable and create it in local scripts or CI before the run. Command-line arguments can still override a default for a one-off report. Avoid hard-coding a timestamp into addopts; an unstable path makes artifact upload and cleanup harder.
# pytest.ini
[pytest]
addopts = --html=reports/report.html --self-contained-html
mkdir -p reports
python -m pytest -q
python -c "from pathlib import Path; assert Path('reports/report.html').is_file(); print('default report exists')"
Verify: Confirm the file's modification time changed after the run, and open it to see the custom title from conftest.py. Run python -m pytest -q --html=reports/one-off.html when you need a separate named artifact; inspect which files were written before adopting this pattern in a shared repository. If a CI matrix runs concurrent jobs in one workspace, give each job a distinct report path or separate working directory.
Treat generated HTML as build output. Exclude reports/ from version control in your own project configuration, but retain reports through your CI artifact system for the period your team needs to investigate failures. A report is a snapshot tied to source revision, interpreter, dependencies, and test data. Record that run context outside the HTML when it is needed for reproducibility. A useful next extension is a consistent naming convention across branches, environments, or shards.
Troubleshooting
- Problem: pytest says
unrecognized arguments: --html-> The process did not load pytest-html. Activate the virtual environment, runpython -m pip show pytest-html, and invokepython -m pytestwith that same interpreter. Check for-p no:htmlor plugin autoload settings only if installation is confirmed. - Problem: the HTML file is missing -> Confirm the output path, working directory, and write permissions. Create
reports/explicitly, then run the command without shell redirection that might conceal pytest's own error. If the process was killed abruptly, it may not have completed the report. - Problem: a standalone report opens without an image -> File-path and URL image extras are not automatically embedded by
--self-contained-html. Supply image bytes through a supported extra or publish the referenced image alongside the report, then test the downloaded copy. - Problem: an expected failure appears as a failure -> Inspect whether
strict=Trueencountered an unexpected pass, or whether the test failed outside the expected assertion path. Run that node ID alone with-vvand read the terminal reason before changing marks. - Problem: custom title or extra does not appear -> Place
conftest.pyin the test root and check that pytest loads it. Generate a fresh report, search its HTML forReceipt validation run, and inspect the failed row; the extra is intentionally absent from passing tests. - Problem: CI reports success after a red test -> Remove
continue-on-errorfrom the pytest step and do not append|| trueto its command. Putif: always()only on artifact upload so the workflow keeps evidence and a nonzero test result.
These checks separate plugin loading, test execution, report writing, and artifact transport. Diagnose the first broken layer. A browser rendering issue does not necessarily mean tests failed, and a pretty report never changes pytest's exit status.
Interview Questions and Answers
The questions below connect reporting choices to test engineering. Use the concrete run above when explaining your answer, and keep the report's role separate from the pipeline gate.
Q: What does pytest-html add to pytest?
It converts one pytest run's outcomes and captured details into a browsable HTML artifact. The plugin adds CLI options and hooks, while pytest still collects tests, evaluates assertions, and determines the process exit code. I would use the report for triage and the exit code for automation.
Q: Why choose --self-contained-html in CI?
A single file is easier to download and share than an HTML file plus adjacent assets. I would verify the downloaded copy because separately linked images can still be missing. Portability does not justify publishing sensitive captured output to a public artifact store.
Q: How do you attach useful failure context?
I use pytest_runtest_makereport and add a small report.extras entry for failed call phases. A test node ID or sanitized correlation ID helps someone find the exact failing case. I avoid duplicating the traceback and exclude secrets or large payloads.
Q: How should CI behave when a test fails?
The pytest step should fail the job, while a later artifact step runs with an always condition. That gives reviewers a report without turning a failing assertion into a green check. I also make missing artifacts an error so report generation failures are visible.
Q: What is the difference between skipped and xfailed?
A skipped test was not executed under that condition, while an expected-failure test ran and produced an anticipated failure. I record reasons for both. With strict xfail, an unexpected pass is surfaced as a failure so the team can remove stale expectations.
Q: When would you use a different report format?
I keep pytest-html for human review of one run. If a CI service needs structured ingestion, I also generate a machine-readable format such as JUnit XML; if the team needs cross-run history and richer dashboards, I evaluate a dedicated reporting system. I choose based on the consumer rather than styling preference.
Best Practices
- Generate the report from the exact test command that determines CI success. A separate report-only rerun can show different results.
- Keep artifacts tied to a commit and environment, and give parallel jobs distinct filenames.
- Attach concise, sanitized identifiers that accelerate reproduction; avoid raw credentials and customer data.
- Review skip and xfail reasons regularly so the report does not normalize unfinished coverage.
- Open a downloaded report during pipeline setup to verify that assets and extras survived transport.
- Keep custom CSS small and verify readable contrast after plugin upgrades.
For investigation beyond the HTML page, use the flaky test debugging interview questions to rehearse how you would distinguish test code, data, and environment causes. A report helps you locate the symptom; a reproducible failing command and relevant logs help you explain it.
Where To Go Next
You now have a working pytest HTML report tutorial: install a verified plugin version, run a deterministic suite, generate a shareable file, add targeted evidence, and retain the artifact when CI fails. Start with one real repository and replace the receipt tests with a small representative slice. Compare the artifact against the terminal result before rolling the setting across every job.
For richer report ecosystems, compare test reporting with Allure and Allure reports in CI. For suite design, continue with how to structure a test automation repository. Keep the choice proportional: a readable single-run artifact often answers the immediate triage question, while trend analysis and multi-run history call for different storage and tooling.
Conclusion
pytest-html makes pytest results easier to inspect and pass along, especially when a failure needs context after the terminal session ends. The core command is python -m pytest --html=reports/report.html; add --self-contained-html when the report must travel as one file. Verify the generated file, retain the pytest exit code, and protect any captured data before sharing it.
Interview Questions and Answers
How would you introduce pytest-html to an existing suite?
I would pin the plugin with pytest in the project's test dependencies, run a small representative subset with `--html`, and compare the report counts to the terminal result. Then I would test a downloaded artifact and add upload-on-failure behavior in CI. The change should not modify assertions or hide a failing exit code.
What makes a pytest HTML report useful during triage?
It groups outcomes and lets a reviewer open a failed test's captured details without searching a long terminal log. A meaningful node ID and a sanitized correlation identifier can lead to the exact test and application trace. I would still inspect the original failure and environment context before assigning root cause.
Why can a self-contained report still have missing screenshots?
The option embeds pytest-html's own assets, but file-path and URL image extras can remain external. I would inspect how the image was supplied, then either embed its bytes through a supported extra or upload the referenced asset with the HTML. I would verify the downloaded copy, not only the workspace original.
How do you keep a failing test red while retaining its report?
Let the pytest command return its normal nonzero exit status. Put `if: always()` on the artifact upload step, with a missing-file error. I would avoid `continue-on-error` and `|| true` on the test step because those can suppress the gate.
When should you attach data through `report.extras`?
I attach a small item only when it speeds investigation, such as a test node ID or a nonsecret request identifier. I guard on `report.when == 'call'` and the failure outcome so setup, teardown, and passing rows do not receive irrelevant copies. I use the plural `extras` attribute supported by current pytest-html.
How would you explain skip versus xfail in a report?
Skip means the case was not executed under the given condition. Xfail means it ran and the known failure occurred. I ask for a reason on either mark and use strict xfail when an unexpected pass must trigger follow-up rather than silently look healthy.
What report would you choose for a CI system that parses test results?
I would provide a machine-readable output such as JUnit XML for the CI parser, and optionally pytest-html for humans reviewing a failed run. HTML is convenient to browse but is a poor ingestion contract. The two artifacts serve different consumers and should come from the same test execution.
Frequently Asked Questions
How do I generate an HTML report with pytest?
Install `pytest-html` in the active Python environment and run `python -m pytest --html=reports/report.html`. The plugin writes the report as pytest runs. Open the resulting file in a browser and compare its counts with the terminal summary.
Does pytest include HTML reporting by default?
No. The `--html` option comes from the pytest-html plugin. If pytest rejects the option, confirm that the Python interpreter running pytest is the one where the plugin was installed.
What does `--self-contained-html` do?
It embeds pytest-html's own report assets into one HTML file, which simplifies downloads and sharing. Images supplied as file paths or URLs may still refer to external resources. Test a downloaded copy before relying on it.
Can pytest-html show skipped and expected-failure tests?
Yes. The result table and summary distinguish skipped cases from expected failures. Include explicit reasons in your marks, and use strict xfail where an unexpected pass should fail the suite.
Why is my report missing after pytest fails?
A normal assertion failure can still produce a report, but an unwritable path, wrong working directory, or abruptly terminated process can prevent creation. Create the destination directory and check the exact command and pytest output. In CI, make artifact upload report a missing file as an error.
How do I add information to a failed test row?
Implement `pytest_runtest_makereport` in `conftest.py` and append a supported pytest-html extra to `report.extras` for the failed call phase. Keep the value small and sanitized. The `report.extra` spelling is deprecated.
Should I commit generated HTML reports?
Usually keep them as local build output or CI artifacts tied to a run instead of committing changing generated files. Retain the source revision, dependency versions, and test logs needed to reproduce the result. Restrict access if captured data is sensitive.
Related Guides
- API Testing with pytest and requests: Step-by-Step Tutorial
- k6 handleSummary HTML Report Tutorial
- Create Cypress Query Commands with TypeScript: Cypress Query Commands TypeScript Tutorial
- How to Rerun Failed Tests in pytest with pytest-rerunfailures
- JMeter JSON Extractor Tutorial with Examples
- Newman htmlextra Report Tutorial for Postman Collections