An assertion in E2E must withstand the interface's asynchrony, and this is exactly what distinguishes a web-first assertion from an ordinary comparison. A one-off read of state - take an element's text and compare it with a string - lands at a specific moment, and if the data has not arrived yet, the test fails not because the behavior is wrong but because the check was late or early. A web-first assertion is different: it retries the observation until the condition is met or the timeout expires.
The shape of such checks is recognizable: await expect(a locator or the page) and a matcher describing the expected state. expect(page).toHaveURL with a regular expression checks the address, toContainText and toHaveText the element's text, toBeVisible and toBeHidden visibility, toBeEnabled a button's availability. Each matcher retries automatically, so the test does not need to manually wait for an element to appear or disappear: the wait is built into the assertion itself.
The key property is safe retrying. The matcher does not read state once but polls the page again until it sees the expected. For async UI this is exactly reliability: a success message that arrived three hundred milliseconds after the click will be caught, not missed. This is why web-first assertions replace both manual waits and one-off checks - they combine waiting and assertion in one step.
It helps to gather a working set of matchers once, to describe the observable precisely rather than approximately. Below are the typical checks of one order-placement scenario: the address by pattern, the notification text, the total value, a hidden dialog, an enabled button. You return to this set when you need to express an expected state at a public boundary rather than digging into the app's internals.
For state not reflected in the UI directly there is expect.poll. It periodically runs an arbitrary function - a request to an API, a read from an external source - and checks its result with the same matcher, retrying until success or timeout. This is the web-first approach extended to non-DOM state: when readiness is expressed not by an element on the page but by a service response that needs polling.
A separate tool is soft assertions. An ordinary check stops the test on failure, and that is right for critical conditions. But sometimes you need to gather several discrepancies at once - for example, check that all fields of a card on the page are correct - and then the soft variant lets you continue and show all problems together. This is handy for the report, but the technique has a boundary that must not be crossed.
The boundary runs along the destructiveness of the next step. A soft assertion is appropriate while the scenario continues with safe checks after a discrepancy. But if a critical assertion failed and the next step is an irreversible action (payment, deletion, submission), you must not continue: the test will either corrupt data or act in a knowingly broken state. A critical failure must stop the scenario, not accumulate softly.
The typical assertion failures come down to losing retrying. A one-off read of textContent with manual comparison instead of a web-first matcher is a classic source of flakiness on async UI. expect.poll where readiness is perfectly expressed by an element on the page is needless complication. And soft assertions after which the scenario continues destructive actions past a critical failure. Describe the observable with a retrying matcher and stop at a critical discrepancy.
await expect(page).toHaveURL(/\/orders\/\d+$/)
await expect(page.getByRole('alert')).toContainText('Order created')
await expect(page.getByTestId('total')).toHaveText('1,490 ₽')
await expect(page.getByRole('dialog')).toBeHidden()
await expect(downloadButton).toBeEnabled()