Python Output & Comments

Python Tutorial › Getting Started › Output & Comments

Python Output & Comments

BeginnerLesson 6 of 4710 min read10 runnable examples

print() is how your program talks to you, and comments are how you talk to the next person who reads your code. Both look simple, but a few options make them far more useful.

In this lesson
  • Print several values at once
  • Control separators with sep and end
  • Print variables neatly with f-strings
  • Write comments and docstrings that help

The print() function

print() writes text to the screen and then moves to a new line. You can give it any value: text, numbers, lists or the result of a calculation.

Python
print("Hello, Python")print(42)print(3.5 * 2)print([1, 2, 3])print()                 # an empty lineprint("after the blank line")
Output
Hello, Python
42
7.0
[1, 2, 3]

after the blank line

Print several values

Separate values with commas. print() converts each one to text and puts a space between them, so you can mix text and numbers without converting anything.

Python
name = "Asha"tests = 12print("Tester:", name, "ran", tests, "tests")
Output
Tester: Asha ran 12 tests

Change the separator: sep

sep sets what goes between the values. The default is a single space.

Python
print("2026", "11", "02", sep="-")print("a", "b", "c", sep="")print("login", "search", "checkout", sep=" | ")
Output
2026-11-02
abc
login | search | checkout

Change the line ending: end

end sets what is printed after the last value. The default is a newline ("\n"), so each print() starts a new line. Change it to keep printing on the same line.

Python
for step in range(1, 4):    print("step", step, end=" -> ")print("finished")
Output
step 1 -> step 2 -> step 3 -> finished

Print variables with f-strings

An f-string starts with f before the quote. Anything in { } is replaced by its value. This is the clearest way to build output.

Python
item = "keyboard"price = 1499.5qty = 2print(f"{qty} x {item} = {price * qty}")print(f"Price with 2 decimals: {price:.2f}")print(f"{qty=}")
Output
2 x keyboard = 2999.0
Price with 2 decimals: 1499.50
qty=2
Tip

{qty=} prints both the name and the value, which is handy while debugging. The String Formatting lesson covers alignment, padding and number formats.

Escape characters

A backslash inside a string starts an escape sequence:

EscapePrints
\na new line
\ta tab
\\one backslash
\"a double quote inside a double-quoted string
Python
print("Name\tScore")print("Asha\t91")print("Line one\nLine two")print("She said \"hi\"")print("C:\\tests\\report.txt")
Output
Name	Score
Asha	91
Line one
Line two
She said "hi"
C:\tests\report.txt

Print to a file

print() can write to a file instead of the screen with the file argument. The File Handling lesson explains open() in detail.

Python
with open("log.txt", "w") as log:    print("test started", file=log)    print("test passed", file=log) print(open("log.txt").read())
Output
test started
test passed

Comments

A comment starts with #. Python ignores everything from # to the end of the line. Use comments to explain why the code does something, not what it obviously does.

Python
# Prices are stored in paise to avoid rounding errors with floatsprice_paise = 149950 discount = 0.10   # 10% festival discount, ends 15 Novfinal = price_paise * (1 - discount)print(final / 100)
Output
1349.55

Multi-line comments

Python has no special multi-line comment. Put # at the start of each line. Most editors do this for a selection with Ctrl+/.

Python
# Step 1: read the users file# Step 2: keep only active users# Step 3: print their namesusers = [("asha", True), ("ravi", False), ("meena", True)]print([name for name, active in users if active])
Output
['asha', 'meena']

Docstrings

A string written as the first line of a function, class or module is a docstring. Unlike a comment, Python keeps it, and help() shows it.

Python
def area(width, height):    """Return the area of a rectangle."""    return width * height print(area(3, 4))print(area.__doc__)
Output
12
Return the area of a rectangle.
Note

A triple-quoted string on its own in the middle of code is not a comment. Python creates the string and throws it away. It works, but # is the correct tool for comments.

Check your understanding

Predict the output

print("a", "b", sep="*", end="!")print("c")

Quick quiz

1. What is the default value of sep in print()?

2. Which line prints Total: 5 when n = 5?

3. How do you write a comment in Python?

Exercise: print a receipt line

Print one receipt line from these variables, with the price to 2 decimal places and | between the fields.

Input
item = "Mouse", qty = 3, price = 349.5
Expected output
Mouse | 3 | 1048.50
Hint 1

Use an f-string and the format :.2f for the total.

Program Practice

Python print and comments FAQ

How do I print without a newline in Python?

Pass end="" (or any other text) to print(), for example print("Loading", end="").

How do I print a variable with text?

Use an f-string, for example print(f"Total: {total}"), or pass several values: print("Total:", total).

How do I write a multi-line comment in Python?

Start each line with #. Triple-quoted strings are for docstrings, not comments.

What is the difference between a comment and a docstring?

Python ignores comments completely. A docstring is a real string stored on the function, class or module, and tools such as help() and IDEs display it.

Python Syntax

Python Tutorial › Getting Started › Python Syntax

Python Syntax

BeginnerLesson 5 of 479 min read9 runnable examples

Syntax is the set of rules for how Python code must be written. Python’s rules are few, but one of them, indentation, is stricter than in most languages. Learn these rules once and most beginner errors disappear.

In this lesson
  • How statements and lines work
  • Why indentation defines blocks
  • How to split long lines
  • Python keywords and case sensitivity

Statements and lines

A Python program is a list of statements, normally one per line. Python runs them from top to bottom. No semicolon is needed at the end of a line.

Python
city = "Pune"temperature = 31print(city, temperature)
Output
Pune 31

Indentation defines blocks

Many languages use curly braces { } to group lines. Python uses indentation: the spaces at the start of a line. A line ending in a colon : starts a block, and every line in that block must be indented by the same amount.

Python
score = 82 if score >= 50:    print("Passed")          # inside the if block    print("Well done")       # also insideprint("Report finished")     # outside: runs every time
Output
Passed
Well done
Report finished
Tip

Use 4 spaces per level. Every IDE inserts 4 spaces when you press Tab in a .py file. Never mix tabs and spaces in one file.

Wrong indentation is a syntax error, and Python refuses to run the file:

Python
if True:print("not indented")
Output
  File "main.py", line 2
    print("not indented")
    ^^^^^
IndentationError: expected an indented block after 'if' statement on line 1

Indenting a line that should not be indented is also an error:

Python
print("one")    print("two")
Output
  File "main.py", line 2
    print("two")
IndentationError: unexpected indent

Blocks inside blocks

Each new block adds one more level of indentation. Python knows a block has ended when the indentation goes back.

Python
for n in [1, 2, 3, 4]:    if n % 2 == 0:        print(n, "is even")    else:        print(n, "is odd")print("done")
Output
1 is odd
2 is even
3 is odd
4 is even
done

Long lines

Python ends a statement at the end of the line, except inside brackets (), [] or {}. So the easiest way to split a long line is to wrap it in parentheses.

Python
total = (    120    + 80    + 45)names = ["login", "search",         "checkout"]print(total, names)
Output
245 ['login', 'search', 'checkout']

A backslash \ at the end of a line also continues it, but brackets are clearer and are what the PEP 8 style guide recommends.

Several statements on one line

A semicolon lets you put two statements on one line. It works, but it hurts readability, so use it rarely.

Python
a = 1; b = 2; print(a + b)
Output
3

Case sensitivity

Python is case-sensitive: name, Name and NAME are three different variables, and Print is not the same as print.

Python
name = "Asha"Name = "Ravi"print(name, Name)Print("hello")
Output
Asha Ravi
Traceback (most recent call last):
  File "main.py", line 4, in <module>
    Print("hello")
    ^^^^^
NameError: name 'Print' is not defined. Did you mean: 'print'?

Keywords

Keywords are reserved words with a special meaning, such as if, for and def. You cannot use them as variable names. The keyword module lists them:

Python
import keywordprint(len(keyword.kwlist))print(keyword.kwlist)
Output
35
['False', 'None', 'True', 'and', 'as', 'assert', 'async', 'await', 'break', 'class', 'continue', 'def', 'del', 'elif', 'else', 'except', 'finally', 'for', 'from', 'global', 'if', 'import', 'in', 'is', 'lambda', 'nonlocal', 'not', 'or', 'pass', 'raise', 'return', 'try', 'while', 'with', 'yield']
Note

True, False and None are keywords too, and they start with a capital letter. true or none would be treated as ordinary (undefined) names.

Check your understanding

Predict the output

x = 5if x > 3:    print("big")print("end")if x > 10:    print("huge")

Quick quiz

1. What marks the start of a block in Python?

2. How many spaces per indentation level does PEP 8 recommend?

3. Which of these is a valid variable name?

Exercise: fix the indentation

This program should print “Even numbers:” once, then each even number, then “Done”. Fix the indentation so it runs.

Hint 1

Only the print(n) line belongs inside the if.

Hint 2

“Done” must print once, after the loop, so it should not be indented.

Program Practice

Python syntax FAQ

Does Python use semicolons?

Not normally. A newline ends a statement. A semicolon is only needed to put two statements on one line, which is rarely a good idea.

What is an IndentationError?

It is the error Python raises when lines in a block are not indented consistently, or when a block is missing after a colon.

Can I use tabs instead of spaces in Python?

Python accepts tabs, but not a mix of tabs and spaces in the same block. Use 4 spaces; every editor can insert them for you.

Is Python case-sensitive?

Yes. total and Total are different names, and built-in functions such as print must be lowercase.

Retries and Flaky Tests in Playwright Python with pytest-rerunfailures

A flaky test passes on one run and fails on the next, with no change in the code. Flaky tests destroy trust in a test suite: people stop believing red builds. The Playwright TypeScript runner has retries built in; in Python, the pytest-rerunfailures plugin provides them. In this lesson you will learn what makes Playwright tests flaky and how to fix the cause, then use retries correctly: –reruns, delays, the flaky marker, rerunning only specific errors, failing the build on flaky passes, artifacts per attempt, and finding flaky tests with pytest-repeat.

📖 What You’ll Learn: what a flaky test is and why it matters, common causes in browser tests and their fixes, installing pytest-rerunfailures, –reruns and the R progress letter, –reruns-delay, the flaky marker, –only-rerun and –rerun-except, why expect() timeouts are AssertionErrors, –fail-on-flaky, reruns in pytest.ini, screenshots and traces for each attempt, and repeating tests with pytest-repeat to expose flakiness.


🎲 What Is a Flaky Test?

A flaky test gives different results on different runs of the same code: green on your laptop, red in CI, green again when someone clicks “re-run”. It is one of the most expensive problems in test automation:

  • Real bugs hide among the “probably flaky again” failures.
  • Teams waste hours re-running pipelines and investigating ghosts.
  • People stop trusting the suite, and a suite nobody trusts is worth nothing.

A real example from this course: while Lesson 42 was being prepared, about one in six navigations to the public SauceDemo site stalled for more than 30 seconds, and the same unchanged tests failed with Page.goto: Timeout 30000ms exceeded in some runs and passed in others.


🔍 Common Causes and Their Real Fixes

Retries hide symptoms. Before you add them, look for the cause:

CauseSymptomReal fix
Checking too earlyPasses locally, fails on slow CIUse expect() assertions, never time.sleep() or one-shot reads like inner_text() (Module 5)
Shared state between testsFails only in a certain order or in parallelFresh context per test (the default), unique test data, no module-level globals
Animations and transitionsClicks land on a moving elementLet actionability checks work; disable animations for screenshots
Dependence on live third-party servicesRandom timeoutsMock the network (Module 8), or use a stable test environment
Dates, times and random dataFails around midnight or on certain valuesFix the time zone with timezone_id, control test data
Fragile locatorsFails after small UI changes or with A/B testsRole-based locators and test ids (Module 4)
Too-short timeoutsFails under loadTimeouts based on real requirements, not guesses

💡 Tip: The Trace Viewer (Module 5) is the best tool for flaky failures: run CI with --tracing retain-on-failure and compare the trace of a failed attempt with a passing one.


🔁 Retries with pytest-rerunfailures

Install the plugin:

⌨️ Terminal

pip install pytest-rerunfailures

This lesson uses version 16.7. To make the demonstrations repeatable, the test below simulates a flaky server. It shows a search result after a delay: 6 seconds on the first attempt (longer than expect()‘s 5-second timeout) and 1 second on later attempts. pytest-rerunfailures numbers the attempts in request.node.execution_count (1, 2, 3, …).

📄 flaky/test_flaky.py

from playwright.sync_api import Page, expect

RESULT_PAGE = """
<p id="result">Searching...</p>
<script>
  setTimeout(() => document.getElementById('result').textContent = '3 products found', {delay});
</script>
"""


def test_search_results(page: Page, request):
    attempt = getattr(request.node, "execution_count", 1)   # 1 = first try, 2 = first retry, ...
    delay = 6_000 if attempt == 1 else 1_000                # simulated slow server on the first try
    page.set_content(RESULT_PAGE.replace("{delay}", str(delay)))

    expect(page.locator("#result")).to_have_text("3 products found")
  • getattr(..., 1) returns 1 when the plugin is not active, so the test also runs without it.
  • In a real suite the delay comes from the server, not from your test. The simulation only makes the behaviour predictable for this lesson.

Without retries, the first attempt fails:

⌨️ Terminal

pytest -q flaky

🖥️ Output

F                                                                        [100%]
================================== FAILURES ===================================
________________________ test_search_results[chromium] ________________________

page = <Page url='about:blank'>
request = <FixtureRequest for <Function test_search_results[chromium]>>

    def test_search_results(page: Page, request):
        attempt = getattr(request.node, "execution_count", 1)   # 1 = first try, 2 = first retry, ...
        delay = 6_000 if attempt == 1 else 1_000                # simulated slow server on the first try
        page.set_content(RESULT_PAGE.replace("{delay}", str(delay)))
    
>       expect(page.locator("#result")).to_have_text("3 products found")
E       AssertionError: Locator expected to have text '3 products found'
E       Actual value: Searching... 
E       Call log:
E         - Expect "to_have_text" locator("#result") with timeout 5000ms
E         - waiting for locator("#result")
E           14 × locator resolved to <p id="result">Searching...</p>
E              - unexpected value "Searching..."
E       
E       Aria snapshot:
E       - paragraph: Searching...

flaky\test_flaky.py:16: AssertionError
=========================== short test summary info ===========================
FAILED flaky/test_flaky.py::test_search_results[chromium] - AssertionError: L...
1 failed, 1 warning in 5.56s

With retries, --reruns 2 allows up to two more attempts for a failed test:

⌨️ Terminal

pytest -q -rR flaky --reruns 2

🖥️ Output

R.                                                                       [100%]
=========================== rerun test summary info ===========================
RERUN flaky/test_flaky.py::test_search_results[chromium]
PASSED flaky/test_flaky.py::test_search_results[chromium]
1 passed, 1 warning, 1 rerun in 6.95s
  • R in the progress line means “failed, then rerun”; the final . is the passing attempt.
  • The summary counts reruns separately: 1 passed, 1 rerun. The run’s exit code is 0.
  • -rR adds the “rerun test summary info” section that lists which tests needed a rerun. Keep an eye on this list: every entry is a flaky test to investigate.
  • Each attempt runs the test’s fixtures again: a new context and a new page.

⚙️ Retry Options

OptionMeaning
--reruns NRerun a failed test up to N times
--reruns-delay SWait S seconds before each rerun (for example, to let a service recover)
--only-rerun REGEXRerun only failures whose error matches the regex; repeatable
--rerun-except REGEXDo not rerun failures that match the regex; repeatable
--fail-on-flakyFail the run with exit code 7 if any test passed only after a rerun
--max-suite-reruns NLimit the total number of reruns in the whole run
--force-reruns NUse N reruns for every test, ignoring markers

The same settings work in pytest.ini:

[pytest]
addopts = --reruns 2 --reruns-delay 1

pytest-rerunfailures 16.7 also has its own ini keys, such as reruns = 2, reruns_delay = 1, only_rerun and rerun_except.

Retry only infrastructure errors: –only-rerun

