Assertions with expect()

9 min read

A test that doesn't assert is just a script. Assertions are how you turn "click this button" into "click this button, then verify the dashboard rendered three orders and a total of $142.50." Playwright Python has two flavours of assertion — web-first (auto-retrying, for things that take time to render) and standard Python assert (instant, for static values) — and choosing between them at the right moment is the difference between a test that flakes once a week and one that catches real bugs deterministically.

Web-first assertions — the default

Web-first assertions are the ones you'll reach for 90% of the time. They auto-retry until the assertion passes or the assertion timeout fires (5 seconds by default):

from playwright.sync_api import Page, expect
 
def test_dashboard_renders(page: Page):
    page.goto("/dashboard")
    expect(page.get_by_role("heading")).to_contain_text("Welcome")
    expect(page.get_by_test_id("order-count")).to_have_text("3")
    expect(page).to_have_url("/dashboard")

The retry behaviour matters more than it sounds. After clicking a button that triggers an async operation, you can write the assertion immediately:

page.get_by_role("button", name="Submit").click()
expect(page.get_by_text("Order confirmed")).to_be_visible()
# Playwright keeps re-querying the page until the toast appears
# or 5 seconds pass. No manual wait, no time.sleep.

This single behaviour — every assertion retries — is why Playwright tests rarely need explicit waits. The framework handles the timing.

In the TypeScript course, this looked like await expect(...).toBeVisible(). In Python sync, the same call is expect(...).to_be_visible() — no await, snake_case method name. Same retry semantics, same engine.

Page-level assertions

Some assertions apply to the page itself, not a locator:

import re
 
expect(page).to_have_url("https://shop.example.com/dashboard")
expect(page).to_have_url(re.compile(r"/dashboard$"))     # regex
expect(page).to_have_title("Dashboard — MyApp")
expect(page).to_have_title(re.compile(r"MyApp"))

to_have_url is your post-navigation guard: assert the redirect actually happened before you keep going. Note the use of re.compile for regex — Python doesn't have JS's literal /regex/ syntax.

Locator assertions — the toolbox

The day-to-day vocabulary, snake_case style:

# Visibility / existence
expect(locator).to_be_visible()
expect(locator).to_be_hidden()
expect(locator).to_be_attached()
 
# Interaction state
expect(locator).to_be_enabled()
expect(locator).to_be_disabled()
expect(locator).to_be_editable()
expect(locator).to_be_checked()
expect(locator).to_be_focused()
 
# Text
expect(locator).to_have_text("exact string")
expect(locator).to_have_text(re.compile(r"pattern"))
expect(locator).to_contain_text("partial")
expect(locator).to_contain_text(["item 1", "item 2"])  # list — each must appear
 
# Form values
expect(locator).to_have_value("alice@test.com")
expect(locator).to_have_values(["Sports", "Music"])  # multi-select
 
# Counts
expect(locator).to_have_count(5)
 
# Attributes and CSS
expect(locator).to_have_attribute("href", "/products")
expect(locator).to_have_attribute("aria-expanded", "true")
expect(locator).to_have_class(re.compile(r"active"))
expect(locator).to_have_css("color", "rgb(255, 0, 0)")
expect(locator).to_have_css("display", "block")
 
# Screenshots (chapter 6)
expect(locator).to_have_screenshot()
expect(page).to_have_screenshot("home.png")

Pattern matchers like to_have_text accept a string for exact match (case-sensitive), or a re.Pattern for regex match. to_contain_text accepts a string for substring match, or a list of strings where every entry must appear in the element.

Negation with .not_to_*

In TypeScript the negation prefix is .not. (.not.toBeVisible()). In Python it's a not_ prefix on the matcher name itself:

expect(page.get_by_text("Error")).not_to_be_visible()
expect(page.get_by_label("Newsletter")).not_to_be_checked()
expect(page).not_to_have_url(re.compile(r"login"))

Same retry semantics — the assertion keeps polling until the condition no longer holds (or the timeout fires).

not_to_be_visible() and to_be_hidden() are subtly different: to_be_hidden() passes if the element is in the DOM but not visible or not in the DOM at all; not_to_be_visible() keeps the asymmetric retry behaviour. In practice, prefer to_be_hidden() when you're asserting "the modal closed" and not_to_be_visible() when you want to retry waiting for it to disappear.

Custom timeouts

The default assertion timeout is 5 seconds (configurable globally via the expect config). Override per-assertion when you genuinely need longer or shorter:

expect(page.get_by_text("Report ready")).to_be_visible(timeout=30_000)
expect(page.get_by_text("Quick toast")).to_be_visible(timeout=1_000)

