Assertion steps are the Then half of every scenario: after navigating and interacting, they verify that the user actually saw the right thing. This group covers page-level checks - plain text on the page, regex pattern matching, text inside a table row or a specific element, element existence, CSS properties, element counts, link destinations, the raw HTML response, and the HTTP status code. Field-level checks (checkbox state, field values) live in the field steps group, URL/path checks in the navigation steps group, and modal checks in the modal steps group.
Most of these steps are web-first: instead of taking a single snapshot of the page, they wait on a Playwright locator, so the assertion passes the moment the expected content appears. For example, I should see "Welcome" waits up to 5 seconds for the body to contain the text, element-existence checks wait up to 3 seconds for visibility, and element-content checks wait up to 5-10 seconds for the element to attach before reading its text. That means you rarely need an explicit wait between an action and its assertion; for slow AJAX flows, combine them with the wait steps (BBR smart waits) or the auto-retrying matchers on the web-first assertion steps page.
Steps that take an element "by attr" run the identifier through the selector registry: a bare word like uname expands to a tolerant selector that tries the tag name, #id, .class, name, data-testid, data-test-id, data-test, data-cy, aria-label, value, placeholder, and title - so the same phrasing works whether your markup uses ids, classes, or test attributes. Naming an explicit attribute (by its "id" attr) pins the lookup to that attribute with a case-insensitive match. Every step also accepts I, we, or no pronoun, and (where shown) a not variant for the negative check.
Quick reference
| Step | What it does |
|---|---|
Then I should see "Welcome" | Assert that text is or is not present on the page. |
Then I should see text matching "^T\w+" | Assert that page text does or does not match a regex pattern. |
Then I should see "Active" in the "John Smith" row | Assert text is or is not inside a table row identified by text. |
Then I should see a "Username" element | Assert that an element with a given label exists (or does not). |
Then I should see a "uname" element by its "id" attr | Assert that an element, identified by attribute, exists (or does not). |
Then I should see "John Smith" in the "Username" element | Assert that an element found by its label contains (or not) text. |
Then I should see "John Smith" in the "uname" element by its "id" attr | Assert that an element found by attribute contains (or not) text. |
Then I should see text matching "\d{4}" in the "#year" element | Assert that an element's text does or does not match a regex. |
Then the "body" element should contain "color:white;" | Assert that an element has (or lacks) a computed CSS property. |
Then I should see 3 "li" elements | Assert the exact number of elements matching a CSS selector. |
Then the "Login" link should contain "/log-in" | Assert that a link located by visible text points to a URL. |
Then the "#about-us-id" link should contain "about" by attr | Assert that a link located by attribute points to a URL. |
Then the response should contain "Welcome visitor" | Assert that the full HTML response contains (or not) text. |
Then the response status code should be 200 | Assert the current page's HTTP status code is (or is not) a value. |
Steps in detail
Then I should see "Welcome"
The workhorse assertion: check that the given text appears anywhere in the page body. The positive form is web-first - it waits up to 5 seconds for the text to appear, so it tolerates content that arrives after an AJAX call or a client-side render. The negative form (should not see) takes an immediate snapshot of the body text.
When I press "Register"
Then I should see "Registration Done Successfully"
And I should not see "Access denied"
Then I should see text matching "^T\w+"
Check the page's rendered text (document.body.innerText) against a JavaScript regular expression. Use it for dynamic values - dates, order numbers, prices - where the exact text can't be hard-coded. Because it reads innerText, hidden elements are excluded.
Given I am on "/test--then--i-should--see-text-matching-pattern.html"
Then I should see text matching "^T\w+"
And I should see text matching "[a-z0-9]+@[a-z]+\.[a-z]{2,3}"
And I should not see text matching "^O\w+"
Then I should see "Active" in the "John Smith" row
Find every <tr> whose text contains the row identifier, then assert the expected text appears in (at least) one of them. This scopes the check to a single table row, so "Active" next to John Smith passes even when "Inactive" appears elsewhere in the table. Pairs naturally with When I click "Edit" in the "John Smith" row from the interaction steps.
Given I am on "/test--then--i-should-see-text-in-table-row.html"
Then I should see "Admin" in the "John Smith" row
And I should see "$85,000" in the "John Smith" row
And I should not see "Admin" in the "Jane Doe" row
Then I should see a "Username" element / ... element by its "id" attr
Existence checks. The label form locates the exact text on the page; if that text is a <label for="...">, the step follows the for attribute and asserts the associated control itself is visible - so I should see a "Username" element proves the input exists, not just its label. The attribute form goes through the selector registry: by attr alone tries id, class, name, data-testid and friends, while by its "id" attr pins it to one attribute. Both wait up to 3 seconds for visibility; the negative forms assert absence immediately.
Given I am on "/test--then--i-should--see-text-in-element.html"
Then I should see a "Username" element
And I should see a "uname" element by its "id" attr
And I should see a "pwordcss" element by attr
And I should not see an "emailId" element by its "id" attr
Then I should see "John Smith" in the "Username" element / ... element by its "id" attr
Scoped text checks: assert that a specific element contains (or does not contain) the expected text. The label form resolves <label for> the same way as the existence check, which makes it ideal for asserting the value shown in a form control. The attribute form accepts a CSS selector or a registry-expanded shorthand. For inputs, the element's value counts as its text, so you can verify pre-filled fields.
Given I am on "/test--then--i-should--see-text-in-element.html"
When I fill in "user1" for "Username"
Then I should see "user1" in the "Username" element
And I should see "1234" in the "pwordcss" element by attr
And I should not see "John Smith" in the "uname" element by its "id" attr
Then I should see text matching "\d{4}" in the "#year" element
Regex matching scoped to one element, located by CSS selector. This is the tool for format checks - timestamps, currencies, dates - inside a known container.
Given I am on "/date.html"
Then I should see text matching "(0?[1-9]|[12][0-9]|3[01])[\/\-](0?[1-9]|1[012])[\/\-]\d{4}" in the "#date" element
And I should see text matching "\$\d+\.\d{2}" in the "#price" element
And I should not see text matching "Error" in the "#status" element
Then the "body" element should contain "color:white;"
A CSS assertion, not a text assertion: the quoted string is parsed as property:value and compared against the element's computed style. Color values are normalized before comparison, so white, #fff, and rgb(255, 255, 255) all match each other - write whichever form your stylesheet uses. Shorthand values are token-matched, so border:solid 5px red; matches regardless of token order in the computed value. The trailing semicolon is optional.
Given I am on "/test--then--the-element-should_not-contain-cssproperty.html"
Then the "body" element should contain "background-color:white;"
And the "#uname" element should not contain "border:solid 5px red;"
And the "pword" element should not contain "font-size: 28px;"
Then I should see 3 "li" elements
Assert the exact count of elements matching a CSS selector - including compound selectors and attribute selectors. A count of 0 is a valid expectation for asserting absence. The failure message reports the actual count, and the singular/plural element/elements are both accepted. Note this counts immediately; if the list is still loading, precede it with When I wait until 3 elements match "li" from the wait steps, or use the auto-retrying should have a count of web-first matcher.
Given I am on "/element.html"
Then I should see 1 "h1" element
And I should see 13 "ul:first-of-type li" elements
And I should see 0 ".does-not-exist" elements
Then the "Login" link should contain "/log-in" / ... by attr
Verify where a link points without clicking it. The first form locates the link by its exact visible text; the by attr form locates it by CSS selector or by a named attribute (by its "class" attribute, by its "title" attribute, …). The check is a substring match against the link's resolved href, so "about" matches /about-us and absolute URLs alike.
Given I am on "/test--then--the-link-should-contain.html"
Then the "About us" link should contain "about"
And the "#aboutUsid" link should contain "about" by attr
And the "contactUsid" link should contain "/contact-" by its "id" attribute
And the "aboutUs" link should contain "about" by its "class" attribute
Then the response should contain "Welcome visitor"
Assert against the text of the entire <html> element rather than just the visible body. Reach for this when the text lives outside normal view: <head> content such as titles and JSON-LD, <noscript> blocks, or hidden regions. The negative form guards against content that must never ship (debug output, "TODO" markers).
Given I am on "/"
Then the response should contain "Welcome to the homepage"
And the response should not contain "TODO"
Then the response status code should be 200
Re-request the current URL and assert its HTTP status. Because the check issues a fresh GET (via axios) rather than reusing the browser's original response, it also works after client-side navigation, and non-2xx codes (404, 301, 500) are captured rather than thrown. Use it to confirm error pages really are errors and real pages really are 200s.
When I go to "/does-not-exist.html"
Then the response status code should be 404
And the response status code should not be 200
When I go to "/existing-page.html"
Then the response status code should be 200
Complete example
Adapted from the suite's own feature files, running against the bundled fixture pages in examples/.
Feature: Verify a user directory page
As a site owner
I want the user table, links, and page content verified
So that I know visitors see the right thing
Scenario: Page renders the expected content and structure
Given I am on "/test--then--i-should-see-text-in-table-row.html"
Then the response status code should be 200
And I should see "Admin" in the "John Smith" row
And I should see "john.smith@company.com" in the "John Smith" row
And I should not see "Admin" in the "Jane Doe" row
And I should see text matching "[a-z0-9.]+@[a-z]+\.[a-z]{2,3}"
Scenario: Login form has the expected fields and styling
Given I am on "/test--then--i-should--see-text-in-element.html"
Then I should see a "Username" element
And I should see a "uname" element by its "id" attr
And I should not see an "Eamil" element
And the "#uname" element should not contain "border:solid 5px red;"
When I fill in "user1" for "Username"
Then I should see "user1" in the "Username" element
Scenario: Navigation links point to the right pages
Given I am on "/test--then--the-link-should-contain.html"
Then the "About us" link should contain "about"
And the ".contactUs" link should contain "/contact-" by attr
And I should see 1 "h1" element
Tips
- Prefer an assertion over a sleep:
Then I should see "Done"already waits up to 5 seconds, andThen eventually I should see "Done" within 10 seconds(wait steps) polls even longer. A fixedI wait N secondsbefore an assertion is almost never needed. - Scope your checks.
I should see "Active"passes if the word appears anywhere;I should see "Active" in the "John Smith" rowor... in the "#status" elementproves it appears in the right place. - Negative checks are snapshots.
should not seeruns immediately, so if content is still loading it can pass spuriously - settle the page first withWhen I wait for AJAX to finishor a targeted disappear wait. - For state checks (visible, hidden, enabled, focused, count) with configurable
within N secondsbudgets, see the auto-retrying matchers on the web-first assertion steps page.