A retry makes sense for problems outside your application, such as a slow network. It makes no sense for a wrong text, which will be wrong again on the next attempt. Here is a real bug and a slow page in one file:

📄 filters/test_filters.py

from playwright.sync_api import Page, expect

SLOW_BUTTON = """
<script>
  setTimeout(() => document.body.innerHTML = '<button>Checkout</button>', {delay});
</script>
"""


def test_wrong_heading(page: Page):
    page.set_content("<h1>Cart</h1>")
    expect(page.locator("h1")).to_have_text("Basket", timeout=1_000)   # a real bug: wrong text


def test_slow_button(page: Page, request):
    attempt = getattr(request.node, "execution_count", 1)
    page.set_content(SLOW_BUTTON.replace("{delay}", "3000" if attempt == 1 else "200"))
    page.get_by_role("button", name="Checkout").click(timeout=2_000)       # an action timeout

⌨️ Terminal

pytest -q filters --reruns 2 --only-rerun TimeoutError

🖥️ Output

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

page = <Page url='about:blank'>

    def test_wrong_heading(page: Page):
        page.set_content("<h1>Cart</h1>")
>       expect(page.locator("h1")).to_have_text("Basket", timeout=1_000)   # a real bug: wrong text
        ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
E       AssertionError: Locator expected to have text 'Basket'
E       Actual value: Cart 
E       Call log:
E         - Expect "to_have_text" locator("h1") with timeout 1000ms
E         - waiting for locator("h1")
E           11 × locator resolved to <h1>Cart</h1>
E              - unexpected value "Cart"
E       
E       Aria snapshot:
E       - heading "Cart" [level=1]

filters\test_filters.py:12: AssertionError
=========================== short test summary info ===========================
FAILED filters/test_filters.py::test_wrong_heading[chromium] - AssertionError...
1 failed, 1 passed, 1 warning, 1 rerun in 4.07s
  • test_slow_button failed with Playwright’s TimeoutError on the first attempt, matched TimeoutError, was rerun and passed.
  • test_wrong_heading failed without a rerun. A failed expect() raises an AssertionError, even when it waited for its timeout, so it does not match TimeoutError.

📝 Note: This difference is useful. Action and navigation timeouts (click, fill, goto) raise TimeoutError, often caused by the environment. Failed expect() checks raise AssertionError, more often a real difference in the application. --only-rerun TimeoutError therefore retries infrastructure problems while real assertion failures fail at once.

Failing the build on flaky passes: –fail-on-flaky

Some teams want retries to keep the pipeline moving, but still want to know about flakiness. --fail-on-flaky lets the tests pass on retry but ends the run with exit code 7:

⌨️ Terminal

pytest -q flaky --reruns 2 --fail-on-flaky

🖥️ Output

R.                                                                       [100%]
1 passed, 1 warning, 1 rerun in 6.96s

The summary still says 1 passed, 1 rerun, but pytest exited with code 7 (in our run, the verification script recorded exit 7), so CI marks the job as failed and someone has to look at the flaky test.


🏷️ The flaky Marker: Retries for Specific Tests

Instead of retrying the whole suite, mark only the tests you know are affected:

📄 marker/test_marker.py

import pytest
from playwright.sync_api import Page, expect

RESULT_PAGE = """
<p id="result">Searching...</p>
<script>
  setTimeout(() => document.getElementById('result').textContent = 'done', {delay});
</script>
"""


@pytest.mark.flaky(reruns=3, reruns_delay=1, only_rerun=["AssertionError"])
def test_known_slow_search(page: Page, request):
    attempt = request.node.execution_count
    print(f"\nattempt {attempt}")
    page.set_content(RESULT_PAGE.replace("{delay}", "4000" if attempt < 3 else "100"))
    expect(page.locator("#result")).to_have_text("done", timeout=2_000)

⌨️ Terminal

pytest -q -s marker

🖥️ Output

attempt 1
R
attempt 2
R
attempt 3
.
1 passed, 1 warning, 2 rerun in 6.89s
  • reruns=3 allows three reruns for this test only, without any command-line option.
  • reruns_delay=1 waits one second between attempts.
  • only_rerun= (and rerun_except=) work in the marker too. Here the test’s failures are AssertionErrors from expect(), so they are listed explicitly.
  • The test failed twice (each R) and passed on attempt 3; the summary counts 2 rerun.
  • When both the marker and --reruns are given, the marker wins by default (--reruns-mode strict).

⚠️ Watch out: A flaky marker is a temporary measure. Add a comment with the reason and a ticket number, and remove the marker once the cause is fixed. A suite where many tests carry it has a flakiness problem that retries only hide.


📸 Artifacts for Each Attempt

When you combine retries with pytest-playwright’s artifacts, each failed attempt keeps its own evidence. Run the simulated flaky test with one rerun and screenshots on failure:

⌨️ Terminal

pytest -q flaky --reruns 1 --screenshot only-on-failure

🖥️ Output

R.                                                                       [100%]
1 passed, 1 warning, 1 rerun in 7.00s

The output folder now contains:

🖥️ Output

test-results/flaky-test-flaky-py-test-search-results-chromium/test-failed-1.png
  • The screenshot of the first, failed attempt is kept, which is exactly the evidence you need to understand the flaky failure.
  • The retry passed, so it kept nothing. A failed retry would have its own folder ending in -retry1, as you saw in Module 5.
  • With --tracing retain-on-failure, you get a trace of the failed attempt too: the best way to see why it was slow.

🔂 Finding Flaky Tests: pytest-repeat

To check whether a test is stable before it causes trouble in CI, run it many times. The pytest-repeat plugin (version 0.9.4 here) adds --count:

⌨️ Terminal

pip install pytest-repeat

📄 repeat/test_repeat.py

from playwright.sync_api import Page, expect


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

⌨️ Terminal

pytest repeat --count 3 -v

🖥️ Output

============================= test session starts =============================
platform win32 -- Python 3.15.1, pytest-9.2.0, pluggy-1.7.0 -- C:\pwv\_venv\Scripts\python.exe
...
plugins: html-4.2.0, metadata-3.1.1, playwright-0.10.0, repeat-0.9.4, rerunfailures-16.7, xdist-3.8.0
collecting ... collected 3 items

repeat/test_repeat.py::test_practice_page_title[chromium-1-3] PASSED     [ 33%]
repeat/test_repeat.py::test_practice_page_title[chromium-2-3] PASSED     [ 66%]
repeat/test_repeat.py::test_practice_page_title[chromium-3-3] PASSED     [100%]

======================== 3 passed, 1 warning in 6.59s =========================
  • Each repetition gets its own test id (1-3, 2-3, 3-3) and a fresh context.
  • For a real stability check, use a higher count, such as --count 50, and add -x to stop at the first failure. Combine it with pytest-xdist (Module 12) to finish faster.
  • Do not combine --count with --reruns for this purpose: retries would hide exactly the failures you are looking for.

✅ A Sensible Retry Policy

  1. Locally: no retries. You want to see every failure while you work.
  2. In CI: a small number, such as --reruns 1 or --reruns 2, ideally with --only-rerun TimeoutError for infrastructure problems.
  3. Always keep evidence: --tracing retain-on-failure and --screenshot only-on-failure.
  4. Track every rerun. Use -rR or the report to list flaky tests, and fix them like bugs; consider --fail-on-flaky once the suite is mostly stable.
  5. Never use retries to hide a real failure. A test that fails 1 time in 10 because of the application is a bug report, not noise.

❓ Frequently Asked Questions

🤔 Does Playwright Python have built-in retries like playwright.config.ts?

No. The TypeScript test runner has a retries setting. In Python, retries come from the pytest-rerunfailures plugin: --reruns N or the flaky marker.

🤔 Are fixtures run again on a rerun?

Yes. Each attempt is a complete run of the test, with its function-scoped fixtures (including page and context) set up and torn down again. Session-scoped fixtures, such as the browser, are reused.

🤔 Why is my expect() failure not retried with –only-rerun TimeoutError?

A failed expect() raises AssertionError, not TimeoutError. Only actions and navigations (click, fill, goto) raise Playwright’s TimeoutError. Use --only-rerun AssertionError if you really want to retry assertion failures.

🤔 How do I know which tests are flaky?

Look for R in the progress line and use -rR to list rerun tests in the summary. Run suspicious tests many times with pytest-repeat’s --count.

🤔 Can I use –reruns together with pytest-xdist?

Yes. Each worker reruns its own failed tests. Parallel runs are covered in Module 12.


🏁 Summary

  • Flaky tests pass and fail on the same code; fix their causes first: waits, shared state, live dependencies, data and locators.
  • pytest-rerunfailures adds retries: --reruns N, --reruns-delay, the flaky marker, and filters with --only-rerun and --rerun-except.
  • Failed expect() checks raise AssertionError; action and navigation timeouts raise TimeoutError.
  • --fail-on-flaky exits with code 7 when a test only passed on retry; -rR lists rerun tests.
  • Failed attempts keep their screenshots and traces; pytest-repeat’s --count exposes flakiness before CI does.

You have finished Module 6. Test yourself with the Module 6 mock interview, then continue to Module 7: test data, configuration and cross-browser testing at scale.

🎬 Watch the Playwright tutorial series on YouTube!

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

Browser Markers in pytest-playwright: skip_browser, only_browser and browser_context_args

One of Playwright’s biggest strengths is real cross-browser testing: the same test runs on Chromium, Firefox and WebKit. Sometimes, though, a test should not run everywhere: a feature is missing in one engine, or the test uses a Chromium-only API. pytest-playwright gives you markers for exactly that. In this lesson you will run a suite on all three engines, find a real browser difference on the practice page, and control where each test runs with skip_browser, only_browser, browser_context_args, browser channels and per-test launch options.

📖 What You’ll Learn: running tests on several browsers with –browser, reading the parametrized test ids, a real WebKit difference on the practice page, skip_browser and only_browser, showing skip reasons with -rs, markers on classes and modules, branching on browser_name, the browser_context_args marker for per-test context options, browser channels for Google Chrome and Microsoft Edge, and per-test launch options with indirect parametrization of browser_type_launch_args.


🌍 Running on Several Browsers

By default pytest-playwright runs every test on Chromium. Pass --browser once per engine to run on more:

⌨️ Terminal

pytest --browser chromium --browser firefox --browser webkit

Each --browser creates one copy of every browser test, with the engine in the test id: test_title[chromium], test_title[firefox], test_title[webkit]. Each engine gets its own browser process; every test still gets a fresh context.

EngineUsed byNotes
ChromiumGoogle Chrome, Microsoft Edge, OperaPlaywright’s default
FirefoxMozilla FirefoxPlaywright’s patched build
WebKitApple SafariPlaywright’s WebKit build runs on Windows, Linux and macOS

📝 Note: Playwright’s WebKit is close to Safari but not identical: the real Safari also depends on the Apple operating system it runs on. It is still the best way to catch WebKit-specific problems without a Mac.


🔎 A Real Difference Between Engines

All tests in this lesson 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

The page has a date picker, <input type="date" id="datePicker">. This test asks each browser what kind of input it actually created:

📄 tests/test_cross_browser.py

import pytest
from playwright.sync_api import Page, expect


def test_title(practice: Page, browser_name):
    print(f"\n{browser_name} {practice.context.browser.version}")
    expect(practice).to_have_title("Automation Practice Page - SQA Tools")


def test_what_is_the_date_input(practice: Page, browser_name):
    input_type = practice.locator("#datePicker").evaluate("el => el.type")
    print(f"\n{browser_name}: #datePicker has type '{input_type}'")
  • practice.context.browser.version is the version of the browser that runs the test.
  • el.type returns the type the browser supports: a browser without a date control falls back to a plain text box and reports "text".

When this lesson was run on Windows, Chromium and Firefox reported date, but **WebKit reported text**: Playwright’s WebKit build on Windows has no native date picker (you will see this in the output below). A test that fills the date picker in the YYYY-MM-DD format and expects a date control would fail there for a reason that has nothing to do with your application.


⏭️ skip_browser and only_browser

pytest-playwright registers two markers for such cases:

📄 tests/test_cross_browser.py

@pytest.mark.skip_browser("webkit")
def test_date_picker(practice: Page):
    practice.locator("#datePicker").fill("2026-11-01")
    expect(practice.locator("#datePicker")).to_have_value("2026-11-01")


@pytest.mark.only_browser("chromium")
def test_chromium_devtools_protocol(practice: Page):
    # CDP sessions exist only in Chromium-based browsers
    cdp = practice.context.new_cdp_session(practice)
    version = cdp.send("Browser.getVersion")
    print("\nCDP product:", version["product"].split("/")[0])
  • @pytest.mark.skip_browser("webkit"): run on every browser except WebKit.
  • @pytest.mark.only_browser("chromium"): run only on Chromium; all other browsers are skipped.
  • new_cdp_session() talks to the Chrome DevTools Protocol, which only Chromium-based browsers support. That makes it a genuine reason for only_browser.
  • Skipped copies appear as s in the progress line. Add -rs to the pytest command to list each skip with its reason.

⚠️ Watch out: Use these markers for real engine limitations, not to hide bugs. If your application is broken in Safari, a skipped test means your users find the bug instead of you. Mark such tests with xfail and a ticket number, or fix the application.

Markers on a whole class or module

Markers can be applied to a class, and to a whole file with pytestmark:

📄 tests/test_firefox_suite.py

import pytest
from playwright.sync_api import Page, expect


@pytest.mark.only_browser("firefox")
class TestFirefoxSpecific:
    def test_user_agent(self, practice: Page):
        expect(practice.locator("body")).to_be_visible()
        assert "Firefox" in practice.evaluate("navigator.userAgent")

    def test_engine_name(self, practice: Page, browser_name):
        assert browser_name == "firefox"

For a whole module, write pytestmark = pytest.mark.skip_browser("webkit") at the top of the file.

Branching inside a test with browser_name

When only one step differs, skip that part or adapt the expected value instead of skipping the whole test:

📄 tests/test_cross_browser.py

ENGINE_IN_USER_AGENT = {"chromium": "Chrome", "firefox": "Firefox", "webkit": "AppleWebKit"}


def test_user_agent_matches_engine(practice: Page, browser_name):
    user_agent = practice.evaluate("navigator.userAgent")
    assert ENGINE_IN_USER_AGENT[browser_name] in user_agent
  • A dictionary keyed by browser_name keeps browser-specific expectations in one readable place.
  • You can also call pytest.skip("reason") inside the test after checking browser_name or is_webkit. Prefer the markers when the whole test does not apply: the decision is then visible without reading the test body.

🎛️ The browser_context_args Marker

You met this marker in Lesson 6. It adds or overrides context options for one test (or one class), on top of the browser_context_args fixture:

📄 tests/test_context_marker.py

import pytest
from playwright.sync_api import Page


@pytest.mark.browser_context_args(locale="de-DE", timezone_id="Europe/Berlin")
def test_german_user(page: Page):
    page.goto("https://sqatools.in/automation-practice-page/")
    language = page.evaluate("navigator.language")
    zone = page.evaluate("Intl.DateTimeFormat().resolvedOptions().timeZone")
    print(f"\nlanguage={language} timezone={zone}")
    assert (language, zone) == ("de-DE", "Europe/Berlin")
  • The marker’s keyword arguments are passed to browser.new_context() for this test’s context.
  • Typical uses: a different locale or time zone, color_scheme="dark", permissions=["geolocation"], a mobile viewport, or storage_state for a test that should start logged in.

Now run the whole tests folder on all three engines, with -rs to show skip reasons:

⌨️ Terminal

pytest -q -s -rs tests --browser chromium --browser firefox --browser webkit

🖥️ Output

language=de-DE timezone=Europe/Berlin
.
chromium 156.0.8078.4
.
chromium: #datePicker has type 'date'
..
CDP product: HeadlessChrome
..ss
language=de-DE timezone=Europe/Berlin
.
firefox 157.0
.
firefox: #datePicker has type 'date'
..s...
language=de-DE timezone=Europe/Berlin
.
webkit 27.2
.
webkit: #datePicker has type 'text'
.ss.ss
=========================== short test summary info ===========================
SKIPPED [2] ..\_venv0\Lib\site-packages\pytest_playwright\pytest_playwright.py:338: skipped for this browser: chromium
SKIPPED [1] ..\_venv0\Lib\site-packages\pytest_playwright\pytest_playwright.py:338: skipped for this browser: firefox
SKIPPED [4] ..\_venv0\Lib\site-packages\pytest_playwright\pytest_playwright.py:338: skipped for this browser: webkit
17 passed, 7 skipped, 1 warning in 38.09s

