Dashboard Test Suites Test Metrics 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)%
Suite Highlights

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

MOST FAILED SUITE

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) Error Message Logs Data
%(archive_status)%
%(archive_body_content)%
Screenshots
Images attached while the tests ran, beside the suite and the error they belong to
%(attach_screenshot_details)%
No screenshots in this run
Hand attach the PNG bytes of a screenshot and it is kept against the test that took it, with the suite and the error it belongs to beside it.
1Attach an image
attach takes the picture, not the browser, so every framework goes through the same call - Selenium, Playwright, or anything else that can produce a PNG.
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
2Better: one hook, both drivers, failures onlyrecommended
The browser is already in item.funcargs, so a single hook covers Selenium and Playwright at once - no fixture of its own, and nothing to remember in each test. Running both? Add the fixture name to the table and that framework is covered too.
# conftest.py
import pytest
from pytest_html_reporter import attach

CAPTURE = {
    "driver": lambda driver: driver.get_screenshot_as_png(),  # Selenium
    "page":   lambda page: page.screenshot(),                 # Playwright
}

@pytest.hookimpl(hookwrapper=True)
def pytest_runtest_makereport(item, call):
    outcome = yield
    report = outcome.get_result()

    if report.when != "call" or not report.failed:
        return

    for name, capture in CAPTURE.items():
        handle = item.funcargs.get(name)
        if handle is not None:
            attach(data=capture(handle))
            return
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. Drop the report.failed check to photograph every test.
3Async tests, and unittest
The hook above is synchronous, so an async Playwright test has nowhere to await - attach from the body instead. A unittest suite attaches from tearDown, which still runs while the driver is open.
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 you attach is kept, whatever the test did - a screenshot of a pass is a baseline worth having. Only the tests that called attach show up here, so capturing failures alone is a matter of when you call it.
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.