%(vendor_assets)% %(icon_styles)%
Dashboard Analytics Test Suites Test Metrics Test Steps Archives Screenshots API Logs Test Coverage %(report_links)%
%(coverage_chip)% Time taken %(execution_time)%
%(title)%%(environment)%
%(date)%
%(total)% TEST CASES
  Trends
Test Suite %(test_suite_length)%
Highlights

%(max_failure_suite_count)% /%(max_failure_total_tests)% Times

MOST FAILED SUITE

Analytics
How these tests behave over time — %(analytics_scope)%
%(analytics_tiles)%
Why this run failed %(analytics_fault_note)%
    %(analytics_faults)%
This is the only build on record, so there is nothing yet to compare it against. The duration panels below are this run's and are already real; the trends, the flake rates and the movement cards fill themselves in from the second run onwards, over as many builds as --archive-count keeps.
Pass rate across builds share of decided tests that passed
What moved, build to build fixed, regressed, added, dropped
Where the time goes tests per duration band, this run
Test base growth tests collected per build
Slowest tests in this run seconds
%(analytics_movement)%
%(analytics_rows)%
Test Verdict History Pass rate Builds Flips Retries Current streak Duration
Test Suites
Outcome breakdown for every test suite in this run
%(suite_metrics_row)%
Suite Pass Fail Skip xPass xFail Error Rerun
Test Metrics
Every test case with its status, duration and error
%(logs_notice)%
%(test_metrics_row)%
Suite Test Case Status Time (s) Rerun Error Message Logs Data Steps Screens
Test Steps
What each test did, step by step, and where its time went
%(step_tree)%
No test named a step in this run
The tree above still works — every test has a set up, a body and a tear down, and each is timed. Naming the pieces inside them is what turns “this test failed after 4 seconds” into “it failed charging the card, 3.2 seconds in”.
1Name a step
step is a with block or a decorator, and steps nest by being called from inside one another — nothing is passed between them.
from pytest_html_reporter import step

def test_checkout():
    with step("Add to cart", sku="A-12"):
        cart.add("A-12")

    with step("Charge the card"):
        assert gateway.charge(cart).ok
2Or decorate the helperrecommended
A page object’s methods are already the steps of the test. Decorating them once names every test that calls them, and the arguments of the call fill in the {placeholders} of the title.
@step("Log in as {user}")
def login(user, password):
    page.fill("#user", user)
    page.click("#submit")
3Already writing Gherkin?
Nothing to do. A pytest-bdd scenario is already a list of steps, so its Given / When / Then land here on their own — each timed, each carrying what it was called with.
Scenario: Add items to the cart
    Given a logged in user
    When I add 2 items to the cart
    Then the cart shows 2 items
Anything attached while a step is open is filed under that step — attach_json, attach_api and the rest need no extra argument to say which one they belong to.
%(archive_status)%
%(archive_body_content)%
Screenshots
The page each test was looking at, beside the suite and the error it belongs to
%(attach_screenshot_details)%
No screenshots in this run
A test that fails holding a Selenium driver or a Playwright page is photographed for you - no hook, no fixture, nothing to import. This run was holding neither, and attached nothing of its own.
1Nothing to writeautomatic
The browser is already in the test’s own fixtures and the reporter is already standing in its teardown, so the picture is taken there - the last moment before the driver is quit. What makes something a browser is that it can hand over a PNG, so Selenium, Playwright, appium, splinter and a wrapper of your own all work, whatever the fixture is called.
def test_checkout(page):                # or driver, or whatever you called it
    page.goto("/cart")
    assert page.locator("h1").inner_text() == "Cart"   # fails, and is photographed
Failures only, by default. --report-screenshots=all photographs every test, none turns the automatic capture off - and attach below keeps working either way.
2Or take the picture yourself
attach takes the image rather than the browser, so anything that can produce a PNG reaches the report - a page mid-test, a chart, a rendered PDF, an image diff. A test that attaches its own picture is not photographed again on the way out.
from pytest_html_reporter import attach