Read the result:

  • The #datePicker lines show the real engine difference: date in Chromium and Firefox, text in WebKit.
  • test_date_picker[webkit] was skipped by skip_browser; the CDP test and the Firefox class were skipped on the other engines by only_browser. The reason for each skip is listed in the short test summary, because of -rs.
  • pytest groups the run by browser: all Chromium tests first, then Firefox, then WebKit, because each engine’s browser is a separate session fixture.
  • The skips add up: Chromium skipped the two Firefox-only tests, Firefox skipped the CDP test, and WebKit skipped the date picker, the CDP test and the two Firefox-only tests.
  • The reason text is fixed by the plugin (“skipped for this browser: …”) and points at the plugin’s code line, not at your test.

🏷️ Browser Channels: Google Chrome and Microsoft Edge

Playwright’s Chromium is an open-source build. To test with the branded browsers installed on the machine, pass a channel:

⌨️ Terminal

pytest -q -s tests/test_cross_browser.py::test_title --browser-channel msedge

🖥️ Output

chromium 156.0.4314.42
.
1 passed, 1 warning in 2.70s
  • --browser-channel msedge launched the Microsoft Edge installed on this Windows machine, so the version is Edge’s own version number. chrome selects Google Chrome; beta and dev channels exist too (chrome-beta, msedge-beta, …).
  • The test id still says [chromium], because a channel is a Chromium-based browser.
  • A channel uses the browser installed on the machine. Install it there first (for Chrome you can also run playwright install chrome).

🚀 Per-Test Launch Options

Context options change the page environment (locale, viewport, permissions). Launch options change the browser process itself: command-line arguments, slow_mo, headless, channel. The suite-wide way to set them is overriding the browser_type_launch_args fixture (Lesson 6). For a single test, parametrize that fixture indirectly:

📄 launch/test_launch_args.py

import pytest
from playwright.sync_api import Page


@pytest.mark.only_browser("chromium")
@pytest.mark.parametrize(
    "browser_type_launch_args",
    [{"args": ["--lang=de-DE"]}],
    indirect=True,
)
def test_browser_started_in_german(page: Page, browser_type_launch_args):
    print("\nlaunch args:", browser_type_launch_args)
    print("navigator.language:", page.evaluate("navigator.language"))
  • indirect=True sends the value to the browser_type_launch_args fixture instead of passing it straight to the test. In pytest-playwright 0.10, the fixture merges it over the command-line launch options.
  • --lang=de-DE is a Chromium command-line switch, so the test is limited to Chromium with only_browser.
  • Because browser_type_launch_args and browser are session-scoped, each distinct value launches its own browser. That is slower, so keep per-test launch options for the few tests that really need them.

⌨️ Terminal

pytest -q -s -rs launch --browser chromium --browser firefox

🖥️ Output

launch args: {'args': ['--lang=de-DE']}
navigator.language: de
.s
=========================== short test summary info ===========================
SKIPPED [1] ..\_venv0\Lib\site-packages\pytest_playwright\pytest_playwright.py:338: skipped for this browser: firefox
1 passed, 1 skipped, 1 warning in 0.57s
  • The browser started with --lang=de-DE, so navigator.language is de, without any context option.
  • On Firefox the test was skipped by the only_browser marker.
  • The test id is test_browser_started_in_german[chromium-browser_type_launch_args0]: the parameter gets an automatic id. Pass ids=["german"] to parametrize for a readable one.

📋 Which Tool for Which Need?

NeedUse
Run the suite on several engines--browser chromium --browser firefox --browser webkit
A test that cannot work on one engine@pytest.mark.skip_browser("webkit")
A test that uses an engine-specific API@pytest.mark.only_browser("chromium")
One step that differs per enginebrowser_name, is_webkit and friends inside the test
Locale, time zone, viewport, permissions for one test@pytest.mark.browser_context_args(...)
The same for every testOverride the browser_context_args fixture
Branded Chrome or Edge--browser-channel chrome or msedge
Browser command-line switches for one testIndirect parametrization of browser_type_launch_args

❓ Frequently Asked Questions

🤔 How do I run Playwright Python tests on all browsers?

Pass --browser once per engine: pytest --browser chromium --browser firefox --browser webkit. Put it in addopts in pytest.ini to make it the default.

🤔 What is the difference between skip_browser and only_browser?

skip_browser("webkit") excludes one engine and runs on the rest. only_browser("chromium") runs on one engine and skips all others.

🤔 Why is my only_browser test always skipped?

It only runs when that browser is part of the run. With only_browser("firefox") and no --browser firefox, every copy of the test is skipped.

🤔 Is WebKit on Windows the same as Safari?

No. It is Playwright’s build of the WebKit engine, so it behaves like Safari in most respects but not all, as the date input in this lesson shows. Run important Safari checks on macOS as well.

🤔 Can I set a different viewport for one test?

Yes: @pytest.mark.browser_context_args(viewport={"width": 390, "height": 844}), or create a context with new_context(viewport=...) inside the test.


🏁 Summary

  • --browser runs every browser test once per engine; the engine appears in the test id.
  • Engines differ: on Windows, WebKit has no native date input on the practice page.
  • skip_browser and only_browser decide where a test runs; -rs shows why tests were skipped.
  • Apply markers to classes or whole modules (pytestmark), and branch on browser_name for small differences.
  • The browser_context_args marker changes context options for one test; --browser-channel runs branded Chrome or Edge.
  • Indirect parametrization of browser_type_launch_args sets launch options for one test, at the cost of a separate browser launch.

Next, you will deal with tests that fail only sometimes: why tests become flaky, and how to retry them with pytest-rerunfailures without hiding real problems.

🎬 Watch the Playwright tutorial series on YouTube!

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

Custom Fixtures for Playwright Python Tests (Page Objects, Login, Factories)

The built-in page fixture gives every test an empty browser tab. Real tests need more: an opened login page, a user who is already logged in, products in the cart, a check that no JavaScript errors happened. Custom fixtures turn that preparation into one reusable word in the test’s parameter list. In this lesson you will build a small fixture library for the SauceDemo shop on top of pytest-playwright’s fixtures, including a session-scoped login that is reused by every test.

📖 What You’ll Learn: why custom fixtures keep Playwright tests short, a page object fixture, chaining fixtures into a logged-in page, logging in once per session with storage_state and new_context, factory fixtures that take arguments, yield teardown, a fixture that collects console and page errors, parametrized fixtures for desktop and mobile, the ScopeMismatch error and the scope rules for fixtures that use page.


🧩 Why Custom Fixtures?

Compare two versions of the same test. Without fixtures, every test repeats the setup:

def test_cart_badge(page):
    page.goto("https://www.saucedemo.com/")
    page.get_by_placeholder("Username").fill("standard_user")
    page.get_by_placeholder("Password").fill("secret_sauce")
    page.get_by_role("button", name="Login").click()
    # ... and only now the actual test starts

With fixtures, the test says only what it is about:

def test_cart_badge(add_to_cart):
    page = add_to_cart("Sauce Labs Backpack")
    expect(page.locator(".shopping_cart_badge")).to_have_text("1")

You already know how fixtures work (Module 3). This lesson is about Playwright-specific fixtures: building on page, new_context and browser, choosing the right scope, and keeping tests isolated.

All examples use SauceDemo (www.saucedemo.com), a public practice shop. Its login page lists the demo user names and the shared password secret_sauce.

📝 Note: SauceDemo is a shared public site. While this lesson was being prepared, roughly one navigation in six stalled for more than 30 seconds, and the tests then failed with Page.goto: Timeout 30000ms exceeded. If that happens to you, simply run the tests again. Lesson 44 shows how to handle such flaky failures with automatic retries.


🏗️ The Fixture Library: conftest.py

Here is the start of conftest.py. The rest of the file follows in the next sections.

📄 conftest.py

import pytest
from playwright.sync_api import Browser, Page, expect

SAUCE = "https://www.saucedemo.com/"
USER = {"username": "standard_user", "password": "secret_sauce"}   # public demo account


class LoginPage:
    """A tiny page object for the SauceDemo login page."""

    def __init__(self, page: Page):
        self.page = page
        self.username = page.get_by_placeholder("Username")
        self.password = page.get_by_placeholder("Password")
        self.button = page.get_by_role("button", name="Login")
        self.error = page.locator("[data-test='error']")

    def open(self) -> "LoginPage":
        self.page.goto(SAUCE, wait_until="domcontentloaded")
        return self

    def login(self, username: str, password: str) -> None:
        self.username.fill(username)
        self.password.fill(password)
        self.button.click()


# 1. A page object fixture: the login page, already opened
@pytest.fixture
def login_page(page: Page) -> LoginPage:
    return LoginPage(page).open()


# 2. Chained fixture: a page where the user is logged in
@pytest.fixture
def logged_in_page(login_page: LoginPage) -> Page:
    login_page.login(**USER)
    expect(login_page.page).to_have_url(f"{SAUCE}inventory.html")
    return login_page.page

1. A page object fixture

  • LoginPage groups the login page’s locators and actions in one class. This is the Page Object Model, covered in depth in Module 10.
  • The login_page fixture builds on pytest-playwright’s page, opens the page and returns the object. A test that asks for login_page starts on the login screen.
  • wait_until="domcontentloaded": as you saw in Module 5, a public site’s load event can be slow because of third-party resources. The locators wait for the form anyway.

2. Chaining fixtures into a logged-in page

  • logged_in_page asks for login_page, which asks for page. pytest resolves the chain: page → login_page → logged_in_page.
  • The fixture ends with an expect(): if the login fails, the fixture fails with a clear message, and pytest reports the test as an error in setup rather than a failure in the test body. That tells you immediately that the problem is in the preparation.

📄 tests/test_login.py

from playwright.sync_api import Page, expect


def test_locked_out_user_sees_error(login_page):
    login_page.login("locked_out_user", "secret_sauce")
    expect(login_page.error).to_have_text("Epic sadface: Sorry, this user has been locked out.")


def test_products_are_listed(logged_in_page: Page):
    expect(logged_in_page.locator(".title")).to_have_text("Products")
    expect(logged_in_page.locator(".inventory_item")).to_have_count(6)

🔐 Logging In Once: a Session Fixture with storage_state

Logging in through the form in every test costs time. A faster pattern: log in once per session, save the browser’s cookies and storage to a file, and start every test’s context from that file.

📄 conftest.py

# 3. Log in once for the whole session and save the browser state
@pytest.fixture(scope="session")
def auth_state(browser: Browser, tmp_path_factory) -> str:
    context = browser.new_context()
    page = context.new_page()
    LoginPage(page).open().login(**USER)
    page.wait_for_url("**/inventory.html")

    path = tmp_path_factory.mktemp("auth") / "state.json"
    context.storage_state(path=path)       # cookies + localStorage as JSON
    context.close()
    print(f"\n[auth_state] logged in once, saved {path.name}")
    return str(path)


# 4. Each test gets a NEW context that starts from the saved state
@pytest.fixture
def fast_logged_in_page(new_context, auth_state: str) -> Page:
    page = new_context(storage_state=auth_state).new_page()
    page.goto(f"{SAUCE}inventory.html", wait_until="domcontentloaded")
    return page
  • auth_state is session-scoped, so it may only use session-scoped fixtures. It uses browser (session) and pytest’s built-in tmp_path_factory (session), not page (function).
  • context.storage_state(path=...) writes the context’s cookies and localStorage to a JSON file. SauceDemo keeps the login in a cookie named session-username.
  • fast_logged_in_page calls new_context(storage_state=...): a fresh, isolated context per test, which already contains the login cookie. The test lands directly on the products page.

📄 tests/test_fast_login.py

from playwright.sync_api import Page, expect


def test_first_fast_login(fast_logged_in_page: Page):
    expect(fast_logged_in_page.locator(".title")).to_have_text("Products")


def test_second_fast_login(fast_logged_in_page: Page):
    expect(fast_logged_in_page).to_have_url("https://www.saucedemo.com/inventory.html")

Run them with --setup-show to see that the login happens only once (an excerpt; ... marks lines removed for brevity):

⌨️ Terminal

pytest -q --setup-show tests/test_fast_login.py

🖥️ Output

...
SETUP    S browser (fixtures used: launch_browser)
...
SETUP    S tmp_path_factory
SETUP    S auth_state (fixtures used: browser, tmp_path_factory)
        ...
        SETUP    F new_context (fixtures used: _artifacts_recorder, browser, browser_context_args)
        SETUP    F fast_logged_in_page (fixtures used: auth_state, new_context)
        tests/test_fast_login.py::test_first_fast_login[chromium] (fixtures used: ...) .
        TEARDOWN F fast_logged_in_page
        TEARDOWN F new_context
        ...
        SETUP    F new_context (fixtures used: _artifacts_recorder, browser, browser_context_args)
        SETUP    F fast_logged_in_page (fixtures used: auth_state, new_context)
        tests/test_fast_login.py::test_second_fast_login[chromium] (fixtures used: ...) .
        TEARDOWN F fast_logged_in_page
        TEARDOWN F new_context
        ...
TEARDOWN S auth_state
TEARDOWN S tmp_path_factory
...
2 passed, 1 warning in 8.65s

SETUP S auth_state appears once, before the first test; both tests then create their own F fast_logged_in_page. Module 8 builds on this pattern for real authentication setups.

⚠️ Watch out: Never commit real credentials in conftest.py. The SauceDemo values are public demo data. For your own applications, read user names and passwords from environment variables or a secrets store (Module 7).


🏭 Factory Fixtures: Fixtures That Take Arguments

A fixture cannot receive arguments from the test directly. The solution is a factory: the fixture returns a function, and the test calls it with the arguments it needs.

📄 conftest.py

# 5. A factory fixture: the test decides which products to add
@pytest.fixture
def add_to_cart(logged_in_page: Page):
    added = []

    def _add(product: str) -> Page:
        item = logged_in_page.locator(".inventory_item").filter(has_text=product)
        item.get_by_role("button", name="Add to cart").click()
        added.append(product)
        return logged_in_page

    yield _add

    # Teardown: runs after the test, even if it failed
    print(f"\n[add_to_cart] teardown, products added in this test: {added}")

📄 tests/test_cart.py

from playwright.sync_api import expect


def test_one_product(add_to_cart):
    page = add_to_cart("Sauce Labs Backpack")
    expect(page.locator(".shopping_cart_badge")).to_have_text("1")


def test_two_products(add_to_cart):
    add_to_cart("Sauce Labs Backpack")
    page = add_to_cart("Sauce Labs Bike Light")
    expect(page.locator(".shopping_cart_badge")).to_have_text("2")
  • yield _add hands the inner function to the test. Everything after yield is teardown.
  • The added list remembers what the test did, so teardown knows what to clean up.
  • Here teardown only prints, because the browser context, and with it SauceDemo’s cart, is thrown away after each test anyway. In a real application the cart or order may live in a database: that is where teardown matters, for example to delete the order through an API call.

🚨 A Fixture That Watches the Page: Console and Page Errors

Fixtures can also attach listeners to the page. This one collects JavaScript errors so a test can assert that none happened:

📄 conftest.py

# 6. Collect console errors and uncaught exceptions of the page
@pytest.fixture
def js_errors(page: Page) -> list[str]:
    errors: list[str] = []
    page.on("console", lambda msg: errors.append(f"console: {msg.text}") if msg.type == "error" else None)
    page.on("pageerror", lambda exc: errors.append(f"pageerror: {exc}"))
    return errors

📄 tests/test_js_errors.py

from playwright.sync_api import Page, expect