A common pattern: bump the timeout for the one slow operation in your test (say, a long-running export job), keep all the other assertions fast.

Soft assertions in Python — pytest-check

In TypeScript Playwright, expect.soft(...) records failures without halting the test. Python Playwright has no built-in expect.soft. The idiomatic Python equivalent is the pytest-check plugin:

pip install pytest-check
from playwright.sync_api import Page, expect
import pytest_check as check
 
def test_dashboard_smoke(page: Page):
    page.goto("/dashboard")
 
    with check:
        expect(page.get_by_role("heading")).to_have_text("Dashboard")
    with check:
        expect(page.get_by_test_id("count")).to_have_text("3")
    with check:
        expect(page).to_have_url(re.compile(r"dashboard"))
 
    # All three checks run; failures are reported at the end of the test

The with check: context manager catches assertion failures and records them. The test fails if any failed but reports all failures rather than just the first. Useful for smoke tests that check many things on a page in one go — if three are wrong, you want to know all three.

Standard Python assert — for static values

Plain assert (without expect) doesn't retry. It's instant — same as any other Python assertion:

api_response = page.request.get("/api/orders")
assert api_response.status == 200
 
orders = api_response.json()
assert len(orders) == 3
assert "total" in orders[0]
assert orders[0]["total"] > 0
 
cookies = context.cookies()
assert any(c["name"] == "session" for c in cookies)

These are the right choice for API responses, computed values, counts read into a variable, anything that doesn't change once you have it. Use Python's full assertion vocabulary — assert x in y, assert isinstance(x, dict), assert len(x) > 0.

Retrying vs non-retrying — the difference

When does the assertion retry?

expect(...) — auto-retrying

  • Pattern: expect(locator).to_be_visible()

  • Re-queries the DOM until the condition holds or the timeout fires

  • Right tool for: visibility, text, count, URL, anything async

  • Removes the need for time.sleep in 95% of cases

assert ... — instant snapshot

  • Pattern: assert value == ...

  • Checks once against a value already in memory

  • Right tool for: API responses, parsed JSON, computed values

  • Wrong tool for DOM state — turns auto-retry into instant flake

The single most common Playwright Python mistake is using a non-retrying assertion against the DOM:

# ❌ Wrong — snapshots the text once, no retry
text = page.get_by_role("heading").text_content()
assert text == "Welcome"
 
# ✅ Right — retries until the heading shows "Welcome"
expect(page.get_by_role("heading")).to_have_text("Welcome")

Both pass when the page is fast. Only the second works on a slow CI run.

A complete product-page assertion test

import re
from playwright.sync_api import Page, expect
 
class TestProductDetail:
    def setup_method(self, method):
        # pytest hook — runs before each test method
        pass
 
    def test_renders_product_details(self, page: Page):
        page.goto("/products/wireless-headphones")
        card = page.get_by_test_id("product-detail")
 
        # Visibility and structure
        expect(card).to_be_visible()
        expect(card.get_by_role("heading", level=1)).to_have_text("Wireless Headphones")
 
        # Price and stock
        expect(card.get_by_test_id("price")).to_contain_text("$")
        expect(card.get_by_test_id("stock")).to_contain_text(re.compile(r"in stock", re.I))
 
        # Add-to-cart button enabled
        add_btn = card.get_by_role("button", name="Add to cart")
        expect(add_btn).to_be_enabled()
 
        # Image alt text — accessibility check via assertion
        expect(card.get_by_role("img")).to_have_attribute("alt", re.compile(r"headphones", re.I))
 
        # Class state
        expect(card).to_have_class(re.compile(r"in-stock"))
 
    def test_adds_to_cart_and_badge_updates(self, page: Page):
        page.goto("/products/wireless-headphones")
        page.get_by_role("button", name="Add to cart").click()
        expect(page.get_by_test_id("cart-count")).to_have_text("1")
        expect(page.get_by_text("Added to cart")).to_be_visible()

Read each assertion for the reason it picks the matcher it does. to_be_visible() for the card itself. to_have_text() for an exact heading. to_contain_text(re.compile(r"in stock", re.I)) for case-insensitive partial match. to_be_enabled() for button state. to_have_attribute() for the alt text accessibility check. Every assertion describes the user-visible truth, not implementation detail.

Coming from Playwright TypeScript?