attach(data=driver.get_screenshot_as_png())   # Selenium
attach(data=page.screenshot())                # Playwright
attach(data=await page.screenshot())          # Playwright, async API
3Async tests, and unittest
The automatic capture is synchronous, so an async Playwright page has nowhere to await - attach from the body instead. A unittest suite that quits its driver in tearDown has already closed the browser by the time the capture would run, so it attaches from there, before the quit.
async def test_home(page):                # Playwright, async API
    try:
        assert await page.title() == "Example Domain"
    except AssertionError:
        attach(data=await page.screenshot())
        raise

def tearDown(self):                          # unittest
    attach(data=self.driver.get_screenshot_as_png())
    self.driver.quit()                        # after, never before
Every image is kept whatever the test did - a screenshot of a pass is a baseline worth having. Each one also lands on the Screens column of the Test Metrics row it belongs to, next to the error it explains.
API Logs
The request and response behind each test, with the curl line that repeats the call
%(attachment_items)%
No API logs in this run
Hand this tab the request and the response, and they are kept against the test that produced them - both bodies, both sets of headers, and the curl line that repeats the call.
1Attach a call
attach_api reads the response object, so requests and httpx both work as they are - nothing else to install.
from pytest_html_reporter import attach_api

def test_creates_an_order():
    response = requests.post(url, json=payload)
    attach_api(response)

    assert response.status_code == 201
2Better: only when the response failsrecommended
Attaching every call buries the one that matters and grows the report for no reason. The payload worth keeping is the one behind a failure, so attach from a fixture's teardown and let the outcome decide. The reporter builds a test's record after the finalizers have run, which is what makes this work.
# conftest.py
import pytest
from pytest_html_reporter import attach_api

@pytest.fixture
def api(request):
    client = ApiClient()
    yield client

    if request.node.rep_call.failed:
        attach_api(client.last_response)

# lets the fixture above see how the test ended
@pytest.hookimpl(tryfirst=True, hookwrapper=True)
def pytest_runtest_makereport(item, call):
    outcome = yield
    setattr(item, "rep_" + outcome.get_result().when, outcome.get_result())
Put the hook in conftest.py. pytest picks one up from a test module too, but only for that module's own tests - a conftest covers every test under it.
3Not only API calls
Anything you would otherwise dig out of a terminal can go here beside the call it belongs to.
attach_json({"expected": order, "got": body}, name="Diff")
attach_text(query, name="Query", format="sql")
attach_file("payloads/order.json")
Credentials are blanked out before anything is written - in headers, in a ?api_key= query string, in the curl line and in the fields of a JSON body. A report is a build artifact, and it gets published.
Test Coverage
How much of the code under test this run actually ran
%(coverage_display)%% covered
%(coverage_tiles)%
%(coverage_meta)% %(coverage_delta)% %(coverage_target)% Annotated source
Coverage across the last builds
%(coverage_rows)%
File Statements Missing Branches Coverage Missing lines
%(coverage_note)%
No test coverage in this run
Measure it and this tab fills itself in - the percentage, the split by file, and the lines nothing touched - beside the tests that did the measuring.
%(coverage_notice)%
1Run with coverage
Nothing else to configure. Whatever pytest-cov measured is read straight out of the finished run, so the number here is the number your terminal just printed.
pip install pytest-cov

pytest --cov=my_package --cov-branch
my_package is yours to fill in. --cov takes the import name or the path of the code under test - not the tests, and not a folder that is not there: point it at one and the run measures nothing at all.
2Or read a report you already havefor CI
When coverage was produced by an earlier step rather than by this run. A coverage.json, a Cobertura coverage.xml or a .coverage data file all work - and the first two are found without being named if they sit beside the report.
pytest --report-coverage-file=coverage.xml
3Keep the annotated source a click away
Line-by-line source is the one thing a summary cannot replace. Generate it and it is linked from the card above; --report-link does the same for any page of your own.
pytest --cov=my_package --cov-report=html

# anything else worth reaching from the side nav
pytest --report-link "Coverage=htmlcov/index.html"
Read, never re-run, and never framed in. Embedding htmlcov would empty this tab the moment the report was mailed on its own - so the figures are rendered here and the annotated source is linked.