def test_page_with_a_broken_script(page: Page, js_errors):
    page.set_content("""
        <button onclick="undefinedFunction()">Buy</button>
        <script>console.error('Payment widget failed to load')</script>
    """)
    page.get_by_role("button", name="Buy").click()
    expect(page.get_by_role("button")).to_be_visible()      # gives the events time to arrive

    print("\ncollected:", js_errors)
    assert len(js_errors) == 2
  • page.on("console", ...) receives every console message; the fixture keeps those of type "error".
  • page.on("pageerror", ...) receives uncaught JavaScript exceptions, such as calling a function that does not exist.
  • Both js_errors and the test receive the same page object: within one test, a function-scoped fixture is created only once and shared.

🔀 Parametrized Fixtures: Desktop and Mobile

A fixture with params runs every test that uses it once per parameter:

📄 conftest.py

# 7. One test, two screen sizes
@pytest.fixture(
    params=[{"width": 1280, "height": 720}, {"width": 390, "height": 844}],
    ids=["desktop", "mobile"],
)
def sized_page(request, new_context) -> Page:
    page = new_context(viewport=request.param).new_page()
    page.goto(SAUCE, wait_until="domcontentloaded")
    return page

📄 tests/test_sizes.py

from playwright.sync_api import Page, expect


def test_login_form_fits(sized_page: Page):
    print(f"\n{sized_page.viewport_size['width']} px wide")
    expect(sized_page.get_by_role("button", name="Login")).to_be_in_viewport()
  • request.param holds the current parameter; ids gives readable names in the test id.
  • new_context(viewport=...) creates a context with that window size, without changing the project’s defaults.

⌨️ Terminal

pytest -q -s tests

🖥️ Output

.
[add_to_cart] teardown, products added in this test: ['Sauce Labs Backpack']
.
[add_to_cart] teardown, products added in this test: ['Sauce Labs Backpack', 'Sauce Labs Bike Light']

[auth_state] logged in once, saved state.json
..
collected: ['console: Payment widget failed to load', 'pageerror: undefinedFunction is not defined']
...
1280 px wide
.
390 px wide
.
9 passed, 1 warning in 36.71s
  • The [add_to_cart] lines come from the fixture’s teardown, after each cart test.
  • The [auth_state] line is printed once, when the first test that needs it runs; the second fast-login test reuses the saved file.

List the generated test ids with --collect-only:

⌨️ Terminal

pytest -q --collect-only tests/test_sizes.py

🖥️ Output

tests/test_sizes.py::test_login_form_fits[desktop-chromium]
tests/test_sizes.py::test_login_form_fits[mobile-chromium]

2 tests collected in 0.00s

Your fixture’s ids come first and pytest-playwright’s browser name second.


⛔ The Scope Rule: ScopeMismatch

A fixture can only use fixtures with the same or a wider scope. page is function-scoped, so a session-scoped fixture cannot use it:

📄 demos/test_scope_error.py

import pytest
from playwright.sync_api import Page


@pytest.fixture(scope="session")
def shared_home(page: Page) -> Page:          # session fixture asking for a function fixture
    page.goto("https://www.saucedemo.com/", wait_until="domcontentloaded")
    return page


def test_uses_shared_home(shared_home: Page):
    assert shared_home.url.startswith("https://www.saucedemo.com")

⌨️ Terminal

pytest -q demos/test_scope_error.py

🖥️ Output

E                                                                        [100%]
=================================== ERRORS ====================================
______________ ERROR at setup of test_uses_shared_home[chromium] ______________
ScopeMismatch: You tried to access the function scoped fixture page with a session scoped request object. Requesting fixture stack:
demos\test_scope_error.py:5:  def shared_home(page: 'Page') -> 'Page'
Requested fixture:
..\_venv0\Lib\site-packages\pytest_playwright\pytest_playwright.py:623:  def page(context: 'BrowserContext') -> 'Page'
=========================== short test summary info ===========================
ERROR demos/test_scope_error.py::test_uses_shared_home[chromium] - Failed: Sc...
1 error in 0.03s
  • pytest refuses to start the test and reports a ScopeMismatch error in setup.
  • The fix depends on your goal. To share something expensive across tests, build it from session fixtures (browser, playwright, tmp_path_factory), as auth_state does. To share a page, do not: share its saved state instead.
Your fixture’s scopeMay use
functionEverything: page, context, new_context, browser, playwright
class / modulebrowser, playwright, browser_name and other session fixtures
sessionSession fixtures only: browser, playwright, browser_type, tmp_path_factory, pytestconfig

✅ Good Practices for Playwright Fixtures

  1. Name fixtures after the state they give you: logged_in_page, cart_with_two_items, admin_api.
  2. Assert inside setup fixtures (as logged_in_page does) so a broken setup shows up as a setup error.
  3. Keep tests isolated: share saved state (cookies, tokens) across tests, never a live page or context.
  4. Clean up server-side data after yield; browser data disappears with the context automatically.
  5. **Put shared fixtures in conftest.py** and keep page objects in their own module as the project grows.

❓ Frequently Asked Questions

🤔 Can a custom fixture use the page fixture?

Yes, as long as your fixture is function-scoped (the default). The fixture and the test then share the same page object.

🤔 How do I reuse a login across tests without logging in every time?

Log in once in a session-scoped fixture that uses browser, save context.storage_state(path=...), and create each test’s context with new_context(storage_state=path).

🤔 Why is my test reported as an error instead of a failure?

The exception happened in a fixture (setup or teardown), not in the test body. Check the fixture named in the traceback.

🤔 Can I pass arguments to a fixture?

Not directly. Return a function from the fixture (a factory) and call it in the test with the arguments, or use params or indirect parametrization for fixed values.


🏁 Summary

  • Custom fixtures package preparation, such as an opened page object, a logged-in user or products in the cart, into one parameter.
  • Chain fixtures: page → login_page → logged_in_page → add_to_cart.
  • Log in once with a session fixture built on browser, save storage_state, and start each test from it with new_context(storage_state=...).
  • Factory fixtures accept arguments; yield gives you teardown.
  • Listener fixtures, such as js_errors, watch the page during the test.
  • Session fixtures cannot use page: pytest raises ScopeMismatch.

Next, you will control which browsers each test runs on with skip_browser, only_browser and per-test launch options.

🎬 Watch the Playwright tutorial series on YouTube!

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

pytest-playwright Built-in Fixtures Explained (page, context, browser and more)

Every Playwright test you have written so far started with a page parameter. Behind that one word, the pytest-playwright plugin builds a chain of fixtures: it starts Playwright, launches a browser, creates a fresh browser context and opens a page, then cleans everything up. In this lesson you will see the whole chain with pytest’s –setup-show, prove that each test is isolated, and use the other built-in fixtures: new_context, browser, browser_name, is_chromium, device, output_path, base_url and the playwright object itself.

📖 What You’ll Learn: the fixture chain from playwright to page, which fixtures are session-scoped and which run per test, watching setup and teardown with –setup-show, how a fresh context isolates cookies and storage, new_context for several users or settings in one test, using browser for contexts you manage yourself, browser_name and the is_chromium, is_firefox and is_webkit flags, browser_channel and device, output_path, base_url, and the playwright fixture for devices and API requests.


🔗 The Fixture Chain Behind page

When a test asks for page, pytest-playwright resolves a chain of fixtures, each built on the one before:

FixtureScopeWhat it gives you
playwrightsessionThe started Playwright object (sync_playwright().start())
browser_typesessionplaywright.chromium, .firefox or .webkit, chosen by --browser
browsersessionOne launched browser, shared by all tests of that browser
new_contextfunctionA function that creates browser contexts and closes them after the test
contextfunctionA fresh BrowserContext for this test, made with new_context()
pagefunctionA new Page in that context

The two scopes are the key idea:

  • Session scope: started once for the whole run. Launching a browser takes time, so all tests share one browser process.
  • Function scope: created for each test. Every test gets a brand-new context, which is like a new incognito window: no cookies, no localStorage, no cache from other tests.

So tests are fast (one browser) and isolated (a fresh context each).

Watching the chain with –setup-show

pytest’s --setup-show option prints every fixture as it is set up (SETUP) and torn down (TEARDOWN), with its scope: S for session, F for function.

📄 tests/conftest.py

import pytest
from playwright.sync_api import Page

URL = "https://sqatools.in/automation-practice-page/"


@pytest.fixture
def practice(page: Page) -> Page:
    page.goto(URL)
    return page

⌨️ Terminal

pytest -q --setup-show tests/test_isolation.py::test_first_visit

🖥️ Output

SETUP    S pytestconfig
SETUP    S delete_output_dir (fixtures used: pytestconfig)
SETUP    S browser_type_launch_args (fixtures used: pytestconfig)
SETUP    S _pw_api_request_contexts (fixtures used: pytestconfig)
SETUP    S playwright (fixtures used: _pw_api_request_contexts)
SETUP    S browser_name (fixtures used: pytestconfig)['chromium']
SETUP    S browser_type (fixtures used: browser_name, playwright)
SETUP    S connect_options
SETUP    S launch_browser (fixtures used: browser_type, browser_type_launch_args, connect_options)
SETUP    S browser (fixtures used: launch_browser)
SETUP    S device (fixtures used: pytestconfig)
SETUP    S base_url (fixtures used: pytestconfig)
SETUP    S _pw_artifacts_folder (fixtures used: pytestconfig)
SETUP    S browser_context_args (fixtures used: _pw_artifacts_folder, base_url, device, playwright, pytestconfig)
        SETUP    F _pw_trace_api_requests
        SETUP    F output_path (fixtures used: pytestconfig)
        SETUP    F _artifacts_recorder (fixtures used: _pw_api_request_contexts, _pw_artifacts_folder, output_path, playwright, pytestconfig)
        SETUP    F new_context (fixtures used: _artifacts_recorder, browser, browser_context_args)
        SETUP    F context (fixtures used: new_context)
        SETUP    F page (fixtures used: context)
        SETUP    F practice (fixtures used: page)
        tests/test_isolation.py::test_first_visit[chromium] (fixtures used: _artifacts_recorder, _pw_api_request_contexts, _pw_artifacts_folder, _pw_trace_api_requests, base_url, browser, browser_context_args, browser_name, browser_type, browser_type_launch_args, connect_options, context, delete_output_dir, device, launch_browser, new_context, output_path, page, playwright, practice, pytestconfig, request) .
        TEARDOWN F practice
        TEARDOWN F page
        TEARDOWN F context
        TEARDOWN F new_context
        TEARDOWN F _artifacts_recorder
        TEARDOWN F output_path
        TEARDOWN F _pw_trace_api_requests
TEARDOWN S browser_context_args
TEARDOWN S _pw_artifacts_folder
TEARDOWN S base_url
TEARDOWN S device
TEARDOWN S browser
TEARDOWN S launch_browser
TEARDOWN S connect_options
TEARDOWN S browser_type
TEARDOWN S browser_name['chromium']
TEARDOWN S playwright
TEARDOWN S _pw_api_request_contexts
TEARDOWN S browser_type_launch_args
TEARDOWN S delete_output_dir
TEARDOWN S pytestconfig
1 passed, 1 warning in 1.90s

Reading the output from top to bottom:

  • The session fixtures (playwright, browser_type, browser, browser_context_args and others) are set up first. Fixtures whose names start with _pw are the plugin’s internal helpers; you never use them directly.
  • browser_name is shown with ['chromium']: pytest-playwright parametrizes every browser test by browser name, which is why test ids end in [chromium].
  • The function fixtures new_context, context and page are created just for this test, then your practice fixture runs on top of page.
  • After the test (the .), teardown runs in reverse order: first the function fixtures, and at the end of the session the browser and Playwright.

🧼 Proving Test Isolation

These two tests run one after the other in the same browser. The first one stores data in the browser; the second checks that none of it is left:

📄 tests/test_isolation.py

from playwright.sync_api import Browser, BrowserContext, Page

SEEN = {}


def test_first_visit(practice: Page, context: BrowserContext, browser: Browser):
    practice.evaluate("localStorage.setItem('cart', '3 items')")
    context.add_cookies([{"name": "session", "value": "abc123", "url": practice.url}])

    SEEN["browser"], SEEN["context"] = browser, context
    print("\nstored:", practice.evaluate("localStorage.getItem('cart')"), "| cookies:", len(context.cookies()))


def test_second_visit(practice: Page, context: BrowserContext, browser: Browser):
    print("\nleft over:", practice.evaluate("localStorage.getItem('cart')"),
          "| cookies named session:", [c for c in context.cookies() if c["name"] == "session"])
    print("same browser:", browser is SEEN["browser"], "| same context:", context is SEEN["context"])
  • SEEN is a plain module-level dictionary used only to compare objects between the two tests.
  • context.add_cookies() adds a cookie; context.cookies() lists the cookies of the context. The count of 7 includes cookies that the live site set itself while loading.
  • localStorage is read and written with page.evaluate(), which runs JavaScript in the page.

Part of the output of pytest -q -s tests (the full run is at the end of this section):

🖥️ Output

stored: 3 items | cookies: 7
.
left over: None | cookies named session: []
same browser: True | same context: False

The second test sees no localStorage value and no session cookie, because it runs in a new context. The browser object, however, is the very same one.

💡 Tip: Isolation is why Playwright tests can run in any order and in parallel. If one of your tests only passes after another test has run, it secretly depends on that test’s state: fix it with a fixture that sets up what it needs.


👥 new_context: Several Users in One Test

Some scenarios need two independent sessions at once: a buyer and a seller, an admin and a user, a chat between two people. The new_context fixture is a function: each call creates another context, using the same settings as context (browser_context_args), plus any options you pass.

📄 tests/test_new_context.py

from playwright.sync_api import Page


def test_two_users(new_context):
    alice = new_context().new_page()
    bob = new_context(locale="de-DE", viewport={"width": 800, "height": 600}).new_page()

    alice.goto("https://sqatools.in/automation-practice-page/")
    bob.goto("https://sqatools.in/automation-practice-page/")
    alice.evaluate("localStorage.setItem('user', 'alice')")

    print("\nalice sees:", alice.evaluate("localStorage.getItem('user')"))
    print("bob sees  :", bob.evaluate("localStorage.getItem('user')"))
    print("bob locale:", bob.evaluate("navigator.language"), "| bob viewport:", bob.viewport_size)


def test_page_and_extra_context(page: Page, new_context):
    # page uses one context; new_context() makes a second, independent one
    other = new_context().new_page()
    print("\ncontexts in the browser:", len(page.context.browser.contexts))
    assert page.context is not other.context
  • Each new_context() call returns a new, isolated BrowserContext.
  • Options passed to the call, such as locale or viewport, are added to the configured browser_context_args for that context only.
  • You do not close these contexts yourself: the fixture closes all of them after the test. They also get the plugin’s tracing, video and screenshot handling.

🌐 browser: Contexts You Manage Yourself

You can also call browser.new_context() directly. The difference: such a context does not use browser_context_args, does not get the plugin’s artifacts, and you must close it.

📄 tests/test_browser_fixture.py

from playwright.sync_api import Browser


def test_manual_context(browser: Browser):
    context = browser.new_context(java_script_enabled=False)
    try:
        page = context.new_page()
        page.goto("https://sqatools.in/automation-practice-page/")
        print("\nbrowser:", browser.browser_type.name, browser.version.split(".")[0], "| JS enabled:", False)
    finally:
        context.close()
  • try / finally makes sure the context is closed even if the test fails.
  • Prefer new_context in tests. Use browser.new_context() when you deliberately want a context without the project’s default settings.

⌨️ Terminal

pytest -q -s tests

🖥️ Output

browser: chromium 156 | JS enabled: False
.
stored: 3 items | cookies: 7
.
left over: None | cookies named session: []
same browser: True | same context: False
.
alice sees: alice
bob sees  : None
bob locale: de-DE | bob viewport: {'width': 800, 'height': 600}
.
contexts in the browser: 2
.
5 passed, 1 warning in 8.88s

🧭 Information Fixtures: browser_name, is_*, channel and device

These session fixtures tell a test where it runs:

