Skip to content

Generating HTML Test Reports

Useful for:

  • sharing results with teams
  • CI artifacts
  • historical tracking
  • pytest-html
bash
pytest --html=report.html --self-contained-html
  • store reports as CI artifacts
  • include environment info and versions
commands
pytest --html=report.html --self-contained-html       # which TESTS ran
pytest --cov=shop --cov-report=html                    # which CODE ran
diagram Diagram mermaid

Measured artefacts from one run:

filesizecontents
report.html37,274 bytesthe test run, self-contained
htmlcov/index.html4,686 bytesper-file coverage, plus assets
coverage.xml1,279 bytesline-rate="0.8333" for CI tools
.coverage53,248 bytesthe 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”
for CI
pytest --junitxml=results.xml        # test results, understood by most CI systems
pytest --cov --cov-report=xml        # coverage.xml for Codecov, SonarQube and friends

Measured 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.

.github/workflows/tests.yml
- 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.xml

if: 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.

.gitignore
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.

sketch Which report answers which question p5.js
pytest-html records the test run. coverage html records which lines the run reached. They are complementary, not alternatives.
pch.quizTag pch.quizDefaultTitle
  1. What is the difference between report.html from pytest-html and htmlcov/index.html from coverage?

    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.

  2. Why pass --self-contained-html?

    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.

  3. Why does the artifact-upload step in CI need if: always()?

    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.coffeeCta

pch.feedbackHeading

pch.feedbackSubheading