Web First Steps

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

StepWhat it does
Then "#dashboard" should be visibleAuto-retrying state assertion (visible, hidden, attached, focused, enabled, disabled, editable).
Then "#loading-spinner" should not be visibleAuto-retrying negated state assertion.
Then "#hero" should be in the viewportAssert an element's bounding box overlaps the visible viewport.
Then "#footer" should not be in the viewportAssert an element is outside the visible viewport.
Then ".product-card" should have a count of 12Assert 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" buttonClick an element by accessible role + name (getByRole).
Then the "Save changes" button should be visibleAssert 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 of considers every match.
  • Prefer Then ".spinner" should not be visible within 10 seconds over a fixed wait - it returns as soon as the spinner goes, and fails with the last observed state if it never does.
  • should be hidden passes when the element is invisible or absent; use should be attached when 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.

Back to all step groups