06 · Assertions with expect¶
Why expect instead of plain assert¶
from playwright.sync_api import Page, expect
def test_login_shows_dashboard(page: 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()
expect(page.get_by_text("Products")).to_be_visible()
You could write assert page.get_by_text("Products").is_visible() instead —
but is_visible() checks the DOM at the exact instant it's called and
returns False immediately if the element isn't there yet, even if it
would appear one frame later. expect(...).to_be_visible() retries the
check against the live page until it passes or a timeout elapses (5 seconds
by default). This single difference eliminates the majority of timing-related
flakiness in E2E suites — you stop needing to guess how long to wait before
asserting.
# fragile — checks once, right now, no retry:
assert page.get_by_text("Products").is_visible()
# robust — polls until it's true or times out:
expect(page.get_by_text("Products")).to_be_visible()
# the assert version fails immediately if the DOM update hasn't
# landed yet; expect() gives the app up to 5s (configurable) to
# finish rendering before failing
Common assertions¶
from playwright.sync_api import expect
expect(page).to_have_title("Swag Labs")
expect(page).to_have_url("https://www.saucedemo.com/inventory.html")
expect(page.get_by_text("Products")).to_be_visible()
expect(page.get_by_text("Out of stock")).to_be_hidden()
expect(page.get_by_role("button", name="Login")).to_be_enabled()
expect(page.get_by_role("button", name="Checkout")).to_be_disabled()
expect(page.get_by_label("Remember me")).to_be_checked()
expect(page.locator(".error-message")).to_have_text("Invalid credentials")
expect(page.locator(".error-message")).to_contain_text("Invalid")
expect(page.locator(".product")).to_have_count(6)
expect(page.get_by_role("link", name="Cart")).to_have_attribute("href", "/cart")
# each raises an AssertionError with a readable diff after
# retrying for up to the timeout if the condition never becomes true
| Assertion | Checks |
|---|---|
to_be_visible() / to_be_hidden() |
Rendered and occupying layout space, vs. not |
to_be_enabled() / to_be_disabled() |
Interactive state |
to_be_checked() |
Checkbox/radio state |
to_have_text(...) |
Exact (trimmed) text content |
to_contain_text(...) |
Substring match |
to_have_value(...) |
Current value of an input |
to_have_count(n) |
Number of elements a locator matches |
to_have_attribute(name, value) |
A specific HTML attribute |
to_have_url(...) / to_have_title(...) |
Page-level state |
Reading a failure¶
def test_wrong_credentials_shows_error(page):
page.goto("https://www.saucedemo.com/")
page.get_by_placeholder("Username").fill("standard_user")
page.get_by_placeholder("Password").fill("wrong_password")
page.get_by_role("button", name="Login").click()
expect(page.locator("[data-test='error']")).to_have_text(
"Epic sadface: Wrong password"
)
If the copy actually reads slightly differently, the failure looks like this:
AssertionError: Locator expected to have text "Epic sadface: Wrong password"
Actual value: Epic sadface: Username and password do not match any user in this service
Call log:
- expect.to_have_text with timeout 5000ms
- waiting for locator("[data-test='error']")
- locator resolved to <h3 data-test="error">…</h3>
- unexpected value "Epic sadface: Username and password do not match any user in this service"
- retrying …
The call log shows the resolved element and every retry — you can see exactly what the locator found and how its text changed (or didn't) across retries, which is usually enough to tell whether the bug is in your assertion or in the app.
Soft assertions¶
Playwright Python doesn't ship a built-in "soft assert that collects failures
and reports them all at the end" the way some frameworks do; each expect(...)
call raises immediately on failure. For test-suite-level soft-assertion
behavior, pair pytest with a plugin such as pytest-check, or structure the
test so each independently meaningful check is its own test function — which
also gives you a clearer failure name per check (Module 8 of
Python Testing Mastery Path covers the "one logical assertion per test"
principle this maps to).
Negating an assertion¶
expect(page.get_by_text("Loading...")).not_to_be_visible()
expect(page.get_by_role("button", name="Submit")).not_to_be_disabled()
# no output on success — not_to_* still retries, waiting for
# the condition to become false rather than checking once
not_to_be_visible() is not the same as "never became visible" — it means
"is not visible right now, after waiting up to the timeout for it to
disappear." If the element was visible a moment ago and disappears within
the timeout window, this still passes. That's usually exactly what you
want (waiting for a spinner to go away), but be aware it wouldn't catch a
spinner that flashes visible-then-hidden faster than you could ever observe.
Custom timeout per assertion¶
# waits up to 15 seconds instead of the default 5 for this
# specific assertion — useful for a known-slow operation
# (report generation, a large file upload) without loosening
# the default for every other assertion in the suite
How It Actually Works¶
expect(locator).to_be_visible() isn't a single check — it's a poll loop
Playwright runs internally: re-resolve the locator against the live DOM,
evaluate the condition, and if it fails, wait a short backoff interval and
try again, up to the timeout. Each retry is a fresh round trip over CDP
(re-querying the accessibility tree or DOM, re-checking bounding boxes for
visibility), not a cached comparison — which is why the call log you see on
failure can show the actual value changing across retries: you're watching
real snapshots of the page at each poll, not one static check repeated.
This retry behavior is implemented on the Node driver side, not by
Python looping and re-calling into the driver each time — Python sends one
expect command describing the condition and timeout, and the driver itself
owns the poll loop, which keeps the retry cadence tight (tens of
milliseconds) without round-tripping through the Python/driver stdio pipe on
every single poll.
to_have_text() normalizes whitespace before comparing (collapsing runs of
spaces/newlines the way a browser renders them visually), which is why it
tolerates source-formatting differences in the HTML that to_have_text()'s
plain-string cousin in a naive scraper would not — the comparison happens
against the rendered text content the accessibility layer reports, not the
raw markup.
Exercise¶
Using https://www.saucedemo.com/ (standard_user / secret_sauce):
- Write a test that logs in and asserts
expect(page).to_have_url(...)for the inventory page, andexpect(page.locator(".inventory_item")).to_have_count(6). - Write a second test using the wrong password and assert the exact error
text shown, using
to_have_text()(find the real copy by running the flow manually first — don't guess). - Add an item to the cart and assert the cart badge
(
.shopping_cart_badge) shows"1"withto_have_text(), then add a second item and assert it updates to"2". - Assert that the "Checkout" button on the cart page is enabled with
to_be_enabled(), and that removing all items makes the cart badge disappear entirely — assert this withnot_to_be_visible()rather than checking for empty text. - Deliberately assert an incorrect expected value (e.g. the wrong cart
count) and read the full failure output, including the call log — note
how it differs from a plain Python
AssertionError.