Screenshots, Videos and Traces in Playwright Python (on Failure)

When a test fails on a CI server at 2 a.m., the error message alone rarely tells you why. Screenshots, videos and traces are your evidence: they show what the browser looked like and what happened step by step. In this lesson you will take every kind of screenshot from code, switch on automatic screenshots, videos and traces for failed tests with pytest-playwright, find the files it creates, attach them to an HTML report and open a trace in the Trace Viewer.

Advertisement

πŸ“– What You’ll Learn: page.screenshot for the viewport and the full page, element screenshots, clip, mask, style and JPEG options, screenshots as bytes, the –screenshot, –video, –tracing and –full-page-screenshot options with on, off and only-on-failure or retain-on-failure, the –output folder and its file names, setting the options in pytest.ini, artifacts in pytest-html reports, recording video and traces from code, trace groups, and reading a trace in the Trace Viewer.


πŸ“Έ Why Capture Evidence?

A failing test tells you that something is wrong. Evidence tells you what was on the screen:

ArtifactWhat it showsCost
ScreenshotOne picture of the page at a momentSmall, fast
VideoThe whole test as a movieLarger files, slight slowdown
TraceEvery action with DOM snapshots, network calls, console logs and source codeThe most detail, larger files

The usual setup: keep all three only for failed tests. Passing tests then cost almost nothing, and every failure comes with everything you need to debug it.


πŸ–ΌοΈ Screenshots from Code

All screenshot examples use the SQA Tools practice page:

πŸ“„ tests/conftest.py

import pytest
from playwright.sync_api import Page


@pytest.fixture
def practice(page: Page) -> Page:
    page.goto("https://sqatools.in/automation-practice-page/")
    return page

A small helper reads a PNG file’s width and height (they are stored at bytes 16 to 24 of every PNG), so each test can print what it captured:

πŸ“„ tests/images.py

import struct


def png_size(data: bytes) -> tuple[int, int]:
    """Return (width, height) of PNG image bytes."""
    return struct.unpack(">II", data[16:24])

πŸ“„ tests/test_screenshots.py

from pathlib import Path

from images import png_size
from playwright.sync_api import Page

OUT = Path("screenshots")


def test_page_screenshots(practice: Page):
    # 1. The visible part of the page (the viewport)
    viewport = practice.screenshot(path=OUT / "viewport.png")

    # 2. The whole scrollable page
    full = practice.screenshot(path=OUT / "full-page.png", full_page=True)

    # 3. A rectangle of the page, in CSS pixels from the top-left corner
    corner = practice.screenshot(path=OUT / "corner.png", clip={"x": 0, "y": 0, "width": 400, "height": 300})

    print("\nviewport :", png_size(viewport))
    print("full page:", png_size(full))
    print("clip     :", png_size(corner))
  • page.screenshot() captures the viewport by default. pytest-playwright’s default viewport is 1280 x 720.
  • full_page=True scrolls through the page and captures all of it. The height depends on the page content.
  • clip= captures one rectangle of the page.
  • path= saves the file; Playwright creates missing folders. The method also returns the image as bytes, whether you save it or not.

Element screenshots, masks and styles

πŸ“„ tests/test_screenshots.py

def test_element_and_masked_screenshots(practice: Page):
    # 4. One element only: the web table
    table = practice.locator("table").screenshot(path=OUT / "table.png")
    print("\ntable    :", png_size(table))

    # 5. Hide sensitive or changing parts with a solid box
    practice.locator("#username").fill("secret_user")
    practice.screenshot(
        path=OUT / "masked.png",
        mask=[practice.locator("#username"), practice.locator("#password")],
        mask_color="#FF00FF",            # default is pink #FF00FF
    )

    # 6. Inject CSS only for the screenshot, for example to hide a banner
    practice.screenshot(path=OUT / "styled.png", style="#username { visibility: hidden; }")

    # 7. JPEG with a quality setting: smaller files for long-term storage
    practice.screenshot(path=OUT / "viewport.jpg", type="jpeg", quality=60)

    for name in ["viewport.png", "viewport.jpg"]:
        print(f"{name:<12}: {(OUT / name).stat().st_size:,} bytes")
  • locator.screenshot() captures just that element, after scrolling it into view. It waits until the element is visible and stable.
  • mask= takes a list of locators and paints a box over each one. Use it for passwords, timestamps, ads or anything that changes from run to run.
  • style= injects a stylesheet only while the screenshot is taken; the page itself is not changed.
  • type="jpeg" with quality= (0 to 100) gives smaller files: here about half the size of the PNG (the exact JPEG size changes slightly between runs because the live page has ads). PNG is lossless and the default.
  • The file type follows the extension of path (.png, .jpg or .webp), or set it with type=.
  • Two more useful options: animations="disabled" stops CSS animations and transitions, and omit_background=True gives a transparent background (not for JPEG).

