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-checkfrom 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 testThe 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(...)(usingpytest-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 isexpect(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 sprinkletime.sleep. - Using
expect.soft(...)from TypeScript habit. Python Playwright has no.softAPI. Reach forpytest-checkif you genuinely need multi-failure smoke tests; for normal flow tests, plainexpect(fail-fast) is the right default.
🎯 Practice task
Build an assertion-rich product-page spec. 25-30 minutes.
-
Use Sauce Demo (
base_url = https://www.saucedemo.com) and log in via a fixture (carry the autouseloginfixture from the previous lesson). -
Create
tests/test_assertions.pywith 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") -
Install
pytest-check(pip install pytest-check) and run withpytest tests/test_assertions.py -v. All three pass on Chromium. -
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. -
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 seeNoneinstead of"$29.99". This is the failure mode that turns into "flaky" tests in production. -
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 viapytest-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.