Web-first assertions are auto-retrying matchers: each step polls the live page until the condition holds or its time budget elapses, instead of taking a single snapshot. The assertion is the wait - you never need a wait for AJAX between an action and its check. This is the shortest path from a flaky suite to a green one: a click followed immediately by Then ".toast" should be visible simply keeps checking until the toast renders.
Every step in this group follows the same pattern: "<selector>" should [not] be <state>, optionally ending in within N seconds. The default budget is 5 seconds with a 100 ms polling interval; the trailing clause overrides it per step. On timeout the failure message reports the expectation and the last value seen (for example, Expected ".badge" text to be "12" (last seen: 11)), which makes triage fast.
The group also includes a role-based family built on Playwright's getByRole: click or assert elements by their accessible role and name - the same rules screen readers use. Prefer these when you want tests that double as lightweight accessibility checks. For waits that are not assertions (settling AJAX, waiting for URLs or titles), see the wait steps group.
Quick reference
| Step | What it does |
|---|---|
Then "#dashboard" should be visible | Auto-retrying state assertion (visible, hidden, attached, focused, enabled, disabled, editable). |
Then "#loading-spinner" should not be visible | Auto-retrying negated state assertion. |
Then "#hero" should be in the viewport | Assert an element's bounding box overlaps the visible viewport. |
Then "#footer" should not be in the viewport | Assert an element is outside the visible viewport. |
Then ".product-card" should have a count of 12 | Assert exactly N elements match a selector (auto-retry). |
Then "h1" should have text "Welcome" | Assert an element's text equals an expected string (trimmed). |
Then "h1" should contain text "Welcome" | Assert an element's text contains a substring. |
Then "#email" should have value "alice@example.com" | Assert an input's value property equals a string. |
Then "#tab-1" should have attribute "aria-selected" with value "true" | Assert an element's attribute equals a value. |
Then "#tab-1" should have class "is-active" | Assert an element's class attribute contains a class token. |
When I click the "Sign in" button | Click an element by accessible role + name (getByRole). |
Then the "Save changes" button should be visible | Assert a role-addressed element is visible (auto-retry). |
Steps in detail
Then "<selector>" should be <state> / should not be <state>
The core state matcher. Supported states: visible, hidden, attached (present in the DOM whether or not visible), focused (element is document.activeElement), enabled, disabled, and editable. The step polls the first element matching the selector until the state holds; the negated form polls until it does not. Both accept an optional within N seconds budget.
When I press "Save"
Then ".success-banner" should be visible within 10 seconds
And "#loading-spinner" should not be visible
And "button.submit" should be enabled
And "input#email" should be editable
Then "<selector>" should be in the viewport / should not be in the viewport
Checks the element's bounding box against the visible viewport rectangle - the element passes when any part of its box overlaps the window. Pair with scroll steps to verify that scrolling actually brought a section on screen, or that below-the-fold content starts off screen.
When I scroll to the element "#footer"
Then "#footer" should be in the viewport
When I scroll to the top
Then "#footer" should not be in the viewport
Then "<selector>" should have a count of <N>
Polls locator.count() until exactly N elements match. Use a count of 0 to assert something is gone - for example error messages after a fix, or skeleton placeholders after data loads. Because it auto-retries, it doubles as a wait for lists that render incrementally.
Given I am on "/table.html"
Then ".error" should have a count of 0 within 5 seconds
And ".feed-item" should have a count of 25 within 10 seconds
Then "<selector>" should have text "<text>" / should contain text "<text>"
Text matchers on the first matching element's textContent. have text requires exact equality after trimming surrounding whitespace; contain text is a substring match - the right choice when the element mixes stable text with dynamic values (".total" should contain text "$99").
Then "h1" should have text "Welcome"
And ".badge" should have text "12" within 3 seconds
And ".total" should contain text "$99"
Then "<selector>" should have value "<value>"
Asserts a form control's value property - inputs, textareas, and selects (a select reports the value of the selected option). Use an empty string to assert a field was cleared. Unlike the text matchers this reads the live property, not the markup attribute, so it reflects user typing and script updates.
When I fill in "alice@example.com" for "Email"
Then "#email" should have value "alice@example.com"
And "#bio" should have value "" within 3 seconds
And "select#country" should have value "JO"
Then "<selector>" should have attribute "<attr>" with value "<value>"
Polls getAttribute() until the attribute equals the expected value exactly. Particularly useful for ARIA state (aria-selected, aria-expanded) and data-* attributes that frameworks toggle to reflect component state.
When I click the "Notifications" tab
Then "#tab-1" should have attribute "aria-selected" with value "true"
And "[data-testid=cta]" should have attribute "data-state" with value "open"
Then "<selector>" should have class "<class>"
Asserts the element's class attribute contains the given class as a whole token - show matches modal fade show but not showing. This is the idiomatic check for state classes like is-active, open, or selected.
Then ".modal" should have class "show" within 3 seconds
And "tr.row-1" should have class "selected"
When I click the "<name>" <role>
Clicks an element resolved by accessible role and name via Playwright's getByRole. Supported roles: button, link, tab, menuitem, checkbox, radio, and option. Because it targets the accessibility tree rather than CSS, the step survives markup refactors and fails when a control loses its accessible name - a free regression check.
When I click the "Sign in" button
And I click the "Profile" link
And we click the "Notifications" tab
Then the "<name>" <role> should be visible
The assertion counterpart of the role click: polls until the role-addressed element is visible, with the same roles and the optional within N seconds budget.
Then the "Save changes" button should be visible
And the "Privacy" tab should be visible within 3 seconds
And the "I agree" checkbox should be visible
Complete example
Feature: Web-first assertions on dynamic content
Scenario: Search results render without explicit waits
Given I am on "/ajax-wait-examples.html"
When I fill in "laptop" for "#search-input" by attr
And I press "Search" button
Then "#search-results" should be visible within 10 seconds
And "#search-results" should contain text "Laptop Pro 15"
And ".loading-skeleton" should have a count of 0
And "#search-input" should have value "laptop"
When I click the "Clear" button
Then "#search-results" should not be visible
Tips
- All selector-based matchers act on the first element that matches; only
should have a count ofconsiders every match. - Prefer
Then ".spinner" should not be visible within 10 secondsover a fixed wait - it returns as soon as the spinner goes, and fails with the last observed state if it never does. should be hiddenpasses when the element is invisible or absent; useshould be attachedwhen you need to distinguish "in the DOM but hidden" from "not rendered at all".- These steps complement the BBR auto-settle that runs after every action (see wait steps): the settle handles the common case, the retry budget absorbs anything slower.