Generating HTML Test Reports
Why HTML reports
Section titled “Why HTML reports”Useful for:
- sharing results with teams
- CI artifacts
- historical tracking
Common plugin
Section titled “Common plugin”pytest-html
Example usage
Section titled “Example usage”pytest --html=report.html --self-contained-html- store reports as CI artifacts
- include environment info and versions
Two different reports, often confused
Section titled “Two different reports, often confused”pytest --html=report.html --self-contained-html # which TESTS ran
pytest --cov=shop --cov-report=html # which CODE ranflowchart TD P["pytest-html"] --> A["report.html
every test, outcome, duration, captured output"] C["coverage html"] --> B["htmlcov/index.html
per-file source with covered lines marked"] A --> Q1["answers: what failed, and what did it print?"] B --> Q2["answers: which lines did the tests never reach?"]
Measured artefacts from one run:
| file | size | contents |
|---|---|---|
report.html | 37,274 bytes | the test run, self-contained |
htmlcov/index.html | 4,686 bytes | per-file coverage, plus assets |
coverage.xml | 1,279 bytes | line-rate="0.8333" for CI tools |
.coverage | 53,248 bytes | the raw SQLite data file |
--self-contained-html inlines the CSS and JavaScript so the file survives being emailed
or stored as a CI artefact. Without it the report needs its assets/ directory alongside,
and it arrives unstyled.
Machine-readable output matters more than the HTML
Section titled “Machine-readable output matters more than the HTML”pytest --junitxml=results.xml # test results, understood by most CI systems
pytest --cov --cov-report=xml # coverage.xml for Codecov, SonarQube and friendsMeasured coverage.xml carried line-rate="0.8333", matching the 83% in the terminal.
CI systems parse these to annotate a pull request with which tests failed and which lines
a change left uncovered — which is where the information is actually useful, rather than
in a file someone has to open.
Publishing them
Section titled “Publishing them”- name: Run tests
run: pytest --cov=myapp --cov-report=xml --junitxml=results.xml --html=report.html --self-contained-html
- name: Upload reports
if: always() # otherwise a failing test suite uploads nothing
uses: actions/upload-artifact@v4
with:
name: test-reports
path: |
report.html
results.xml
coverage.xmlif: always() is the line worth copying. Without it the upload step is skipped when tests
fail — which is precisely the run whose report you wanted.
Keep them out of version control
Section titled “Keep them out of version control”report.html
htmlcov/
coverage.xml
.coverage
.pytest_cache/These are build outputs. Committing them produces enormous diffs on every run and they are stale the moment anything changes.
See it move
Section titled “See it move”Check yourself
Section titled “Check yourself”-
What is the difference between report.html from pytest-html and htmlcov/index.html from coverage?
They answer different questions and are complementary. Measured 37,274 bytes for the test report and 4,686 for the coverage index.
pch.quizShowAnswer
B — one records which tests ran and what they printed; the other records which lines of source the run reached — They answer different questions and are complementary. Measured 37,274 bytes for the test report and 4,686 for the coverage index.
-
Why pass --self-contained-html?
Without it the report needs its assets directory alongside and arrives unstyled.
pch.quizShowAnswer
B — it inlines the CSS and JavaScript so the single file survives being emailed or stored as a CI artefact — Without it the report needs its assets directory alongside and arrives unstyled.
-
Why does the artifact-upload step in CI need if: always()?
Steps are skipped by default once a previous step fails, so the report from the interesting run is the one you would lose.
pch.quizShowAnswer
B — without it the step is skipped when tests fail — exactly the run whose report you wanted — Steps are skipped by default once a previous step fails, so the report from the interesting run is the one you would lose.
pch.coffeeTagline
pch.coffeeCtapch.feedbackHeading
pch.feedbackSubheading