FixtureValue
browser_name"chromium", "firefox" or "webkit"
is_chromium, is_firefox, is_webkitTrue or False
browser_channelThe --browser-channel value, such as "chrome", or None
deviceThe --device value, such as "iPhone 15", or None
output_pathThis test’s artifact folder inside --output (function scope)

📄 info/test_where_am_i.py

from playwright.sync_api import Page


def test_where_am_i(page: Page, browser_name, is_webkit, browser_channel, device, output_path):
    print(f"\nbrowser_name={browser_name} is_webkit={is_webkit} channel={browser_channel} device={device}")
    print("viewport:", page.viewport_size, "| iPhone user agent:", "iPhone" in page.evaluate("navigator.userAgent"))
    print("output_path ends with:", output_path.replace("\\", "/").split("/")[-1])

⌨️ Terminal

pytest -q -s info --browser firefox --device "iPhone 15"

🖥️ Output

browser_name=firefox is_webkit=False channel=None device=iPhone 15
viewport: {'width': 393, 'height': 659} | iPhone user agent: True
output_path ends with: info-test-where-am-i-py-test-where-am-i-firefox
.
1 passed, 1 warning in 2.00s
  • --device "iPhone 15" applies the device’s viewport (393 x 659), user agent and touch support to every context, through browser_context_args.
  • The iPhone descriptor names WebKit as its default browser, but --browser decides which browser actually runs. Here Firefox ran with the iPhone viewport and user agent. For the most realistic iPhone emulation, run such tests on WebKit.
  • output_path is a folder name built from the test id. Nothing is written there unless an artifact is kept.

Now run on two browsers:

⌨️ Terminal

pytest -q -s info --browser chromium --browser webkit

🖥️ Output

browser_name=chromium is_webkit=False channel=None device=None
viewport: {'width': 1280, 'height': 720} | iPhone user agent: False
output_path ends with: info-test-where-am-i-py-test-where-am-i-chromium
.
browser_name=webkit is_webkit=True channel=None device=None
viewport: {'width': 1280, 'height': 720} | iPhone user agent: False
output_path ends with: info-test-where-am-i-py-test-where-am-i-webkit
.
2 passed, 1 warning in 0.72s

Each --browser adds one parametrized copy of every test, so the test ran twice with a different browser_name.


🏠 base_url: Short URLs in Tests

The base_url fixture holds the value of --base-url (or base_url in pytest.ini, or the PYTEST_BASE_URL environment variable). pytest-playwright passes it to every context, so page.goto() accepts paths:

📄 base/test_base_url.py

from playwright.sync_api import Page, expect


def test_relative_goto(page: Page, base_url):
    print("\nbase_url:", base_url)
    page.goto("/automation-practice-page/")                 # resolved against base_url
    expect(page).to_have_url("https://sqatools.in/automation-practice-page/")

⌨️ Terminal

pytest -q -s base --base-url https://sqatools.in

🖥️ Output

base_url: https://sqatools.in
.
1 passed, 1 warning in 2.46s

📝 Note: The PytestRemovedIn10Warning you have seen in every run’s summary comes from this fixture: pytest-playwright 0.10 defines base_url as a method of a helper class, which pytest 9.2 reports as deprecated. It does not affect your tests. Silence it with a filterwarnings line in pytest.ini if it bothers you, as shown in Module 3.


🎭 The playwright Fixture

The session-scoped playwright fixture is the same object you get from sync_playwright().start(). Two things you will use it for:

📄 pw/test_playwright_fixture.py

def test_device_list(playwright):
    iphone = playwright.devices["iPhone 15"]               # a device descriptor (a dict)
    print(iphone["viewport"], iphone["default_browser_type"])

def test_api(playwright):
    api = playwright.request.new_context(base_url="https://sqatools.in")
    response = api.get("/automation-practice-page/")
    assert response.ok
    api.dispose()
  • playwright.devices contains the device descriptors used by --device. You can pass one to new_context(**playwright.devices["Pixel 7"]).
  • playwright.request creates API clients without a browser. Module 9 covers API testing in detail.

📋 All Built-in Fixtures at a Glance

FixtureScopeTypical use
pagefunctionAlmost every UI test
contextfunctionCookies, permissions, routes, a second page in the same session
new_contextfunctionSeveral independent users or per-test context options
browsersessionContexts without the default settings
browser_type, launch_browsersessionLaunching another browser yourself
playwrightsessionDevices, API request contexts
browser_context_argssessionOverride to change context settings (Lesson 6)
browser_type_launch_argssessionOverride to change launch options (headless, args, channel)
connect_optionssessionOverride to connect to a remote browser server
browser_name, browser_channel, devicesessionWhere the test runs
is_chromium, is_firefox, is_webkitsessionBrowser-specific branches
output_pathfunctionThis test’s artifact folder
base_urlsessionThe --base-url value

❓ Frequently Asked Questions

🤔 Is the browser restarted for every test?

No. The browser fixture is session-scoped, so one browser is launched per browser type for the whole run (per worker when you use pytest-xdist). Each test gets a fresh context instead, which is fast and fully isolated.

🤔 Can I make the page fixture shared between tests to save time?

You could write your own session-scoped page fixture, but tests would then share cookies and state and depend on each other’s order. Keep the per-test context; to save login time, reuse a saved login state instead (Module 8).

🤔 What is the difference between context and new_context?

context is one ready-made context for the test. new_context is a function that creates as many extra contexts as you need, with optional per-context settings. All of them are closed automatically after the test.

🤔 Why do all my test names end with [chromium]?

pytest-playwright parametrizes browser tests by browser_name. The suffix shows which browser ran that copy of the test, which matters when you pass several --browser options.


🏁 Summary

  • page sits on a chain: playwright → browser_type → browser (session) → new_context → context → page (per test).
  • One shared browser makes runs fast; a fresh context per test keeps them isolated.
  • --setup-show reveals which fixtures run and in which scope.
  • new_context creates extra isolated contexts with optional settings and closes them for you.
  • browser.new_context() skips the project settings and must be closed by you.
  • browser_name, is_*, browser_channel, device, output_path and base_url tell a test where and how it runs.

Next, you will write your own fixtures on top of these: page objects, logged-in pages, test data with cleanup and fixture factories.

🎬 Watch the Playwright tutorial series on YouTube!

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

Visual Regression Testing in Playwright Python (Screenshot Comparison)

Functional assertions check text, values and states, but they cannot see that a button turned the wrong colour or a layout broke. Visual regression testing compares screenshots with approved baseline images and fails when pixels change. Playwright for TypeScript has this built in; Playwright for Python does not, so in this lesson you will build your own screenshot comparison fixture with Pillow and pixelmatch, try a ready-made pytest plugin, and use the built-in ARIA snapshot assertion to check page structure.

📖 What You’ll Learn: how visual regression testing works (baseline, comparison, diff), why Playwright for Python has no to_have_screenshot, building an assert_snapshot fixture with Pillow and pixelmatch, threshold and allowed-difference settings, first-run baselines and an –update-snapshots option, reading the diff image, masking and other stability techniques, the pytest-playwright-visual-snapshot plugin, cloud visual testing services, and structural snapshots with to_match_aria_snapshot.


👁️ What Is Visual Regression Testing?

Imagine a CSS change that makes the “Submit” button white on a white background. Every functional test still passes: the button exists, it is enabled, its text is “Submit” and clicking it works. A user, however, cannot see it.

Visual regression testing catches this kind of bug by comparing pictures:

  1. Baseline: the first time, the test takes a screenshot and saves it as the approved image.
  2. Comparison: on later runs, it takes a new screenshot and compares it pixel by pixel with the baseline.
  3. Diff: if too many pixels differ, the test fails and saves the new image and a diff image that highlights the changes.
  4. Review: a person looks at the diff. If the change is a bug, fix the app. If it is intended (a redesign), update the baseline.

Python has no built-in to_have_screenshot

In Playwright for TypeScript, the test runner provides expect(page).toHaveScreenshot(). In Playwright for Python 1.64, there is no to_have_screenshot() assertion: expect() for pages and locators has no screenshot comparison. You have three options:

OptionGood for
Your own fixture with Pillow + pixelmatchFull control, no extra plugin, a great way to learn how it works
A pytest plugin such as pytest-playwright-visual-snapshotReady-made baseline handling and options
A cloud service (Percy, Applitools)Teams that want a web dashboard for reviewing changes

This lesson covers the first two in detail. You will also meet to_match_aria_snapshot(), a built-in Python assertion that compares the structure of the page instead of its pixels.


🧰 Building an assert_snapshot Fixture

Install the two libraries the fixture uses:

⌨️ Terminal

pip install pillow pixelmatch
  • Pillow (PIL) opens, compares and saves images.
  • pixelmatch is a Python port of the pixel comparison library used by many visual testing tools. It counts the differing pixels, ignores anti-aliasing differences at text edges, and draws a diff image.

Put the fixture in the project’s root conftest.py, so every test can use it:

📄 conftest.py

import io
import sys
from pathlib import Path

import pytest
from PIL import Image
from pixelmatch.contrib.PIL import pixelmatch

SNAPSHOT_DIR = Path("__snapshots__")          # approved baselines (commit these to Git)
RESULTS_DIR = Path("snapshot-results")        # actual and diff images of failures


def pytest_addoption(parser):
    parser.addoption(
        "--update-snapshots",
        action="store_true",
        help="Save the current screenshots as the new baselines",
    )


@pytest.fixture
def assert_snapshot(request, browser_name):
    update = request.config.getoption("--update-snapshots")

    def check(image: bytes, name: str, threshold: float = 0.1, max_diff_ratio: float = 0.0):
        __tracebackhide__ = True                # report failures at the test's line, not in here

        # One baseline per browser and operating system: rendering differs between them
        file_name = f"{name}-{browser_name}-{sys.platform}.png"
        baseline = SNAPSHOT_DIR / file_name

        # First run, or an explicit update: save the baseline
        if update or not baseline.exists():
            baseline.parent.mkdir(parents=True, exist_ok=True)
            baseline.write_bytes(image)
            if not update:
                pytest.fail(f"No baseline yet. Saved {baseline.as_posix()}: review it, then run again.")
            return

        actual = Image.open(io.BytesIO(image))
        expected = Image.open(baseline)
        if actual.size != expected.size:
            pytest.fail(f"Size changed: baseline {expected.size}, now {actual.size}")

        # Compare: returns the number of pixels that differ and draws the diff image
        diff = Image.new("RGBA", actual.size)
        different = pixelmatch(actual, expected, diff, threshold=threshold)
        ratio = different / (actual.width * actual.height)

        if ratio > max_diff_ratio:
            out = RESULTS_DIR / name
            out.mkdir(parents=True, exist_ok=True)
            actual.save(out / f"actual-{file_name}")
            expected.save(out / f"expected-{file_name}")
            diff.save(out / f"diff-{file_name}")
            pytest.fail(
                f"{different} pixels ({ratio:.2%}) differ from {baseline.as_posix()}. "
                f"See {out.as_posix()}/"
            )

    return check

Line by line:

  • pytest_addoption() adds a --update-snapshots command-line flag (you learned this hook in Module 3).
  • The fixture returns the inner function check(), so a test calls assert_snapshot(image_bytes, "name").
  • __tracebackhide__ = True is a pytest feature: it hides this helper from failure tracebacks, so the report points at the line in your test.
  • browser_name is a pytest-playwright fixture (“chromium”, “firefox” or “webkit”). Together with sys.platform (“win32”, “linux”, “darwin”) it makes the file name unique, because fonts and anti-aliasing render differently on each browser and operating system.
  • First run: there is no baseline, so the image is saved and the test fails on purpose. A missing baseline must never pass silently; a person should look at it first.
  • Size check: pixelmatch needs two images of the same size. A size change usually means the layout changed, so report it clearly.
  • pixelmatch(actual, expected, diff, threshold=0.1) compares colour by colour. threshold (0 to 1) is how different a pixel’s colour must be to count as changed: smaller is stricter.
  • max_diff_ratio is how many pixels may differ (0.0 = none, 0.01 = 1%). Raise it a little if tiny rendering noise makes a test flaky.
  • On failure, the expected, actual and diff images are saved side by side for review.

✅ A First Visual Test

The test takes a screenshot of the web table on the practice page and compares it with the baseline:

📄 tests/test_visual.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


def test_web_table_looks_right(practice: Page, assert_snapshot):
    table = practice.locator("table")
    assert_snapshot(table.screenshot(animations="disabled"), "web-table")
  • An element screenshot is much more stable than a full page: the practice page also contains ads and comments that change, but the table does not.
  • animations="disabled" stops CSS animations and transitions, so a moving element cannot cause a difference.

Run 1: no baseline yet.

⌨️ Terminal

pytest -q tests

🖥️ Output

F                                                                        [100%]
================================== FAILURES ===================================
____________________ test_web_table_looks_right[chromium] _____________________

practice = <Page url='https://sqatools.in/automation-practice-page/'>
assert_snapshot = <function assert_snapshot.<locals>.check at 0x00000218205DD380>

    def test_web_table_looks_right(practice: Page, assert_snapshot):
        table = practice.locator("table")
>       assert_snapshot(table.screenshot(animations="disabled"), "web-table")
E       Failed: No baseline yet. Saved __snapshots__/web-table-chromium-win32.png: review it, then run again.

tests\test_visual.py:13: Failed
=========================== short test summary info ===========================
FAILED tests/test_visual.py::test_web_table_looks_right[chromium] - Failed: N...
1 failed, 1 warning in 2.91s

Open __snapshots__/web-table-chromium-win32.png, check that it looks right, and commit it to Git.

Run 2: the comparison passes.

⌨️ Terminal

pytest -q tests

🖥️ Output

.                                                                        [100%]
1 passed, 1 warning in 2.73s

🔴 Catching a Visual Bug

Now simulate a developer’s CSS change that turns the table headers red. page.add_style_tag() injects a stylesheet into the page, just like a new CSS rule in the application would:

📄 demos/test_regression.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


def test_table_after_css_change(practice: Page, assert_snapshot):
    practice.add_style_tag(content="th { color: red; }")      # the "bug"
    table = practice.locator("table")
    assert_snapshot(table.screenshot(animations="disabled"), "web-table")

⌨️ Terminal

pytest -q demos

🖥️ Output

F                                                                        [100%]
================================== FAILURES ===================================
____________________ test_table_after_css_change[chromium] ____________________

practice = <Page url='https://sqatools.in/automation-practice-page/'>
assert_snapshot = <function assert_snapshot.<locals>.check at 0x00000263C481D640>

    def test_table_after_css_change(practice: Page, assert_snapshot):
        practice.add_style_tag(content="th { color: red; }")      # the "bug"
        table = practice.locator("table")
>       assert_snapshot(table.screenshot(animations="disabled"), "web-table")
E       Failed: 412 pixels (0.32%) differ from __snapshots__/web-table-chromium-win32.png. See snapshot-results/web-table/

demos\test_regression.py:14: Failed
=========================== short test summary info ===========================
FAILED demos/test_regression.py::test_table_after_css_change[chromium] - Fail...
1 failed, 1 warning in 2.77s

Every functional check on this table would still pass: the same headers, the same cells, the same text. Only the visual comparison noticed. The files involved:

🖥️ Output

__snapshots__/web-table-chromium-win32.png
snapshot-results/web-table/actual-web-table-chromium-win32.png
snapshot-results/web-table/diff-web-table-chromium-win32.png
snapshot-results/web-table/expected-web-table-chromium-win32.png
  • expected-...png is the baseline, actual-...png the new screenshot.
  • diff-...png shows the unchanged pixels faded and the changed pixels in red. In this run, only the three header words (ID, Name, Role) were red, so you see at a glance that only the header row changed. Yellow pixels at the letter edges are anti-aliasing differences that pixelmatch detected and did not count.

Accepting an intended change

When a change is intended, update the baselines and commit them:

⌨️ Terminal

pytest -q tests --update-snapshots

🖥️ Output

.                                                                        [100%]
1 passed, 1 warning in 2.46s

⚠️ Watch out: Never run --update-snapshots just to make a red build green. Look at every diff first; updating a baseline means “this is now correct”.