⌨️ Terminal

pytest -q -s tests/test_screenshots.py

πŸ–₯️ Output

viewport : (1280, 720)
full page: (1280, 8894)
clip     : (400, 300)
.
table    : (740, 172)
viewport.png: 61,548 bytes
viewport.jpg: 29,672 bytes
.
2 passed, 1 warning in 4.78s

πŸ’‘ Tip: You can attach page.screenshot() bytes to a report without writing a file. In the reporting module you will attach them to Allure reports.


πŸ€– Automatic Artifacts with pytest-playwright

You do not have to call screenshot() in every test. pytest-playwright has command-line options that capture evidence for you after each test:

OptionValuesDefault
--screenshoton, off, only-on-failureoff
--full-page-screenshotflag: makes those screenshots full pageoff
--videoon, off, retain-on-failureoff
--tracingon, off, retain-on-failureoff
--outputfolder for all artifactstest-results
  • only-on-failure and retain-on-failure record during every test but keep the files only when the test fails.
  • The screenshot is taken at the end of the test, when the browser context closes, so it shows the page as it was when the test failed.

Here are two tests, one passing and one failing:

πŸ“„ demos/test_checkout.py

from playwright.sync_api import Page, expect


def test_page_opens(page: Page):
    page.goto("https://sqatools.in/automation-practice-page/")
    expect(page).to_have_title("Automation Practice Page - SQA Tools")


def test_wrong_heading(page: Page):
    page.goto("https://sqatools.in/automation-practice-page/")
    expect(page.locator("h1").first).to_have_text("Checkout", timeout=2_000)

Run them with all three artifact types kept on failure:

⌨️ Terminal

pytest -q demos --screenshot only-on-failure --video retain-on-failure --tracing retain-on-failure

πŸ–₯️ Output

.F                                                                       [100%]
================================== FAILURES ===================================
________________________ test_wrong_heading[chromium] _________________________

page = <Page url='https://sqatools.in/automation-practice-page/'>

    def test_wrong_heading(page: Page):
        page.goto("https://sqatools.in/automation-practice-page/")
>       expect(page.locator("h1").first).to_have_text("Checkout", timeout=2_000)
E       AssertionError: Locator expected to have text 'Checkout'
E       Actual value: Automation Practice Page 
E       Call log:
E         - Expect "to_have_text" locator("h1").first with timeout 2000ms
E         - waiting for locator("h1").first
E           20 Γ— locator resolved to <h1 class="entry-title" itemprop="headline">Automation Practice Page</h1>
E              - unexpected value "Automation Practice Page"
E       
E       Aria snapshot:
E       - heading "Automation Practice Page" [level=1]

demos\test_checkout.py:11: AssertionError
=========================== short test summary info ===========================
FAILED demos/test_checkout.py::test_wrong_heading[chromium] - AssertionError:...
1 failed, 1 passed, 1 warning in 6.91s

After the run, the test-results folder contains:

πŸ–₯️ Output

test-results/demos-test-checkout-py-test-wrong-heading-chromium/test-failed-1.png
test-results/demos-test-checkout-py-test-wrong-heading-chromium/trace.zip
test-results/demos-test-checkout-py-test-wrong-heading-chromium/video.webm
  • There is one folder per test, named after the test’s node id (file, test name and browser). Only the failed test has a folder.
  • test-failed-1.png is the screenshot. With several pages in a test, you get test-failed-1.png, test-failed-2.png and so on; with --screenshot on, a passing test’s file is called test-finished-1.png.
  • video.webm is the video (WebM format, which every modern browser can play).
  • trace.zip is the trace, which you will open in a moment.
  • pytest-playwright empties the output folder at the start of each run, so old results do not mix with new ones. Copy anything you want to keep.

πŸ“ Note: With pytest-rerunfailures, a retried test’s artifacts go into a separate folder ending in -retry1, -retry2 and so on, so the first failure is not overwritten.

Setting the options once in pytest.ini

Typing the options every time is tedious. Put them in addopts:

πŸ“„ configured/pytest.ini

[pytest]
addopts =
    --screenshot only-on-failure
    --video retain-on-failure
    --tracing retain-on-failure

πŸ“„ configured/test_login.py

from playwright.sync_api import Page, expect


def test_login_button(page: Page):
    page.goto("https://sqatools.in/login-page/")
    expect(page.get_by_role("button", name="Log in")).to_have_text("Sign in", timeout=2_000)

