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()andto_have_url(). - Each method retries until it passes, or raises
AssertionErrorafter the timeout.
All examples below run against the SQA Tools practice page. Every test receives the opened page from this fixture:
๐ tests/conftest.py
import pytest
from playwright.sync_api import Page
@pytest.fixture
def practice(page: Page) -> Page:
page.goto("https://sqatools.in/automation-practice-page/")
return page
๐ Page Assertions: Title and URL
๐ tests/test_page_assertions.py
import re
from playwright.sync_api import Page, expect
def test_title_and_url(practice: Page):
# Exact title
expect(practice).to_have_title("Automation Practice Page - SQA Tools")
# Part of the title, with a regular expression
expect(practice).to_have_title(re.compile("Practice Page"))
# Exact URL, then a URL pattern
expect(practice).to_have_url("https://sqatools.in/automation-practice-page/")
expect(practice).to_have_url(re.compile(r"/automation-practice-page/$"))
- Pass the page itself (not a locator) to
expect()for page-level checks. - A plain string must match exactly. Use
re.compile(...)when only part of the value matters, for example a URL that contains an id or a query string. to_have_url()is the right check after a click that navigates: it waits for the new URL instead of readingpage.urltoo 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 writenot_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 hasclass="btn btn-primary",to_have_class("btn")fails, whileto_contain_class("btn")passes.to_have_css()checks the computed style, so it also covers styles that come from a stylesheet, not just thestyleattribute.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.requestsends HTTP requests that share the browser context’s cookies.to_be_ok()passes for status codes 200 to 299. On failure, it prints the response details.
Run all the passing tests:
โจ๏ธ Terminal
pytest -q tests
๐ฅ๏ธ Output
........ [100%]
8 passed, 1 warning in 18.62s
โณ Why a Plain assert Can Be Flaky
Here is the “Load Success” button checked two ways. First, the right way:
๐ tests/test_retry.py
from playwright.sync_api import Page, expect
def test_expect_waits_for_the_ajax_result(practice: Page):
practice.get_by_role("button", name="Load Success").click()
loader = practice.locator("#loader")
expect(loader).to_have_text("Loading...")
expect(loader).to_have_text("Data loaded successfully!") # arrives after 3 s; expect waits
Now the same check with a plain assert:
๐ demos/test_plain_assert.py
import pytest
from playwright.sync_api import Page
@pytest.fixture
def practice(page: Page) -> Page:
page.goto("https://sqatools.in/automation-practice-page/")
return page
def test_plain_assert_reads_too_early(practice: Page):
practice.get_by_role("button", name="Load Success").click()
# inner_text() reads the text ONCE, immediately after the click
assert practice.locator("#loader").inner_text() == "Data loaded successfully!"
โจ๏ธ Terminal
pytest -q demos/test_plain_assert.py
๐ฅ๏ธ Output
F [100%]
================================== FAILURES ===================================
_________________ test_plain_assert_reads_too_early[chromium] _________________
practice = <Page url='https://sqatools.in/automation-practice-page/'>
def test_plain_assert_reads_too_early(practice: Page):
practice.get_by_role("button", name="Load Success").click()
# inner_text() reads the text ONCE, immediately after the click
> assert practice.locator("#loader").inner_text() == "Data loaded successfully!"
E AssertionError: assert 'Loading...' == 'Data loaded successfully!'
E
E - Data loaded successfully!
E + Loading...
demos\test_plain_assert.py:15: AssertionError
=========================== short test summary info ===========================
FAILED demos/test_plain_assert.py::test_plain_assert_reads_too_early[chromium]
1 failed, 1 warning in 2.72s
The page is fine; the test is wrong. inner_text() returned “Loading…” because it ran before the 3-second timer finished.
So when is a plain assert right? When the value is already final and does not depend on the page changing:
- Python values you calculated:
assert total == 300. - An API response you already received:
assert response.status == 200. - Values after a web-first assertion has already waited for the page to settle.
๐ฌ Custom Messages and Timeouts
Pass a second argument to expect() to explain what failed in business terms, and timeout= (milliseconds) to change how long one assertion waits:
๐ demos/test_message.py
import pytest
from playwright.sync_api import Page, expect
@pytest.fixture
def practice(page: Page) -> Page:
page.goto("https://sqatools.in/automation-practice-page/")
return page
def test_ajax_within_two_seconds(practice: Page):
practice.get_by_role("button", name="Load Success").click()
expect(
practice.locator("#loader"),
"AJAX data should load within 2 seconds",
).to_have_text("Data loaded successfully!", timeout=2_000)
โจ๏ธ Terminal
pytest -q demos/test_message.py
๐ฅ๏ธ Output
E AssertionError: AJAX data should load within 2 seconds
E Expected value: 'Data loaded successfully!'
E Actual value: Loading...
E Call log:
E - Expect "to_have_text" locator("#loader") with timeout 2000ms
E - waiting for locator("#loader")
E 20 ร locator resolved to <p id="loader">Loading...</p>
E - unexpected value "Loading..."
E
E Aria snapshot:
E - paragraph: Loading...
demos\test_message.py:17: AssertionError
=========================== short test summary info ===========================
FAILED demos/test_message.py::test_ajax_within_two_seconds[chromium] - Assert...
1 failed, 1 warning in 3.99s
Read the failure from top to bottom:
- Your message comes first, then the expected and actual values.
- The call log shows each retry: Playwright kept finding
#loaderwith the text “Loading…” until the 2 seconds ran out. - The real data arrives after 3 seconds, so a 2-second limit fails. Choose timeouts from real requirements, not by guessing.
Changing the default timeout for all assertions
The default is 5 seconds. To change it for the whole suite, call expect.set_options() once, for example in conftest.py:
๐ global_timeout/conftest.py
from playwright.sync_api import expect
# Every expect(...) in this suite now waits up to 10 seconds by default
expect.set_options(timeout=10_000)
๐ global_timeout/test_slow_page.py
from playwright.sync_api import Page, expect
def test_uses_the_global_timeout(page: Page):
page.goto("https://sqatools.in/automation-practice-page/")
page.get_by_role("button", name="Load Success").click()
expect(page.locator("#loader")).to_have_text("Data loaded successfully!")
- A
timeout=argument on a single assertion still overrides the global value. expect.set_options()affects only assertions. Action timeouts (click, fill) are set on the page or context; you will see them in the next lesson.
๐งบ Soft Assertions: Collect Failures, Keep Going
A normal assertion stops the test at the first failure. Sometimes you want to check several independent things on one page and see every problem in a single run, for example all the labels of a form. That is what expect.soft() does:
๐ demos/test_soft.py
import pytest
from playwright.sync_api import Page, expect
@pytest.fixture
def practice(page: Page) -> Page:
page.goto("https://sqatools.in/automation-practice-page/")
return page
def test_form_labels(practice: Page):
# Each soft check records a failure and lets the test continue
expect.soft(practice.locator("#submitButton")).to_have_text("Send", timeout=1_000)
expect.soft(practice.locator("#normalButton")).to_have_text("Normal Button")
expect.soft(practice.locator("#username")).to_have_attribute(
"placeholder", "Your username", timeout=1_000
)
print("\nThe test body reached its last line")
โจ๏ธ Terminal
pytest -q demos/test_soft.py
๐ฅ๏ธ Output
_________________________ test_form_labels[chromium] __________________________
+ Exception Group Traceback (most recent call last):
| File "C:\pwv\_venv0\Lib\site-packages\pytest_playwright\pytest_playwright.py", line 137, in pytest_runtest_call
| raise _BaseExceptionGroup("Soft assertion failures", errors)
| ExceptionGroup: Soft assertion failures (2 sub-exceptions)
+-+---------------- 1 ----------------
| AssertionError: Locator expected to have text 'Send'
| Actual value: Submit
| Call log:
| - Expect "to_have_text" locator("#submitButton") with timeout 1000ms
| - waiting for locator("#submitButton")
| 11 ร locator resolved to <button type="submit" id="submitButton">Submit</button>
| - unexpected value "Submit"
|
| Aria snapshot:
| - button "Submit"
+---------------- 2 ----------------
| AssertionError: Locator expected to have attribute 'Your username'
| Actual value: Enter username
| Call log:
| - Expect "to_have_attribute" locator("#username") with timeout 1000ms
| - waiting for locator("#username")
| 10 ร locator resolved to <input type="text" id="username" name="username" placeholder="Enter username"/>
| - unexpected value "Enter username"
|
| Aria snapshot:
| - textbox "Enter username"
+------------------------------------
---------------------------- Captured stdout call -----------------------------
The test body reached its last line
=========================== short test summary info ===========================
FAILED demos/test_soft.py::test_form_labels[chromium] - ExceptionGroup: Soft ...
1 failed, 1 warning in 4.47s
- The test ran to the end (the
printoutput appears under “Captured stdout call”), then failed with both problems reported together. - With two or more soft failures, pytest-playwright raises an
ExceptionGroupnamed “Soft assertion failures”. With one, it raises that singleAssertionError. expect.soft()needs the pytest-playwright plugin (0.7.3 or later), which collects the failures for each test. Outside pytest-playwright, use a normalexpect().
โ ๏ธ Watch out: Use soft assertions for independent checks only. If a later step depends on an earlier check (for example, you must be logged in before checking the dashboard), use a normal expect() so the test stops at the real cause.
๐ Quick Reference
| Category | Assertions |
|---|---|
| Page | to_have_title, to_have_url |
| Visibility | to_be_visible, to_be_hidden, to_be_attached, to_be_in_viewport |
| State | to_be_enabled, to_be_disabled, to_be_editable, to_be_checked, to_be_focused, to_be_empty |
| Text | to_have_text, to_contain_text |
| Values | to_have_value, to_have_values |
| Attributes | to_have_attribute, to_have_id, to_have_class, to_contain_class, to_have_css, to_have_js_property |
| Count | to_have_count |
| Accessibility | to_have_role, to_have_accessible_name, to_have_accessible_description, to_have_accessible_error_message |
| Structure | 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_textmatches the whole text;to_contain_texta part. Lists check several elements in order.- Every assertion has a
not_form; add a custom message as the second argument ofexpect()andtimeout=per check, orexpect.set_options()globally. expect.soft()collects independent failures and reports them together at the end of the test.- Use a plain
assertfor values that are already final, never for page content that may still be changing.
Next, you will look at the waiting side of the same coin: how Playwright’s auto-waiting works for actions, and the explicit waits you need for navigation, network responses and custom conditions.
๐ฌ Watch the Playwright tutorial series on YouTube!
Subscribe to SqaToolsByDY • Practice on the SQA Tools Automation Practice Page
1 thought on “Playwright Python Assertions with expect (Web-First Assertions)”