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.
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.
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=149950discount=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([nameforname,activeinusersifactive])
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
defarea(width,height):"""Return the area of a rectangle."""returnwidth*heightprint(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")
Output
a*b!c
sep="*" joins a and b, and end="!" replaces the newline, so the next print continues on the same line.
Quick quiz
1. What is the default value of sep in print()?
Values are separated by one space unless you pass sep.
2. Which line prints Total: 5 when n = 5?
Only an f-string fills in {n}. Adding a string and an int raises TypeError.
3. How do you write a comment in Python?
# starts a comment that runs to the end of the line.
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.
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.
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=82ifscore>=50:print("Passed")# inside the if blockprint("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
ifTrue: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 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.
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:
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:
Cause
Symptom
Real fix
Checking too early
Passes locally, fails on slow CI
Use expect() assertions, never time.sleep() or one-shot reads like inner_text() (Module 5)
Shared state between tests
Fails only in a certain order or in parallel
Fresh context per test (the default), unique test data, no module-level globals
Animations and transitions
Clicks land on a moving element
Let actionability checks work; disable animations for screenshots
Dependence on live third-party services
Random timeouts
Mock the network (Module 8), or use a stable test environment
Dates, times and random data
Fails around midnight or on certain values
Fix the time zone with timezone_id, control test data
Fragile locators
Fails after small UI changes or with A/B tests
Role-based locators and test ids (Module 4)
Too-short timeouts
Fails under load
Timeouts 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
Option
Meaning
--reruns N
Rerun a failed test up to N times
--reruns-delay S
Wait S seconds before each rerun (for example, to let a service recover)
--only-rerun REGEX
Rerun only failures whose error matches the regex; repeatable
--rerun-except REGEX
Do not rerun failures that match the regex; repeatable
--fail-on-flaky
Fail the run with exit code 7 if any test passed only after a rerun
--max-suite-reruns N
Limit the total number of reruns in the whole run
--force-reruns N
Use 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
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:
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:
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
Locally: no retries. You want to see every failure while you work.
In CI: a small number, such as --reruns 1 or --reruns 2, ideally with --only-rerun TimeoutError for infrastructure problems.
Always keep evidence: --tracing retain-on-failure and --screenshot only-on-failure.
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.
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.
--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!
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:
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.
Engine
Used by
Notes
Chromium
Google Chrome, Microsoft Edge, Opera
Playwright’s default
Firefox
Mozilla Firefox
Playwright’s patched build
WebKit
Apple Safari
Playwright’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:
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:
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:
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:
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:
indirect=True sends the value to the browser_type_launch_argsfixture 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.
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.
browser_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 test
Override the browser_context_args fixture
Branded Chrome or Edge
--browser-channel chrome or msedge
Browser command-line switches for one test
Indirect 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!
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:
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), notpage (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.
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}")
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 samepage 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:
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.
Name fixtures after the state they give you: logged_in_page, cart_with_two_items, admin_api.
Assert inside setup fixtures (as logged_in_page does) so a broken setup shows up as a setup error.
Keep tests isolated: share saved state (cookies, tokens) across tests, never a live page or context.
Clean up server-side data after yield; browser data disappears with the context automatically.
**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.
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:
Fixture
Scope
What it gives you
playwright
session
The started Playwright object (sync_playwright().start())
browser_type
session
playwright.chromium, .firefox or .webkit, chosen by --browser
browser
session
One launched browser, shared by all tests of that browser
new_context
function
A function that creates browser contexts and closes them after the test
context
function
A fresh BrowserContext for this test, made with new_context()
page
function
A 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.
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.
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:
Fixture
Value
browser_name
"chromium", "firefox" or "webkit"
is_chromium, is_firefox, is_webkit
True or False
browser_channel
The --browser-channel value, such as "chrome", or None
device
The --device value, such as "iPhone 15", or None
output_path
This 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"
--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
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
Fixture
Scope
Typical use
page
function
Almost every UI test
context
function
Cookies, permissions, routes, a second page in the same session
new_context
function
Several independent users or per-test context options
browser
session
Contexts without the default settings
browser_type, launch_browser
session
Launching another browser yourself
playwright
session
Devices, API request contexts
browser_context_args
session
Override to change context settings (Lesson 6)
browser_type_launch_args
session
Override to change launch options (headless, args, channel)
connect_options
session
Override to connect to a remote browser server
browser_name, browser_channel, device
session
Where the test runs
is_chromium, is_firefox, is_webkit
session
Browser-specific branches
output_path
function
This test’s artifact folder
base_url
session
The --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!
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:
Baseline: the first time, the test takes a screenshot and saves it as the approved image.
Comparison: on later runs, it takes a new screenshot and compares it pixel by pixel with the baseline.
Diff: if too many pixels differ, the test fails and saves the new image and a diff image that highlights the changes.
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:
Option
Good for
Your own fixture with Pillow + pixelmatch
Full control, no extra plugin, a great way to learn how it works
A pytest plugin such as pytest-playwright-visual-snapshot
Ready-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:
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:
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:
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:
Problem
Solution
Dates, times, ads, avatars, random data
mask=[locator, ...] in screenshot(), or style= to hide them
Animations, transitions, blinking cursor
animations="disabled"; caret="hide" is the default
Different fonts on Windows, macOS and Linux
Separate baselines per OS (as in the fixture), or always run visual tests in the same Docker image
Different window sizes
Keep the viewport fixed (pytest-playwright uses 1280 x 720)
Content still loading
Wait first, for example expect(locator).to_be_visible(), before the screenshot
Large pages with many moving parts
Screenshot components (a header, a card, a form) instead of the full page
Tiny rendering noise
A small max_diff_ratio, such as 0.001, or a higher threshold
For example, to mask the changing parts of a 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 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())
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!
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:
Artifact
What it shows
Cost
Screenshot
One picture of the page at a moment
Small, fast
Video
The whole test as a movie
Larger files, slight slowdown
Trace
Every action with DOM snapshots, network calls, console logs and source code
The 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:
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).
.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
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:
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.
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 beforecontext.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.
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!
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:
Actions auto-wait.click(), fill(), check() and the others wait until the element is ready.
Assertions retry.expect(...) keeps checking until the page matches (previous lesson).
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:
(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:
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:
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_until
goto() 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.
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
What
Default
Change it with
Actions (click, fill, …) and wait_for*
30 s
page.set_default_timeout() or timeout=
Navigation (goto, wait_for_url, reload, …)
30 s
page.set_default_navigation_timeout() or timeout=
expect(...) assertions
5 s
expect.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])
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 appear
expect(locator).to_be_visible()
A text to change
expect(locator).to_have_text("...")
A new page after a click
page.wait_for_url(...) or expect(page).to_have_url(...)
A server call to finish
with page.expect_response(...)
A spinner to go away
expect(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!
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:
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()
Assertion
Passes 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)
Assertion
Checks
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:
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)
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.
to_match_aria_snapshot (covered in the visual testing lesson)
API
to_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!