⌨️ Terminal

cd configured
pytest -q

πŸ–₯️ Output

F                                                                        [100%]
================================== FAILURES ===================================
_________________________ test_login_button[chromium] _________________________

page = <Page url='https://sqatools.in/login-page/'>

    def test_login_button(page: Page):
        page.goto("https://sqatools.in/login-page/")
>       expect(page.get_by_role("button", name="Log in")).to_have_text("Sign in", timeout=2_000)
E       AssertionError: Locator expected to have text 'Sign in'
E       Actual value: 
E                           Log in
E                        
E       Call log:
E         - Expect "to_have_text" get_by_role("button", name="Log in") with timeout 2000ms
E         - waiting for get_by_role("button", name="Log in")
E           20 Γ— locator resolved to <button type="submit" id="loginbutton" class="btn-login">↡                    Log in↡                </button>
E              - unexpected value "
E                           Log in
E                       "
E       
E       Aria snapshot:
E       - button "Log in"

test_login.py:6: AssertionError
=========================== short test summary info ===========================
FAILED test_login.py::test_login_button[chromium] - AssertionError: Locator e...
1 failed, 1 warning in 5.28s

The button on that page says “Log in”, not “Sign in”, so the test fails and the artifacts appear without any option on the command line.

πŸ’‘ Tip: A common pattern is retain-on-failure locally and in CI, and on only when you are investigating a specific problem. Videos and traces of every test can fill a CI disk quickly.


πŸ“Š Artifacts in a pytest-html Report

If pytest-html is installed and you pass --html, pytest-playwright 0.10 adds the kept artifacts to the report automatically: the screenshot is shown inline, the video can be played, and the trace is a link.

⌨️ Terminal

pip install pytest-html
pytest -q demos --screenshot only-on-failure --output report-results --html report/index.html

A small script confirms the screenshot is linked in the report:

πŸ–₯️ Output

report size: 28,902 characters
mentions 'Screenshot': True
links: ['report-results/demos-test-checkout-py-test-wrong-heading-chromium/test-failed-1.png']
  • The report links to the files in the output folder. Keep the report and the output folder together when you publish them as CI artifacts.
  • Add --self-contained-html to embed screenshots and videos inside the HTML file itself (traces are always linked).
  • pytest-playwright also records each artifact’s path on the test report (as playwright_screenshot, playwright_video and playwright_trace properties), so JUnit XML reports list them too.

🎬 Recording Video from Code

Outside pytest-playwright, or when you need different settings for one test, create a context with record_video_dir:

πŸ“„ tests/test_video_manual.py

from pathlib import Path

from playwright.sync_api import Browser


def test_record_video(browser: Browser):
    context = browser.new_context(
        record_video_dir="videos/",
        record_video_size={"width": 1280, "height": 720},
    )
    page = context.new_page()
    page.goto("https://sqatools.in/automation-practice-page/")
    page.locator("#username").fill("video_user")

    context.close()                         # the video file is finished when the context closes

    video = Path(page.video.path())
    print(f"\nvideo: {video.parent.name}/<random name>{video.suffix}, {video.stat().st_size > 0 = }")
  • record_video_dir switches recording on for every page in the context.
  • record_video_size sets the video resolution. Without it, the video is the viewport scaled down to fit 800 x 800 (1280 x 720 becomes 800 x 450).
  • The video is **complete only after context.close()**. Read page.video.path() after closing, or use page.video.save_as("my-name.webm") to choose a file name.
  • The browser fixture comes from pytest-playwright; a context you create yourself is yours to close.

🧡 Traces from Code and Trace Groups

πŸ“„ tests/test_trace_manual.py

import zipfile
from pathlib import Path

from playwright.sync_api import Browser


def test_record_trace(browser: Browser):
    context = browser.new_context()
    context.tracing.start(screenshots=True, snapshots=True, sources=True)
    page = context.new_page()

    with context.tracing.group("Open the practice page"):
        page.goto("https://sqatools.in/automation-practice-page/")

    with context.tracing.group("Fill the form"):
        page.locator("#username").fill("trace_user")
        page.locator("#male").check()

    context.tracing.stop(path="traces/practice-trace.zip")
    context.close()

    names = zipfile.ZipFile("traces/practice-trace.zip").namelist()
    print("\ntrace files:", sorted(n for n in names if "/" not in n))
    for folder in ["resources/", "screencast/", "src/"]:
        print(f"{folder:<12}:", sum(n.startswith(folder) for n in names), "files")
  • tracing.start() options: screenshots (the filmstrip timeline), snapshots (DOM snapshots and network activity for every action) and sources (your source code, shown next to each action).
  • tracing.stop(path=...) writes the zip. Call it before context.close().
  • tracing.group("name") collects the actions inside the with block under one heading in the Trace Viewer, so a long test reads like a list of steps.
  • trace.trace holds the actions and snapshots, trace.network the network log and trace.stacks the call stacks. The folders hold page resources (resources/), the filmstrip images (screencast/) and your Python source files (src/).