🎯 Making Visual Tests Stable

Visual tests fail on any pixel change, including ones that are not bugs. These techniques keep them reliable:

ProblemSolution
Dates, times, ads, avatars, random datamask=[locator, ...] in screenshot(), or style= to hide them
Animations, transitions, blinking cursoranimations="disabled"; caret="hide" is the default
Different fonts on Windows, macOS and LinuxSeparate baselines per OS (as in the fixture), or always run visual tests in the same Docker image
Different window sizesKeep the viewport fixed (pytest-playwright uses 1280 x 720)
Content still loadingWait first, for example expect(locator).to_be_visible(), before the screenshot
Large pages with many moving partsScreenshot components (a header, a card, a form) instead of the full page
Tiny rendering noiseA small max_diff_ratio, such as 0.001, or a higher threshold

For example, to mask the changing parts of a page:

image = page.screenshot(
    full_page=True,
    animations="disabled",
    mask=[page.locator(".ad-banner"), page.get_by_test_id("current-time")],
)
assert_snapshot(image, "home-page")

💡 Tip: Generate and compare baselines in one environment, usually CI. A baseline created on a Windows laptop will not match a screenshot taken on a Linux CI server, because the fonts are different.


🔌 Option 2: The pytest-playwright-visual-snapshot Plugin

If you would rather not maintain your own fixture, the pytest-playwright-visual-snapshot plugin (version 0.5.1, which needs Python 3.11 or newer) provides an assert_snapshot fixture built on the same libraries:

⌨️ Terminal

pip install pytest-playwright-visual-snapshot

📄 plugin/pytest.ini

[pytest]

📄 plugin/test_plugin_visual.py

from playwright.sync_api import Page


def test_table_with_plugin(page: Page, assert_snapshot):
    page.goto("https://sqatools.in/automation-practice-page/")
    assert_snapshot(page.locator("table"))
  • Pass a page or a locator; the plugin takes the screenshot itself, with animations disabled.
  • It also accepts threshold=, name= and mask_elements= (a list of CSS selectors).
  • Command-line options: --update-snapshots, --ignore-size-diff and --disable-visual-snapshots. Settings such as the snapshot folder and default masks can go in pytest.ini.
  • The empty pytest.ini here only makes this folder its own pytest project for the demo. In a real project, use either your own fixture or the plugin: both define --update-snapshots and assert_snapshot, so they cannot be loaded together.

The first run creates the baseline and fails; the second run compares (... marks lines removed from the long traceback):

🖥️ Output

