Playwright Python Assertions with expect (Web-First Assertions)

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

Advertisement

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


๐ŸŽฏ What Is a Web-First Assertion?

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

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

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

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

from playwright.sync_api import expect

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

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

๐Ÿ“„ tests/conftest.py

import pytest
from playwright.sync_api import Page


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

๐Ÿ“„ Page Assertions: Title and URL

๐Ÿ“„ tests/test_page_assertions.py

import re

from playwright.sync_api import Page, expect


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

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

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

๐Ÿ‘€ Visibility and Presence

๐Ÿ“„ tests/test_locator_assertions.py

import re

from playwright.sync_api import Page, expect


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

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

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

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

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


๐Ÿ”˜ Element State: Enabled, Editable, Checked, Focused, Empty

๐Ÿ“„ tests/test_locator_assertions.py

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

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

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

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

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

๐Ÿ“ Text Assertions

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

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

๐Ÿ“„ tests/test_locator_assertions.py

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

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

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

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

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

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

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


๐Ÿงพ Values, Attributes, Classes, CSS and Count

๐Ÿ“„ tests/test_locator_assertions.py

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

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

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

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

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

โ™ฟ Accessibility Assertions

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

๐Ÿ“„ tests/test_locator_assertions.py

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

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

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


๐ŸŒ API Response Assertions

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

๐Ÿ“„ tests/test_api_assertion.py

from playwright.sync_api import Page, expect


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

Run all the passing tests:

โŒจ๏ธ Terminal

pytest -q tests

๐Ÿ–ฅ๏ธ Output

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

โณ Why a Plain assert Can Be Flaky

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

๐Ÿ“„ tests/test_retry.py

from playwright.sync_api import Page, expect


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

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

Now the same check with a plain assert:

๐Ÿ“„ demos/test_plain_assert.py

import pytest
from playwright.sync_api import Page


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


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

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

โŒจ๏ธ Terminal

pytest -q demos/test_plain_assert.py

๐Ÿ–ฅ๏ธ Output

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

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

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

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

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

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

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

๐Ÿ’ฌ Custom Messages and Timeouts

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

๐Ÿ“„ demos/test_message.py

import pytest
from playwright.sync_api import Page, expect


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


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

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

โŒจ๏ธ Terminal

pytest -q demos/test_message.py

๐Ÿ–ฅ๏ธ Output

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

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

Read the failure from top to bottom:

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

Changing the default timeout for all assertions

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

๐Ÿ“„ global_timeout/conftest.py

from playwright.sync_api import expect

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

๐Ÿ“„ global_timeout/test_slow_page.py

from playwright.sync_api import Page, expect


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

๐Ÿงบ Soft Assertions: Collect Failures, Keep Going

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

๐Ÿ“„ demos/test_soft.py

import pytest
from playwright.sync_api import Page, expect


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


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

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

โŒจ๏ธ Terminal

pytest -q demos/test_soft.py

๐Ÿ–ฅ๏ธ Output

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

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

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


๐Ÿ“š Quick Reference

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

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


โ“ Frequently Asked Questions

๐Ÿค” What is the difference between to_have_text and to_contain_text?

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

๐Ÿค” Should I write not_to_be_visible() or to_be_hidden()?

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

๐Ÿค” Why does my assertion take 5 seconds before failing?

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

๐Ÿค” Can I use expect with plain Python values, like numbers or lists?

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

๐Ÿค” Is there a to_have_screenshot assertion in Python?

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


๐Ÿ Summary

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

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

๐ŸŽฌ Watch the Playwright tutorial series on YouTube!

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

1 thought on “Playwright Python Assertions with expect (Web-First Assertions)”

Leave a Comment