πŸ“ Note: In Python, expect() calls are recorded in the trace too, as actions named like *Expect “to_have_text”*, because each assertion runs in the browser. You will see this in the Trace Viewer below.

⌨️ Terminal

pytest -q -s tests

πŸ–₯️ Output

viewport : (1280, 720)
full page: (1280, 8894)
clip     : (400, 300)
.
table    : (740, 172)
viewport.png: 61,548 bytes
viewport.jpg: 30,305 bytes
.
trace files: ['trace.network', 'trace.stacks', 'trace.trace']
resources/  : 17 files
screencast/ : 12 files
src/        : 9 files
.
video: videos/<random name>.webm, video.stat().st_size > 0 = True
.
4 passed, 1 warning in 9.73s

πŸ”¬ Reading a Trace in the Trace Viewer

Open a trace with the Playwright CLI that comes with the Python package:

⌨️ Terminal

playwright show-trace test-results/demos-test-checkout-py-test-wrong-heading-chromium/trace.zip

You can also drag the zip file onto trace.playwright.dev. The trace is opened in your browser; it is not uploaded to a server.

The Trace Viewer of the failed test above shows:

  • Timeline (top): a filmstrip of screenshots over time. Hover over it to see the page at any moment.
  • Actions (left): every step in order with its duration. For this test: *Create page*, *Navigate* (2.1 s) and *Expect “to_have_text”* (2.0 s), shown in red because it failed. Click an action to select it.
  • Action / Before / After (centre): the DOM snapshot of the page at that step. It is a real snapshot, not a picture: you can inspect elements, and the element the action used (here the h1) is highlighted.
  • Bottom tabs: *Locator* (try locators against the snapshot), *Call* (the parameters, such as the expected text “Checkout” and the 2000 ms timeout), *Log* (the same call log as in the terminal), *Errors*, *Console*, *Network* (every request, 35 in this test), *Source* (your Python code with the line highlighted) and *Attachments*.
  • Metadata (next to Actions): the browser, viewport and other context settings.

πŸ’‘ Tip: Start every investigation from the Errors tab, then step back through the Actions list and compare the Before and After snapshots until you find the step where the page stopped matching your expectation.


❓ Frequently Asked Questions

πŸ€” How do I take a screenshot only when a test fails in Playwright Python?

Run pytest with --screenshot only-on-failure, or add it to addopts in pytest.ini. The screenshot is saved as test-failed-1.png in the test’s folder inside test-results.

πŸ€” Why is there no video for my passing test?

With --video retain-on-failure, videos of passing tests are deleted at the end of the test. Use --video on to keep every video.

πŸ€” Can I change the screenshot file name?

The automatic files always use pytest-playwright’s names. For your own names, call page.screenshot(path="...") in the test, or in a fixture that runs after the test.

πŸ€” Where are the screenshots saved?

In the folder set by --output, test-results by default, with one subfolder per test. pytest-playwright clears that folder at the start of each run.

πŸ€” Should I keep traces for every test?

Usually not. Traces are the most detailed artifact and the largest. Use --tracing retain-on-failure so only failed tests keep one.


🏁 Summary

  • page.screenshot() captures the viewport, full_page=True the whole page, clip= a rectangle and locator.screenshot() one element.
  • mask=, mask_color= and style= hide sensitive or changing content; type="jpeg" and quality= make smaller files.
  • pytest-playwright’s --screenshot, --video and --tracing options capture evidence automatically; only-on-failure and retain-on-failure keep it only for failed tests.
  • Artifacts go to test-results/<test-id>/ (test-failed-1.png, video.webm, trace.zip), and are added to pytest-html reports.
  • record_video_dir and context.tracing record from code; tracing.group() organises a trace into steps.
  • playwright show-trace trace.zip or trace.playwright.dev opens a trace for step-by-step debugging.

Next, you will use screenshots for more than evidence: visual regression testing compares them with approved baseline images to catch unwanted UI changes.

🎬 Watch the Playwright tutorial series on YouTube!

Subscribe to SqaToolsByDY • Practice on the SQA Tools Automation Practice Page

1 thought on “Screenshots, Videos and Traces in Playwright Python (on Failure)”

Leave a Comment