# First run
2026-11-01T10:35:09.415283Z [info     ] structlog output capture disabled, no output directory specified [structlog_config.pytest]
.E                                                                       [100%]
=================================== ERRORS ====================================
____________ ERROR at teardown of test_table_with_plugin[chromium] ____________
...
E           Failed: [playwright-visual-snapshot] New snapshot(s) created. Please review images. C:\pwv\playwright-python-visual-testing\plugin\__snapshots__\test_plugin_visual\test_table_with_plugin\test_table_with_plugin[chromium][win32].png
...
================================= slow tests ==================================
[slow] 2.53s test_plugin_visual.py::test_table_with_plugin[chromium]
=========================== short test summary info ===========================
ERROR test_plugin_visual.py::test_table_with_plugin[chromium] - Failed: [play...
1 passed, 1 warning, 1 error in 3.09s

# Second run
.                                                                        [100%]
================================= slow tests ==================================
[slow] 2.44s test_plugin_visual.py::test_table_with_plugin[chromium]
1 passed, 1 warning in 2.97s

The baseline file it created:

🖥️ Output

plugin/__snapshots__/test_plugin_visual/test_table_with_plugin/test_table_with_plugin[chromium][win32].png

What to notice in this real output:

  • The plugin reports a new baseline or a mismatch at the end of the test, from a fixture finalizer. pytest therefore shows the test as passed plus an error at teardown, and the run still fails.
  • The structlog log line and the “slow tests” section come from structlog-config, a dependency the plugin installs, which registers its own pytest plugin.
  • Baselines go to __snapshots__/<test file>/<test name>/<test name>[browser][os].png, so they are already separated per browser and operating system.

📝 Note: On GitHub Actions (version 0.5.1 checks for the GITHUB_ACTIONS environment variable), this plugin also overwrites the baseline file in place after a mismatch, so you can download the new images from the CI artifacts. The test still fails.

Cloud visual testing services

Percy (BrowserStack) and Applitools Eyes publish Python SDKs for Playwright on PyPI (percy-playwright and eyes-playwright). They upload screenshots to a web dashboard where the team reviews and approves changes, and Applitools markets its comparison as “Visual AI” that ignores insignificant differences. Both are commercial services (check their current pricing); consider them when many people need to review visual changes.


🌳 Structural Snapshots: to_match_aria_snapshot

Pixel comparison catches visual changes. Sometimes you want something more robust: did the structure of a component change? Playwright for Python has a built-in assertion for this. to_match_aria_snapshot() compares the element’s accessibility tree, described in YAML:

📄 aria/test_aria.py

from playwright.sync_api import Page, expect


def test_table_structure(page: Page):
    page.goto("https://sqatools.in/automation-practice-page/")
    expect(page.locator("table")).to_match_aria_snapshot("""
      - table:
        - rowgroup:
          - row "ID Name Role"
        - rowgroup:
          - row "1 Deepesh Trainer"
          - row "2 Rahul Tester"
          - row "3 Anita Developer"
    """)


def test_partial_and_regex(page: Page):
    page.goto("https://sqatools.in/automation-practice-page/")
    # Only list what matters: other options and attributes may exist
    expect(page.locator("#country")).to_match_aria_snapshot("""
      - combobox:
        - option "India"
        - option /U.*/
    """)


def test_print_a_snapshot(page: Page):
    page.goto("https://sqatools.in/automation-practice-page/")
    print("\n" + page.locator("#country").aria_snapshot())

⌨️ Terminal

pytest -q -s aria

🖥️ Output

..
- combobox:
  - option "–Select Country–" [selected]
  - option "India"
  - option "USA"
  - option "UK"
  - option "Australia"
.
3 passed, 1 warning in 6.62s
  • Each line is - role "accessible name", indented to show nesting.
  • The template matches partially: you can leave out children, such as the rows’ cells and the other countries above. The children you list must appear in that order.
  • /U.*/ is a regular expression for the name; here it matched “USA”.
  • locator.aria_snapshot() returns the snapshot as text. Print it once, check it, and paste it into your test.
  • ARIA snapshots ignore colours and layout, so they do not replace pixel comparison. They are excellent for menus, tables and forms whose content and structure must not change, and they do not depend on the operating system’s fonts.

❓ Frequently Asked Questions

🤔 Does Playwright Python have toHaveScreenshot?

No. to_have_screenshot() exists only in the TypeScript/JavaScript test runner. In Python, compare screenshots with your own fixture (Pillow and pixelmatch), a pytest plugin, or a cloud service.

🤔 Where should I store baseline images?

In your Git repository, next to the tests, so a baseline change is reviewed in the same pull request as the code change that caused it.

🤔 Why does my visual test fail on CI but pass locally?

Usually different fonts or rendering on another operating system. Create the baselines in the same environment that compares them, typically a fixed Docker image in CI, and keep one baseline per browser and OS.

🤔 What threshold should I use?

Start with pixelmatch’s default threshold=0.1 and max_diff_ratio=0 (no differing pixels). Raise them only if you see real noise, and only as much as needed; a high tolerance hides real bugs.

🤔 Can I compare a full page?

Yes, with page.screenshot(full_page=True), but full pages contain more changing content. Mask dynamic parts, or prefer component screenshots for stable tests.


🏁 Summary

  • Visual regression testing compares screenshots with approved baselines and shows the differences in a diff image.
  • Playwright for Python has no to_have_screenshot(); build an assert_snapshot fixture with Pillow and pixelmatch, or use a plugin such as pytest-playwright-visual-snapshot.
  • A missing baseline should fail; --update-snapshots accepts intended changes after review.
  • Keep visual tests stable: element screenshots, masks, disabled animations, a fixed viewport, and one environment for baselines.
  • to_match_aria_snapshot() is the built-in way to check a component’s structure, independent of fonts and colours.

You have finished Module 5. Test yourself with the Module 5 mock interview, then continue to Module 6, where you will master Playwright’s pytest fixtures: browser, context and page scopes, and your own fixtures.

🎬 Watch the Playwright tutorial series on YouTube!

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

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.

📖 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

Auto-Waiting and Explicit Waits in Playwright Python

Flaky tests are almost always waiting problems: the test acts or checks before the page is ready. Playwright for Python removes most of them with auto-waiting, so you rarely write a wait yourself. In this lesson you will see exactly what Playwright waits for before each action, which explicit waits exist for the remaining cases (navigation, elements, JavaScript conditions and network responses), how to set timeouts, and why time.sleep() does not belong in a test.

📖 What You’ll Learn: actionability checks (visible, stable, receives events, enabled, editable), what happens when an element never becomes actionable, force=True, goto wait_until options (commit, domcontentloaded, load, networkidle), wait_for_url, locator.wait_for with states, page.wait_for_function, expect_response and expect_request, action, navigation and assertion timeouts and how to change them, why wait_for_timeout and time.sleep are flaky, and a simple polling helper for non-page conditions.


🤔 Why Waiting Matters

A browser does many things asynchronously: it downloads files, runs JavaScript, starts animations and waits for servers. Your test code runs much faster than all of that. If the test clicks a button before it exists, or reads a text before the server has answered, the test fails, even though a real user would have seen a working page.

Older tools solve this with waits written by hand: “sleep 3 seconds”, or “wait until the element is clickable” before every step. Playwright takes a different approach:

  1. Actions auto-wait. click(), fill(), check() and the others wait until the element is ready.
  2. Assertions retry. expect(...) keeps checking until the page matches (previous lesson).
  3. Explicit waits exist for the few cases left: navigation, network responses and custom conditions.

⚙️ Auto-Waiting: The Actionability Checks

Before an action, Playwright first finds exactly one element for the locator, then checks that the element is actionable. Which checks run depends on the action:

ActionVisibleStableReceives eventsEnabledEditable
click(), dblclick(), check(), uncheck(), set_checked(), tap()YesYesYesYes–
hover(), drag_to()YesYesYes––
fill(), clear()Yes––YesYes
select_option()Yes––Yes–
screenshot()YesYes–––
press(), press_sequentially(), focus(), set_input_files(), dispatch_event()–––––

(Source: the official Playwright Python “Auto-waiting” guide.)

What each check means:

  • Visible: the element has a size, and is not visibility:hidden or display:none. An element with opacity:0 counts as visible.
  • Stable: the element kept the same position and size for two animation frames, so it is not sliding or growing.
  • Receives events: nothing else, such as an overlay or a cookie banner, covers the point Playwright will click.
  • Enabled: no disabled attribute (also through a disabled <fieldset>), and not inside aria-disabled="true".
  • Editable: enabled and not read-only.

Seeing auto-waiting work

To show auto-waiting clearly, this test builds a tiny page with page.set_content(). The button starts disabled, and a loading overlay covers it. After one second the overlay disappears; after 1.5 seconds the button becomes enabled. The test contains no wait at all:

📄 tests/test_auto_wait.py

import time

from playwright.sync_api import Page, expect

SLOW_PAGE = """
<div id="overlay" style="position:fixed; inset:0; background:#0008">Loading...</div>
<button id="save" disabled onclick="this.textContent='Saved'">Save</button>
<script>
  setTimeout(() => document.getElementById('overlay').remove(), 1000);
  setTimeout(() => document.getElementById('save').disabled = false, 1500);
</script>
"""


def test_click_waits_until_the_button_is_ready(page: Page):
    page.set_content(SLOW_PAGE)
    start = time.monotonic()

    page.get_by_role("button", name="Save").click()     # waits for: no overlay + enabled

    print(f"\nclicked after {time.monotonic() - start:.1f} s")
    expect(page.get_by_role("button")).to_have_text("Saved")
  • page.set_content(html) loads HTML directly into the page. It is handy for demonstrating one behaviour in isolation.
  • click() kept waiting until the button was enabled and nothing covered it, then clicked.
  • In our run it printed clicked after 2.0 s, a little later than 1.5 s: Playwright re-checks the element in a loop, so it acts on a check shortly after the page becomes ready, not at the exact moment it changes.
  • time.monotonic() is used only to print how long the click waited.

When an element never becomes actionable

If the checks do not pass within the action timeout, the action fails with a TimeoutError that explains what it was waiting for. Here the overlay never goes away:

📄 demos/test_blocked_click.py

from playwright.sync_api import Page

BLOCKED = """
<div id="cookie-banner" style="position:fixed; inset:0; background:#0008">We use cookies</div>
<button>Buy now</button>
"""


def test_button_covered_by_a_banner(page: Page):
    page.set_content(BLOCKED)
    page.get_by_role("button", name="Buy now").click(timeout=3_000)

⌨️ Terminal

pytest -q demos/test_blocked_click.py

🖥️ Output

E           playwright._impl._errors.TimeoutError: Locator.click: Timeout 3000ms exceeded.
E           Call log:
E             - waiting for get_by_role("button", name="Buy now")
E               - locator resolved to <button>Buy now</button>
E             - attempting click action
E               2 × waiting for element to be visible, enabled and stable
E                 - element is visible, enabled and stable
E                 - scrolling into view if needed
E                 - done scrolling
E                 - <div id="cookie-banner">We use cookies</div> intercepts pointer events
E               - retrying click action
E               - waiting 20ms
E               2 × waiting for element to be visible, enabled and stable
E                 - element is visible, enabled and stable
E                 - scrolling into view if needed
E                 - done scrolling
E                 - <div id="cookie-banner">We use cookies</div> intercepts pointer events
E               - retrying click action
E                 - waiting 100ms
E               5 × waiting for element to be visible, enabled and stable
E                 - element is visible, enabled and stable
E                 - scrolling into view if needed
E                 - done scrolling
E                 - <div id="cookie-banner">We use cookies</div> intercepts pointer events
E               - retrying click action
E                 - waiting 500ms
E               - waiting for element to be visible, enabled and stable

..\_venv0\Lib\site-packages\playwright\_impl\_connection.py:636: TimeoutError
=========================== short test summary info ===========================
FAILED demos/test_blocked_click.py::test_button_covered_by_a_banner[chromium]
1 failed, 1 warning in 3.64s
  • The call log is your best debugging tool. Here it says the <div id="cookie-banner"> intercepts pointer events, so the fix is to close the banner first, not to add a sleep.
  • timeout=3_000 limits this one action to 3 seconds. Without it, the default action timeout applies (30 seconds in pytest-playwright).

⚠️ Watch out: click(force=True) skips the “receives events” and other non-essential checks. It makes this test pass, but a real user could not click that button. Use force=True only when you understand why the check fails and it does not matter for the test.


🧭 Waiting for Navigation: wait_until

page.goto() waits for the page to load before it returns. The wait_until argument decides what “loaded” means:

wait_untilgoto() returns when…
"commit"The server’s response arrived and the document started loading
"domcontentloaded"The HTML is parsed (the DOMContentLoaded event)
"load" (default)The page and all its resources, such as images, styles and scripts, have loaded
"networkidle"No network request for 500 ms (discouraged by Playwright)

📄 tests/test_navigation.py

import time

from playwright.sync_api import Page, expect

URL = "https://sqatools.in/automation-practice-page/"


def test_wait_until_options(page: Page):
    for state in ["commit", "domcontentloaded", "load"]:
        start = time.monotonic()
        response = page.goto(URL, wait_until=state)
        print(f"\n{state:>16}: {time.monotonic() - start:.2f} s, status {response.status}")

    # Whatever wait_until you choose, check readiness with an assertion
    expect(page.get_by_role("heading", name="Text Fields")).to_be_visible()

One run printed these timings (the full run at the end of the lesson shows another; the numbers vary):

🖥️ Output

          commit: 0.14 s, status 200
domcontentloaded: 0.17 s, status 200
            load: 2.61 s, status 200
  • goto() returns the main Response, so you can check its status code.
  • The numbers change from run to run, but the order is always the same: commit is the earliest, load the latest.
  • "load" waits for every image, font and third-party script. Some sites load slow third-party resources: while preparing this course, the load event of a public demo site sometimes took longer than the 30-second navigation timeout. When that happens, use wait_until="domcontentloaded" and then wait for the element you actually need with expect().

📝 Note: Why is "networkidle" discouraged? Many modern pages never become idle: analytics, chat widgets and polling keep sending requests. Your test then waits until the timeout. Wait for something the user can see instead.

After a click that navigates: wait_for_url

goto() waits by itself. But when a click starts the navigation, wait for the new URL:

📄 tests/test_navigation.py

def test_click_then_wait_for_url(page: Page):
    page.set_content(f'<a href="{URL}">Open the practice page</a>')
    page.get_by_role("link", name="Open the practice page").click()

    page.wait_for_url("**/automation-practice-page/")     # glob pattern
    expect(page).to_have_url(URL)                       # the assertion form also waits
    expect(page).to_have_title("Automation Practice Page - SQA Tools")
  • wait_for_url() accepts an exact URL, a glob (** matches any characters, including /), a regex or a function.
  • expect(page).to_have_url(...) waits too. Prefer it when you also want the check to appear as an assertion in your test.
  • wait_for_url() also takes wait_until=, so you can return as soon as the new URL has committed.

🔍 Waiting for an Element: locator.wait_for()

Sometimes you need to wait for an element without acting on it or asserting it, for example before reading a list of values. locator.wait_for() waits for one of four states:

state=Waits until the element…
"visible" (default)is in the DOM and visible
"hidden"is not visible, or not in the DOM
"attached"is in the DOM (visible or not)
"detached"is removed from the DOM

The practice page has an auto-suggest box: typing into the search field shows matching suggestions below it.

📄 tests/test_locator_wait.py

from playwright.sync_api import Page


def test_wait_for_suggestions(page: Page):
    page.goto("https://sqatools.in/automation-practice-page/")
    search = page.locator("#searchBox")
    suggestions = page.locator("#suggestions div")

    search.press_sequentially("pl")
    suggestions.first.wait_for()                       # state="visible" by default
    print("\nsuggestions:", suggestions.all_inner_texts())

    search.fill("")                                    # empty input hides the box
    page.locator("#suggestions").wait_for(state="hidden")
    print("box hidden, suggestion items left:", suggestions.count())
  • all_inner_texts() does not wait; it returns whatever is in the page right now. That is why the test waits for the first suggestion before reading the list.
  • wait_for() is strict: if the locator matches several elements, it raises a strict mode violation for any state. That is why this test uses .first.
  • Typing “pl” matches only “Playwright” in the page’s suggestion list, so one item appears.
  • count() does not wait either. The page empties the box when the input is cleared, so the count is 0 after the box is hidden.

💡 Tip: If you can express the wait as an assertion, do: expect(suggestions).to_have_count(2) waits and checks in one line. Use wait_for() when you only need the page to be ready before reading data.


🧮 Waiting for a JavaScript Condition: wait_for_function

When the condition is easier to describe in JavaScript, page.wait_for_function() runs your expression in the browser again and again until it returns a truthy value:

📄 tests/test_wait_function.py

import time

from playwright.sync_api import Page


def test_wait_for_ajax_text(page: Page):
    page.goto("https://sqatools.in/automation-practice-page/")
    page.get_by_role("button", name="Load Success").click()

    start = time.monotonic()
    page.wait_for_function(
        "() => document.querySelector('#loader').innerText.includes('loaded')"
    )
    print(f"\nloaded after {time.monotonic() - start:.1f} s:", page.locator("#loader").inner_text())
  • The expression runs inside the page, so it can read anything JavaScript can: element text, global variables, localStorage and more.
  • By default it re-checks on every animation frame. Pass polling=500 to check every 500 ms instead.
  • Pass Python values to the function with arg=: page.wait_for_function("n => document.querySelectorAll('tr').length >= n", arg=4).

🌐 Waiting for Network Traffic: expect_response

To wait for a specific request or response, wrap the action that triggers it in a with block:

📄 tests/test_network_wait.py

from playwright.sync_api import Page


def test_wait_for_the_page_response(page: Page):
    with page.expect_response("**/automation-practice-page/") as response_info:
        page.goto("https://sqatools.in/automation-practice-page/")

    response = response_info.value
    print(f"\n{response.status} {response.request.method} {response.url}")
    assert response.ok


def test_wait_for_a_request(page: Page):
    with page.expect_request(lambda r: r.resource_type == "document") as request_info:
        page.goto("https://sqatools.in/automation-practice-page/")

    print("\nrequest:", request_info.value.method, request_info.value.resource_type)
  • Start waiting before the action: the with block registers the listener, then the action runs inside it. If the response came first, a wait started afterwards could miss it.
  • response_info.value holds the Response once the block ends.
  • The matcher can be a URL glob, a regex or a function that receives the response (or request).
  • In a real application, use this for the API call behind a button: with page.expect_response("**/api/orders") as r: page.get_by_role("button", name="Place order").click(). Module 8 covers network testing in depth.

⏱️ Timeouts: Actions, Navigation and Assertions

WhatDefaultChange it with
Actions (click, fill, …) and wait_for*30 spage.set_default_timeout() or timeout=
Navigation (goto, wait_for_url, reload, …)30 spage.set_default_navigation_timeout() or timeout=
expect(...) assertions5 sexpect.set_options(timeout=...) or timeout=

All values are in milliseconds. set_default_navigation_timeout() takes priority over set_default_timeout() for navigation methods. The same two methods also exist on the browser context.

To change them for every test, override the page fixture in conftest.py. A fixture with the same name as the plugin’s fixture can request it and adjust it:

📄 timeouts/conftest.py

import pytest
from playwright.sync_api import Page


@pytest.fixture
def page(page: Page) -> Page:
    page.set_default_timeout(10_000)               # actions and waits: 10 s
    page.set_default_navigation_timeout(45_000)    # goto, reload, wait_for_url: 45 s
    return page

📄 timeouts/test_timeouts.py

from playwright.sync_api import Page, TimeoutError


def test_default_timeout_applies(page: Page):
    page.goto("https://sqatools.in/automation-practice-page/")
    try:
        page.locator("#doesNotExist").click(timeout=1_000)   # timeout= overrides the default
    except TimeoutError as error:
        print("\n" + str(error).splitlines()[0])

⌨️ Terminal

pytest -q -s timeouts

🖥️ Output

Locator.click: Timeout 1000ms exceeded.
.
1 passed, 1 warning in 3.11s
  • TimeoutError is imported from playwright.sync_api; it is not Python’s built-in TimeoutError.
  • Catching it is useful in helpers (for example, “close the promo popup if it appears”), but in a normal test let it fail so you see the call log.
  • Pass timeout=0 to wait without a limit. Avoid it in tests: a stuck test then never finishes.

🚫 Hard Waits: wait_for_timeout and time.sleep

page.wait_for_timeout(3_000)   # waits 3 s, always

A fixed wait is always wrong in one direction: on a fast run it wastes time, and on a slow run (a busy CI server) it is too short and the test fails. The Playwright documentation states that wait_for_timeout() should only be used for debugging.

time.sleep() is worse in the sync API: it blocks the Python thread, so Playwright cannot process browser events during the sleep. This test proves it with a console message the page logs after 200 ms:

📄 tests/test_sleep_vs_wait.py

import time

from playwright.sync_api import Page


def test_events_are_not_processed_during_sleep(page: Page):
    messages = []
    page.on("console", lambda msg: messages.append(msg.text))
    page.evaluate("setTimeout(() => console.log('hello'), 200)")

    time.sleep(1)                           # 1 s passes, but no event is handled
    print("\nafter time.sleep:", messages)

    page.wait_for_timeout(10)               # Playwright gets control and delivers the event
    print("after wait_for_timeout:", messages)

🖥️ Output

after time.sleep: []
after wait_for_timeout: ['hello']

The message was logged during the sleep, but your page.on("console") handler only ran once Playwright had control again. The same applies to dialog handlers and other events. If you must pause while debugging, use page.wait_for_timeout(), then replace it with a real condition:

Instead of a hard wait for…Use
An element to appearexpect(locator).to_be_visible()
A text to changeexpect(locator).to_have_text("...")
A new page after a clickpage.wait_for_url(...) or expect(page).to_have_url(...)
A server call to finishwith page.expect_response(...)
A spinner to go awayexpect(spinner).to_be_hidden()

🔁 Polling a Non-Page Condition

The TypeScript test runner has expect.poll() and toPass() for retrying arbitrary code. Playwright for Python does not have them: expect() accepts only a Page, Locator or APIResponse. For a condition outside the page, such as a file appearing on disk or a record reaching a database, write a small helper:

📄 tests/helpers.py

import time
from typing import Callable


def wait_until(condition: Callable[[], bool], timeout: float = 10.0, interval: float = 0.25) -> None:
    """Call condition() until it returns True, or raise after timeout seconds."""
    deadline = time.monotonic() + timeout
    while time.monotonic() < deadline:
        if condition():
            return
        time.sleep(interval)
    raise AssertionError(f"Condition not met within {timeout} s")

📄 tests/test_polling.py

import threading
from pathlib import Path

from helpers import wait_until


def test_wait_for_a_file(tmp_path: Path):
    report = tmp_path / "report.csv"

    # Something else creates the file after 1 second (here: a timer thread)
    threading.Timer(1.0, report.write_text, args=["id,status\n1,passed\n"]).start()

    wait_until(report.exists, timeout=5)
    assert report.read_text().startswith("id,status")
  • This test uses no browser, so time.sleep() inside the helper is fine. Do not use this helper for page content; expect() already does it better.
  • from helpers import wait_until works because pytest adds the test’s folder (with no __init__.py) to sys.path in its default import mode.

⌨️ Terminal

pytest -q -s tests

🖥️ Output

clicked after 2.0 s
.
suggestions: ['Playwright']
box hidden, suggestion items left: 0
.
          commit: 0.14 s, status 200

domcontentloaded: 0.17 s, status 200

            load: 1.85 s, status 200
..
200 GET https://sqatools.in/automation-practice-page/
.
request: GET document
.
after time.sleep: []
after wait_for_timeout: ['hello']
.
loaded after 3.0 s: Data loaded successfully!
..
9 passed, 1 warning in 22.57s

❓ Frequently Asked Questions

🤔 Do I need wait_for_selector in Playwright Python?

Rarely. page.wait_for_selector() still works, but the recommended style is locators: actions auto-wait, expect() waits for states, and locator.wait_for() covers the rest.

🤔 Why does my test pass locally but time out in CI?

CI machines are often slower and may have a different network. Look at the call log: it tells you what Playwright was waiting for. Fix the cause (an overlay, a slow third-party script, a missing test user) rather than raising every timeout.

🤔 What is the default timeout in Playwright Python?

With pytest-playwright, actions and navigation have a 30-second default, and expect() assertions 5 seconds. You can change each one, as shown in the timeouts section.

🤔 Is wait_until=”networkidle” a good idea for single-page apps?

Usually not. Playwright marks it as discouraged because pages with polling, analytics or websockets may never be idle. Wait for a visible result with expect() instead.

🤔 Does fill() wait for the field to be enabled?

Yes. fill() waits until the element is visible, enabled and editable. If the field stays disabled, it fails with a TimeoutError whose call log says “element is not enabled”.


🏁 Summary

  • Actions auto-wait for actionability: visible, stable, receiving events, enabled and editable, depending on the action.
  • The call log of a TimeoutError tells you exactly which check failed.
  • goto() waits for load by default; domcontentloaded helps with slow third-party resources. Avoid networkidle.
  • Use wait_for_url() after clicks that navigate, locator.wait_for() before reading data, wait_for_function() for JavaScript conditions and expect_response() for network calls.
  • Change timeouts with set_default_timeout(), set_default_navigation_timeout(), expect.set_options() or timeout=.
  • Replace wait_for_timeout() and time.sleep() with a real condition.

Next, you will capture evidence when a test fails: screenshots, videos and traces.

🎬 Watch the Playwright tutorial series on YouTube!

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

Playwright Python Assertions with expect (Web-First Assertions)

A test is only as good as its checks. Playwright for Python ships a dedicated assertion library, expect, built for web pages that change over time: every expect check keeps retrying until the page reaches the expected state or a timeout runs out. In this lesson you will use every important assertion on the SQA Tools practice page, see why a plain assert can be flaky on a dynamic page, and learn negation, custom messages, timeouts and soft assertions.

📖 What You’ll Learn: what web-first assertions are and how they retry, page assertions (to_have_title, to_have_url), visibility and state assertions, text assertions (exact, partial, regex, lists, ignore_case), value, attribute, class, CSS, id and count assertions, accessibility assertions, API response assertions, not_ negation, custom failure messages, per-assertion and global timeouts, soft assertions with expect.soft, and when a plain assert is the right tool.


🎯 What Is a Web-First Assertion?

An assertion checks that the application is in the state you expect. If the check fails, the test fails.

Python already has the assert statement, and pytest makes it powerful (you used it in Module 3). So why does Playwright add its own expect function?

Because web pages change after your action. Click “Load Success” on the practice page, and the text below the button shows “Loading…” first. It changes to “Data loaded successfully!” 3 seconds later. A check that reads the text once, right after the click, sees the wrong text and fails, even though the page works perfectly.

expect() solves this. It retries the check again and again until it passes or the timeout runs out (5 seconds by default). That is what “web-first” means: the assertion waits for the page, like a human tester would.

from playwright.sync_api import expect

expect(page.locator("#loader")).to_have_text("Data loaded successfully!")
  • expect(...) takes a Locator, a Page or an APIResponse.
  • It returns an assertions object with methods such as to_have_text(), to_be_visible() and to_have_url().
  • Each method retries until it passes, or raises AssertionError after the timeout.

All examples below run against the SQA Tools practice page. Every test receives the opened page from this fixture:

📄 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

📄 Page Assertions: Title and URL

📄 tests/test_page_assertions.py

import re

from playwright.sync_api import Page, expect


def test_title_and_url(practice: Page):
    # Exact title
    expect(practice).to_have_title("Automation Practice Page - SQA Tools")

    # Part of the title, with a regular expression
    expect(practice).to_have_title(re.compile("Practice Page"))

    # Exact URL, then a URL pattern
    expect(practice).to_have_url("https://sqatools.in/automation-practice-page/")
    expect(practice).to_have_url(re.compile(r"/automation-practice-page/$"))
  • Pass the page itself (not a locator) to expect() for page-level checks.
  • A plain string must match exactly. Use re.compile(...) when only part of the value matters, for example a URL that contains an id or a query string.
  • to_have_url() is the right check after a click that navigates: it waits for the new URL instead of reading page.url too early.

👀 Visibility and Presence

📄 tests/test_locator_assertions.py

import re

from playwright.sync_api import Page, expect


def test_visibility(practice: Page):
    # Visible on screen (has a size, not display:none or visibility:hidden)
    expect(practice.locator("#username")).to_be_visible()

    # The hidden field exists in the HTML but has style="display:none;"
    hidden = practice.locator("#hiddenField")
    expect(hidden).to_be_hidden()
    expect(hidden).to_be_attached()          # present in the DOM, even though hidden

    # An element that does not exist at all
    missing = practice.locator("#doesNotExist")
    expect(missing).not_to_be_attached()
    expect(missing).to_be_hidden()           # "hidden" also passes when nothing matches

    # In the viewport: the "Bottom of Page" heading is far below the first screen
    bottom = practice.get_by_role("heading", name="Bottom of Page")
    expect(bottom).not_to_be_in_viewport()
    bottom.scroll_into_view_if_needed()
    expect(bottom).to_be_in_viewport()
AssertionPasses when
to_be_visible()The element is rendered and has a size
to_be_hidden()The element is invisible or does not exist
to_be_attached()The element is in the DOM (visible or not)
to_be_in_viewport()The element is inside the visible part of the window

📝 Note: to_be_hidden() passing for a missing element is by design: the user cannot see it either way. If you need to prove an element is *in the HTML but hidden*, combine to_be_attached() with to_be_hidden(), as the test above does.


🔘 Element State: Enabled, Editable, Checked, Focused, Empty

📄 tests/test_locator_assertions.py

def test_states(practice: Page):
    expect(practice.locator("#disabledInput")).to_be_disabled()

    username = practice.locator("#username")
    expect(username).to_be_enabled()
    expect(username).to_be_editable()
    expect(username).to_be_empty()           # an input with no value

    username.fill("qa_learner")
    expect(username).not_to_be_empty()

    username.focus()
    expect(username).to_be_focused()

    # Checkboxes and radio buttons
    practice.locator("#java").check()
    expect(practice.locator("#java")).to_be_checked()
    expect(practice.locator("#python")).not_to_be_checked()
    expect(practice.locator("#python")).to_be_checked(checked=False)   # same as the line above
  • to_be_empty() works for inputs (no value) and for other elements (no text).
  • to_be_editable() fails for disabled and read-only fields.
  • to_be_checked(checked=False) is another way to write not_to_be_checked(); it is handy when the expected state comes from a variable.

📝 Text Assertions

Text is what you check most often, and there are two main methods:

  • to_have_text() – the element’s whole text must match.
  • to_contain_text() – the expected text must appear somewhere in the element’s text.

📄 tests/test_locator_assertions.py

def test_text(practice: Page):
    submit = practice.locator("#submitButton")

    # Exact (whitespace at the ends is ignored) and partial
    expect(submit).to_have_text("Submit")
    expect(practice.locator(".drag")).to_contain_text("Drag Me")

    # Case-insensitive
    expect(submit).to_have_text("SUBMIT", ignore_case=True)

    # Regular expression: a heading that starts with "Ultimate"
    expect(practice.locator("h1").nth(1)).to_have_text(re.compile(r"^Ultimate"))

    # A list checks several elements at once, in order
    first_row_cells = practice.locator("table tbody tr").first.locator("td")
    expect(first_row_cells).to_have_text(["1", "Deepesh", "Trainer"])

    # The "Name" column contains these names, in this order
    names = practice.locator("table tbody td:nth-child(2)")
    expect(names).to_contain_text(["Deep", "Rah", "Ani"])
  • With a list, to_have_text() requires the locator to match exactly that many elements, each with the matching text, in that order. It is the fastest way to verify a table row or a menu.
  • to_contain_text() with a list checks each element in order for the partial text.
  • Earlier lessons used to_contain_text() on the drag box because an advertising script inserts a “Try Cloud Tools” link inside it on the live site. When a page can contain extra content, check only the part that matters.

💡 Tip: Do not compare text with assert locator.inner_text() == "...". That reads the text once. expect(locator).to_have_text("...") retries, normalises whitespace and prints a clear diff on failure.


🧾 Values, Attributes, Classes, CSS and Count

📄 tests/test_locator_assertions.py

def test_values_and_attributes(practice: Page):
    # Input values
    username = practice.locator("#username")
    username.fill("qa_learner")
    expect(username).to_have_value("qa_learner")
    expect(practice.locator("#address")).to_have_value("Enter address")   # pre-filled textarea

    # Dropdowns: one value, or several for a multi-select
    practice.locator("#country").select_option("uk")
    expect(practice.locator("#country")).to_have_value("uk")
    practice.locator("#skills").select_option(["Python", "Playwright"])
    expect(practice.locator("#skills")).to_have_values(["Python", "Playwright"])

    # Attributes
    expect(username).to_have_attribute("placeholder", "Enter username")
    expect(practice.locator("#googleLink")).to_have_attribute("target", "_blank")
    expect(practice.locator("#googleLink")).to_have_attribute("href", re.compile("google"))

    # id, class, CSS and count
    expect(practice.locator(".drag")).to_have_id("drag1")
    expect(practice.locator(".drag")).to_have_class("drag")
    expect(practice.locator(".drop")).to_contain_class("drop")
    expect(practice.locator("#hiddenField")).to_have_css("display", "none")
    expect(practice.locator("table tbody tr")).to_have_count(3)

    # A DOM property (not an HTML attribute)
    expect(practice.locator("#disabledInput")).to_have_js_property("disabled", True)
AssertionChecks
to_have_value(v)The current value of an input, textarea or single select
to_have_values([..])The selected options of a multi-select
to_have_attribute(name, v)An HTML attribute (string or regex)
to_have_id(id)The id attribute
to_have_class(c)The full class attribute (string, regex or list)
to_contain_class(c)One or more classes are present, in any order
to_have_css(prop, v)A computed CSS value
to_have_count(n)How many elements the locator matches
to_have_js_property(name, v)A JavaScript property of the element
  • to_have_class("drag") compares the whole class attribute. If an element has class="btn btn-primary", to_have_class("btn") fails, while to_contain_class("btn") passes.
  • to_have_css() checks the computed style, so it also covers styles that come from a stylesheet, not just the style attribute.
  • to_have_count(0) is a clear way to say “no such element”, for example “no error messages”.

♿ Accessibility Assertions

These check what a screen reader would announce. They are also a quick way to keep your role-based locators honest:

📄 tests/test_locator_assertions.py

def test_accessibility(practice: Page):
    hover = practice.locator("#hoverButton")
    expect(hover).to_have_role("button")
    expect(hover).to_have_accessible_name("Hover Over Me")

    # A checkbox gets its accessible name from its <label for="java">
    expect(practice.locator("#java")).to_have_accessible_name("Java")

Playwright also has to_have_accessible_description() and to_have_accessible_error_message() for form fields that use aria-describedby or aria-errormessage.


🌐 API Response Assertions

expect() also accepts an APIResponse. You will use it a lot in the API testing module; here is a first taste:

📄 tests/test_api_assertion.py

from playwright.sync_api import Page, expect


def test_page_responds(page: Page):
    response = page.request.get("https://sqatools.in/automation-practice-page/")
    expect(response).to_be_ok()        # status code 200-299
    assert response.status == 200
  • page.request sends HTTP requests that share the browser context’s cookies.
  • to_be_ok() passes for status codes 200 to 299. On failure, it prints the response details.

Run all the passing tests:

⌨️ Terminal

pytest -q tests

🖥️ Output

........                                                                 [100%]
8 passed, 1 warning in 18.62s

⏳ Why a Plain assert Can Be Flaky

Here is the “Load Success” button checked two ways. First, the right way:

📄 tests/test_retry.py

from playwright.sync_api import Page, expect


def test_expect_waits_for_the_ajax_result(practice: Page):
    practice.get_by_role("button", name="Load Success").click()

    loader = practice.locator("#loader")
    expect(loader).to_have_text("Loading...")
    expect(loader).to_have_text("Data loaded successfully!")   # arrives after 3 s; expect waits

Now the same check with a plain assert:

📄 demos/test_plain_assert.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


def test_plain_assert_reads_too_early(practice: Page):
    practice.get_by_role("button", name="Load Success").click()

    # inner_text() reads the text ONCE, immediately after the click
    assert practice.locator("#loader").inner_text() == "Data loaded successfully!"

⌨️ Terminal

pytest -q demos/test_plain_assert.py

🖥️ Output

F                                                                        [100%]
================================== FAILURES ===================================
_________________ test_plain_assert_reads_too_early[chromium] _________________

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

    def test_plain_assert_reads_too_early(practice: Page):
        practice.get_by_role("button", name="Load Success").click()
    
        # inner_text() reads the text ONCE, immediately after the click
>       assert practice.locator("#loader").inner_text() == "Data loaded successfully!"
E       AssertionError: assert 'Loading...' == 'Data loaded successfully!'
E         
E         - Data loaded successfully!
E         + Loading...

demos\test_plain_assert.py:15: AssertionError
=========================== short test summary info ===========================
FAILED demos/test_plain_assert.py::test_plain_assert_reads_too_early[chromium]
1 failed, 1 warning in 2.72s

The page is fine; the test is wrong. inner_text() returned “Loading…” because it ran before the 3-second timer finished.

So when is a plain assert right? When the value is already final and does not depend on the page changing:

  • Python values you calculated: assert total == 300.
  • An API response you already received: assert response.status == 200.
  • Values after a web-first assertion has already waited for the page to settle.

💬 Custom Messages and Timeouts

Pass a second argument to expect() to explain what failed in business terms, and timeout= (milliseconds) to change how long one assertion waits:

📄 demos/test_message.py

import pytest
from playwright.sync_api import Page, expect


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


def test_ajax_within_two_seconds(practice: Page):
    practice.get_by_role("button", name="Load Success").click()

    expect(
        practice.locator("#loader"),
        "AJAX data should load within 2 seconds",
    ).to_have_text("Data loaded successfully!", timeout=2_000)

⌨️ Terminal

pytest -q demos/test_message.py

🖥️ Output

E       AssertionError: AJAX data should load within 2 seconds
E       Expected value: 'Data loaded successfully!'
E       Actual value: Loading... 
E       Call log:
E         - Expect "to_have_text" locator("#loader") with timeout 2000ms
E         - waiting for locator("#loader")
E           20 × locator resolved to <p id="loader">Loading...</p>
E              - unexpected value "Loading..."
E       
E       Aria snapshot:
E       - paragraph: Loading...

demos\test_message.py:17: AssertionError
=========================== short test summary info ===========================
FAILED demos/test_message.py::test_ajax_within_two_seconds[chromium] - Assert...
1 failed, 1 warning in 3.99s

Read the failure from top to bottom:

  • Your message comes first, then the expected and actual values.
  • The call log shows each retry: Playwright kept finding #loader with the text “Loading…” until the 2 seconds ran out.
  • The real data arrives after 3 seconds, so a 2-second limit fails. Choose timeouts from real requirements, not by guessing.

Changing the default timeout for all assertions

The default is 5 seconds. To change it for the whole suite, call expect.set_options() once, for example in conftest.py:

📄 global_timeout/conftest.py

from playwright.sync_api import expect

# Every expect(...) in this suite now waits up to 10 seconds by default
expect.set_options(timeout=10_000)

📄 global_timeout/test_slow_page.py

from playwright.sync_api import Page, expect


def test_uses_the_global_timeout(page: Page):
    page.goto("https://sqatools.in/automation-practice-page/")
    page.get_by_role("button", name="Load Success").click()
    expect(page.locator("#loader")).to_have_text("Data loaded successfully!")
  • A timeout= argument on a single assertion still overrides the global value.
  • expect.set_options() affects only assertions. Action timeouts (click, fill) are set on the page or context; you will see them in the next lesson.

🧺 Soft Assertions: Collect Failures, Keep Going

A normal assertion stops the test at the first failure. Sometimes you want to check several independent things on one page and see every problem in a single run, for example all the labels of a form. That is what expect.soft() does:

📄 demos/test_soft.py

import pytest
from playwright.sync_api import Page, expect


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


def test_form_labels(practice: Page):
    # Each soft check records a failure and lets the test continue
    expect.soft(practice.locator("#submitButton")).to_have_text("Send", timeout=1_000)
    expect.soft(practice.locator("#normalButton")).to_have_text("Normal Button")
    expect.soft(practice.locator("#username")).to_have_attribute(
        "placeholder", "Your username", timeout=1_000
    )

    print("\nThe test body reached its last line")

⌨️ Terminal

pytest -q demos/test_soft.py

🖥️ Output

_________________________ test_form_labels[chromium] __________________________
  + Exception Group Traceback (most recent call last):
  |   File "C:\pwv\_venv0\Lib\site-packages\pytest_playwright\pytest_playwright.py", line 137, in pytest_runtest_call
  |     raise _BaseExceptionGroup("Soft assertion failures", errors)
  | ExceptionGroup: Soft assertion failures (2 sub-exceptions)
  +-+---------------- 1 ----------------
    | AssertionError: Locator expected to have text 'Send'
    | Actual value: Submit 
    | Call log:
    |   - Expect "to_have_text" locator("#submitButton") with timeout 1000ms
    |   - waiting for locator("#submitButton")
    |     11 × locator resolved to <button type="submit" id="submitButton">Submit</button>
    |        - unexpected value "Submit"
    | 
    | Aria snapshot:
    | - button "Submit"
    +---------------- 2 ----------------
    | AssertionError: Locator expected to have attribute 'Your username'
    | Actual value: Enter username 
    | Call log:
    |   - Expect "to_have_attribute" locator("#username") with timeout 1000ms
    |   - waiting for locator("#username")
    |     10 × locator resolved to <input type="text" id="username" name="username" placeholder="Enter username"/>
    |        - unexpected value "Enter username"
    | 
    | Aria snapshot:
    | - textbox "Enter username"
    +------------------------------------
---------------------------- Captured stdout call -----------------------------

The test body reached its last line
=========================== short test summary info ===========================
FAILED demos/test_soft.py::test_form_labels[chromium] - ExceptionGroup: Soft ...
1 failed, 1 warning in 4.47s
  • The test ran to the end (the print output appears under “Captured stdout call”), then failed with both problems reported together.
  • With two or more soft failures, pytest-playwright raises an ExceptionGroup named “Soft assertion failures”. With one, it raises that single AssertionError.
  • expect.soft() needs the pytest-playwright plugin (0.7.3 or later), which collects the failures for each test. Outside pytest-playwright, use a normal expect().

⚠️ Watch out: Use soft assertions for independent checks only. If a later step depends on an earlier check (for example, you must be logged in before checking the dashboard), use a normal expect() so the test stops at the real cause.


📚 Quick Reference

CategoryAssertions
Pageto_have_title, to_have_url
Visibilityto_be_visible, to_be_hidden, to_be_attached, to_be_in_viewport
Stateto_be_enabled, to_be_disabled, to_be_editable, to_be_checked, to_be_focused, to_be_empty
Textto_have_text, to_contain_text
Valuesto_have_value, to_have_values
Attributesto_have_attribute, to_have_id, to_have_class, to_contain_class, to_have_css, to_have_js_property
Countto_have_count
Accessibilityto_have_role, to_have_accessible_name, to_have_accessible_description, to_have_accessible_error_message
Structureto_match_aria_snapshot (covered in the visual testing lesson)
APIto_be_ok

Every assertion has a not_ version, such as not_to_be_visible(), and accepts timeout=.


❓ Frequently Asked Questions

🤔 What is the difference between to_have_text and to_contain_text?

to_have_text() requires the whole text of the element to match (string or regex). to_contain_text() passes if the expected string appears anywhere in the text. Both ignore leading and trailing whitespace and retry until the timeout.

🤔 Should I write not_to_be_visible() or to_be_hidden()?

They are equivalent for a single element: both pass when the element is invisible or missing. Pick the one that reads better in your test.

🤔 Why does my assertion take 5 seconds before failing?

That is the default assertion timeout: expect keeps retrying in case the page is still updating. If 5 seconds is too long or too short, pass timeout= to that assertion or call expect.set_options(timeout=...) once.

🤔 Can I use expect with plain Python values, like numbers or lists?

No. expect() accepts a Page, a Locator or an APIResponse. For plain Python values, use pytest’s assert, which shows a detailed comparison on failure.

🤔 Is there a to_have_screenshot assertion in Python?

Not in Playwright for Python 1.64. The TypeScript test runner has one, but Python does not. The visual testing lesson in this module shows how to do screenshot comparison in Python.


🏁 Summary

  • expect() assertions retry until the page matches or the timeout (5 s by default) runs out; this removes most flaky waits.
  • Use page assertions for title and URL, and locator assertions for visibility, state, text, values, attributes, CSS and count.
  • to_have_text matches the whole text; to_contain_text a part. Lists check several elements in order.
  • Every assertion has a not_ form; add a custom message as the second argument of expect() and timeout= per check, or expect.set_options() globally.
  • expect.soft() collects independent failures and reports them together at the end of the test.
  • Use a plain assert for values that are already final, never for page content that may still be changing.

Next, you will look at the waiting side of the same coin: how Playwright’s auto-waiting works for actions, and the explicit waits you need for navigation, network responses and custom conditions.

🎬 Watch the Playwright tutorial series on YouTube!

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