The mapping is the most mechanical part of the cross-translation:

  • await expect(locator).toBeVisible()expect(locator).to_be_visible()
  • await expect(locator).toContainText('text')expect(locator).to_contain_text("text")
  • await expect(locator).toHaveText('exact')expect(locator).to_have_text("exact")
  • await expect(locator).toHaveCount(5)expect(locator).to_have_count(5)
  • await expect(locator).toHaveValue('foo')expect(locator).to_have_value("foo")
  • await expect(locator).toHaveAttribute('href', '/x')expect(locator).to_have_attribute("href", "/x")
  • await expect(page).toHaveURL(/dash/)expect(page).to_have_url(re.compile(r"dash"))
  • await expect(loc).not.toBeVisible()expect(loc).not_to_be_visible() (note: not_ prefix, not .not.)
  • await expect.soft(...)with check: expect(...) (using pytest-check)

Two structural differences: regex literals become re.compile(...), and .not. becomes not_to_*. Otherwise the ergonomics are identical.

⚠️ Common mistakes

  • Reading the value first, then asserting against it. text = locator.text_content(); assert text == "Saved" snapshots once and skips Playwright's retry. The right pattern is expect(locator).to_have_text("Saved") — the assertion itself does the polling. This is the single highest-leverage habit to internalise from this lesson.
  • Reaching for time.sleep(2) to "let the page settle." Web-first assertions already do this — they retry up to the timeout. Fixed sleeps are slow when the page is fast and flaky when it's slow. If a specific assertion needs longer than 5 seconds, raise its timeout (timeout=10_000); never sprinkle time.sleep.
  • Using expect.soft(...) from TypeScript habit. Python Playwright has no .soft API. Reach for pytest-check if you genuinely need multi-failure smoke tests; for normal flow tests, plain expect (fail-fast) is the right default.

🎯 Practice task

Build an assertion-rich product-page spec. 25-30 minutes.

  1. Use Sauce Demo (base_url = https://www.saucedemo.com) and log in via a fixture (carry the autouse login fixture from the previous lesson).

  2. Create tests/test_assertions.py with three tests against the inventory page:

    import re
    import pytest
    import pytest_check as check
    from playwright.sync_api import Page, expect
     
    @pytest.fixture(autouse=True)
    def login(page: Page):
        page.goto("/")
        page.get_by_placeholder("Username").fill("standard_user")
        page.get_by_placeholder("Password").fill("secret_sauce")
        page.get_by_role("button", name="Login").click()
     
    class TestInventoryAssertions:
        def test_inventory_smoke_with_soft_assertions(self, page: Page):
            with check:
                expect(page).to_have_url(re.compile(r"inventory"))
            with check:
                expect(page.locator(".inventory_item")).to_have_count(6)
            with check:
                expect(page.locator(".shopping_cart_link")).to_be_visible()
            with check:
                expect(page.get_by_text("Products")).to_be_visible()
     
        def test_first_card_has_expected_fields(self, page: Page):
            card = page.locator(".inventory_item").first
            expect(card.locator(".inventory_item_name")).to_have_text("Sauce Labs Backpack")
            expect(card.locator(".inventory_item_price")).to_contain_text("$")
            expect(card.get_by_role("button", name="Add to cart")).to_be_enabled()
            expect(card.get_by_role("img")).to_have_attribute("alt", re.compile(r"Sauce Labs Backpack"))
     
        def test_cart_badge_updates(self, page: Page):
            expect(page.locator(".shopping_cart_badge")).to_be_hidden()
            page.locator(".inventory_item").filter(has_text="Backpack") \
                .get_by_role("button", name="Add to cart").click()
            expect(page.locator(".shopping_cart_badge")).to_have_text("1")
  3. Install pytest-check (pip install pytest-check) and run with pytest tests/test_assertions.py -v. All three pass on Chromium.

  4. Force a slow assertion to fail. In test 3, change the cart-badge assertion to to_have_text("99"). Re-run. Watch the assertion retry for 5 seconds before reporting the failure — that's auto-retry in action.

  5. Demonstrate the snapshot-vs-retry pitfall. Replace test 2's price assertion with the broken pattern: text = card.locator(".inventory_item_price").text_content(); assert "$" in text. It still passes (Sauce Demo is fast), but you've removed the retry. Imagine the price loaded asynchronously after a 2-second delay — the snapshot version would see None instead of "$29.99". This is the failure mode that turns into "flaky" tests in production.

  6. Stretch: add a test that asserts a complex DOM state with five with check: blocks — the heading, the cart badge being hidden, six product cards, the sort dropdown defaulting to "Name (A to Z)", and the burger menu being closed. Run it. Now break two of the five conditions on purpose. The test reports both failures, not just the first — that's the soft-assertion superpower via pytest-check.

You now have a precise, retry-aware assertion vocabulary in Python. The next lesson is the last in this chapter — handling the form controls that don't fit cleanly into "click" and "fill": dropdowns, file uploads, autocomplete comboboxes, date pickers, and the patterns for testing form validation.

// tip to track lessons you complete and pick up where you left off across devices.