The Playwright Assertion Guide: Web-First expect Matchers, Soft Assertions, expect.poll, toPass, Snapshots and Custom Matchers
An assertion that reads the value with textContent() and compares it with toBe does not retry; in Bookstore it reads the total before the coupon applies and fails. With real runs we cover the web-first matcher catalog, the difference between toHaveText and toContainText, expect.soft, expect.poll and toPass, toMatchAriaSnapshot and a custom matcher that keeps the money format in one place; the .NET equivalents are in a table.
Version note: The code in this post was built and run with .NET 10, Angular 22.2, Playwright 1.65 (@playwright/test) and Microsoft.Playwright 1.64.1.
Guess First
In Bookstore, the online bookstore used as the shared example across this site's series, the cart holds two copies of Dune and one Emma: the subtotal is 169.30 TRY. The test applies a 10% coupon and checks the total:
await cart.applyCoupon(coupon);
expect(await cart.total.textContent()).toBe('Total: ₺152.37');
applyCoupon fills the coupon box and presses "Apply"; both are awaited. The coupon is valid and the expected amount is right: 169.30 − 16.93 = 152.37. Does the test pass?
The Answer: A Generic Assertion Looks Once
Error: expect(received).toBe(expected) // Object.is equality
Expected: "Total: ₺152.37"
Received: "Total: ₺169.30"
The coupon is validated by an HTTP request that Angular sends to the API, and the total changes only when the response arrives. click returns once the button is pressed; textContent() reads the text at that moment and toBe compares that string once. The value is still the old one. The correct version of the same check is a single line:
await expect(cart.total).toHaveText('Total: ₺152.37');
Post 6 showed this distinction with count(): if you give expect a value you get a generic assertion, if you give it a locator or a page you get a web-first assertion. The generic ones (toBe, toEqual, toMatchObject, toContain…) are the run-once matchers you know from Jest. The web-first ones re-evaluate the locator until the condition holds or the timeout expires. This post covers the catalog of the second group, the checks that retry (expect.soft, expect.poll, toPass) and writing your own matcher, using Bookstore's cart.
The Web-First Matcher Catalog
It helps to split the matchers for locators and pages into three groups: state (is it visible, is it enabled), content (text, value, attribute, count) and page (address, title). Each one's .NET counterpart is the same name with an Async suffix; the ones below were compiled against Microsoft.Playwright 1.64.1:
| Playwright Test | .NET | When |
|---|---|---|
toBeVisible, toBeHidden | ToBeVisibleAsync, ToBeHiddenAsync | the element is shown or gone |
toBeEnabled, toBeDisabled, toBeChecked | ToBeEnabledAsync, ToBeDisabledAsync, ToBeCheckedAsync | the state of a form element |
toHaveText, toContainText | ToHaveTextAsync, ToContainTextAsync | the whole text or a part of it |
toHaveValue, toHaveAttribute | ToHaveValueAsync, ToHaveAttributeAsync | input value, attribute |
toHaveCount | ToHaveCountAsync | the number of matching elements |
toHaveURL, toHaveTitle | ToHaveURLAsync, ToHaveTitleAsync | page address and title |
toMatchAriaSnapshot | ToMatchAriaSnapshotAsync | the structure of the accessibility tree |
expect.soft, expect.poll, toPass, expect.extend | none | in .NET, with the test framework's tools |
The pair people mix up most is toHaveText and toContainText. Given a string, toHaveText looks for the element's whole text and toContainText for a part of it. Both normalize whitespace and line breaks, and both have ignoreCase and useInnerText options. In a real run the difference looks like this:
await expect(cart.total).toContainText('₺152.37');
await expect(cart.total).toHaveText('₺152.37', { timeout: 2_000 });
Error: expect(locator).toHaveText(expected) failed
Locator: getByText(/^Total:/)
Expected: "₺152.37"
Received: "Total: ₺152.37"
Timeout: 2000ms
Call log:
- Expect "toHaveText" getByText(/^Total:/) with timeout 2000ms
- waiting for getByText(/^Total:/)
21 × locator resolved to <p class="total">Total: ₺152.37</p>
- unexpected value "Total: ₺152.37"
The first line passed; the second tried 21 times over two seconds and failed. My default is toHaveText, so that a word added to the text later (Total (incl. VAT):, say) does not slip past the test. I use toContainText to check the fixed part next to a part that varies. When the locator resolves to several elements both take an array: toHaveText requires the number of elements to equal the length of the array and each element to match exactly, in order; toContainText requires each expected part to be found in an element, in order.
Every matcher can be inverted with .not, and the inverse waits too: await expect(page.getByRole('alert')).not.toBeVisible() retries until the alert disappears. The default timeout is 5 seconds. For a single assertion use the { timeout } option, for the whole suite expect: { timeout } in the config, and for a group of assertions expect.configure({ timeout }). On the .NET side, Assertions.SetDefaultExpectTimeout does the same job.
Bookstore: Cart Count and Total
The test that checks the whole cart screen verifies, after three clicks in the catalog, the count in the header, the cart table, the subtotal and the total after the coupon:
test('the cart count and total follow the cart and the coupon', async ({ data, catalog, cart, page }) => {
const dune = await data.book('Dune', 64.9);
const emma = await data.book('Emma', 39.5);
const coupon = await data.coupon(10);
await catalog.open();
await catalog.search(data.suffix);
await catalog.addToCart(dune.title);
await catalog.addToCart(dune.title);
await catalog.addToCart(emma.title);
await expect(page.getByRole('link', { name: 'Cart (3)' })).toBeVisible();
await cart.open();
await expect(page).toHaveURL('/cart');
await expect(page).toHaveTitle('Cart | Bookstore');
await expect(cart.lines).toHaveCount(2);
await expect(cart.lines).toContainText([dune.title, emma.title]);
await expect(cart.subtotal).toHaveText('Subtotal: ₺169.30');
await expect(page.getByRole('table')).toMatchAriaSnapshot(`
- table:
- rowgroup:
- row "Book Quantity Price"
- rowgroup:
- row /Dune .* 2 ₺129\\.80/
- row /Emma .* 1 ₺39\\.50/
`);
await cart.applyCoupon(coupon);
// The coupon is validated with an HTTP request; the total changes when the response arrives, and the assertion waits for that
await expect(cart.total).toHaveText('Total: ₺152.37');
await expect(cart.total).toShowPrice(152.37);
await expect(page.getByLabel('Coupon code')).toHaveValue(coupon);
});
cart.lines and cart.subtotal are two locators added in this post to the CartPage from post 7: to leave out the header row, lines takes the rows that contain cells. toHaveURL('/cart') resolves the relative address against the baseURL in the config.
toMatchAriaSnapshot arrived in 1.49 and compares the table's structure in the accessibility tree as YAML. The template is partial: I did not write the children of the rows, and it still matches. Because the book titles carry a unique suffix in every test, I wrote the row names as regexes. Instead of writing a template from scratch it is faster to print the output of await locator.ariaSnapshot() and trim it; in Bookstore the output listed the table's three rows, the column headers and the cells. Visual comparison (toHaveScreenshot) was covered in post 8; unlike that, an aria snapshot looks at structure rather than pixels and is not affected by platform differences.
Soft Assertions: Not Stopping at One Failure
The order page shows a summary: status, amount, shipment tracking number. Each of these fields is a separate piece of information; a failure in one should not prevent checking the others. When expect.soft fails it does not stop the test; it records the failure and marks the test as failed at the end:
await expect(page).toHaveURL(/\/orders\/\d+$/);
await expect(page).toHaveTitle('Order | Bookstore');
await expect.soft(page.getByRole('heading', { level: 1 })).toHaveText('Order placed');
await expect.soft(page.getByRole('definition').nth(1)).toShowPrice(129.8);
await expect.soft(page.getByRole('definition').nth(2)).toHaveText('TRK-E2E-1');
A soft assertion has a cost: the test goes on and the next steps run in a wrong state. In an attempt where I produced a payment failure with page.route, the order could not be placed, the soft assertion failed and the test moved on to the next step:
Error: expect(locator).toHaveText(expected) failed
Locator: getByRole('heading', { level: 1 })
Expected: "Order placed"
Received: "Cart"
...
TimeoutError: locator.click: Timeout 2000ms exceeded.
Call log:
- waiting for getByRole('button', { name: 'Cancel order' })
The report has two failures and the second one is misleading: the "Cancel order" button is not really missing, the test never reached the order page. As a rule I write the checks that establish where you are (address, title) as normal assertions and the independent fields on the same page as soft ones. If continuing after the soft checks makes no sense, an expect(test.info().errors).toHaveLength(0) line stops the test there.
Checks That Retry: expect.poll and toPass
Web-first assertions exist only for locators and pages. To wait for a value that is not on the page, for example the stock the API reports after an order, expect.poll calls a function again and again until the matcher passes:
// Read a value that is not on the page from the API again and again until it changes
await expect
.poll(async () => (await (await adminApi.get(`/api/books/${dune.id}`)).json()).stock, {
message: 'stock after the order',
})
.toBe(8);
The default timeout is 5 seconds and the waits between attempts are 100, 250, 500 and 1000 milliseconds; intervals changes them. Bookstore reduces the stock inside the order request; by the time the page moves to the order screen the value is already up to date. When the stock update moves to a background job or a message, the test will keep working unchanged.
toPass, in turn, retries a whole block: when several assertions must hold together, or when an action also has to be repeated before the check:
// Use toPass when several checks or actions must be retried together; its default timeout is 0, so set one
await expect(async () => {
const response = await adminApi.get(`/api/books/${dune.id}`);
expect(response.status()).toBe(200);
expect(await response.json()).toMatchObject({ title: dune.title, stock: 8 });
}).toPass({ intervals: [250, 500, 1_000], timeout: 10_000 });
The detail the documentation warns about matters: the default timeout of toPass is 0 and it ignores the expect timeout in the config. Without a timeout the block can keep retrying until the test timeout expires. Do not use web-first assertions inside the block: the inner assertion fails only after filling its own timeout, and the outer retry adds to that. The whole order test passed in 1.6 seconds.
Your Own Matcher: expect.extend
Bookstore shows prices with Angular's currency pipe, the en-US locale and the narrow symbol of the lira: "₺152.37". Writing strings with "₺" and a dot into tests means changing hundreds of lines when the format changes. A matcher that keeps the format in one place:
import { expect as baseExpect, type ExpectMatcherState, type Locator } from '@playwright/test';
// Same as the application's money format: Angular's currency pipe (en-US, TRY, symbol-narrow)
const money = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'TRY', currencyDisplay: 'narrowSymbol' });
export function formatPrice(amount: number): string {
return money.format(amount);
}
export const priceMatchers = {
// Stays web-first: the inner toContainText retries until the timeout expires
async toShowPrice(this: ExpectMatcherState, locator: Locator, amount: number, options?: { timeout?: number }) {
const expected = formatPrice(amount);
const assertion = this.isNot ? baseExpect(locator).not : baseExpect(locator);
try {
await assertion.toContainText(expected, { timeout: options?.timeout ?? this.timeout });
return { pass: !this.isNot, name: 'toShowPrice', message: () => `${locator} shows ${expected}` };
} catch (error) {
const actual = (error as { matcherResult?: { actual?: unknown } }).matcherResult?.actual;
return {
pass: this.isNot,
name: 'toShowPrice',
expected,
actual,
message: () =>
`${this.utils.matcherHint('toShowPrice', undefined, undefined, { isNot: this.isNot })}\n\n` +
`Locator: ${locator}\n` +
`Expected: ${this.isNot ? 'not ' : ''}${this.utils.printExpected(expected)}\n` +
`Received: ${this.utils.printReceived(actual)}`,
};
}
},
};
fixtures.ts now exports the result of baseExpect.extend(priceMatchers) instead of the expect from @playwright/test; the tests get the new matcher through the same import and TypeScript knows about it. Because the matcher calls a web-first assertion internally, the retry behavior is preserved. When it fails with a wrong amount, the output looks like that of the built-in matchers:
Error: expect(received).toShowPrice(expected)
Locator: getByText(/^Total:/)
Expected: "₺150.00"
Received: "Total: ₺152.37"
If the application one day switches to the tr-TR locale, only the Intl.NumberFormat line changes: with tr-TR the amount 1250 is formatted as "₺1.250,00". There is no counterpart of expect.extend on the .NET side; the same idea is built as an extension method on ILocator that calls Expect(locator).ToContainTextAsync internally. For soft assertions you need xUnit's Assert.Multiple, and for poll and toPass a bounded loop.
In Production
Before raising the default expect timeout, find out why you are waiting. If the CI runner is slow, 5 seconds can be tight and 10 seconds in the config is a reasonable value; but if a single assertion regularly gets close to 5 seconds, the problem is usually in the application or in the test data. Giving that one assertion its own timeout for a long step is better than slowing down the failures of the whole suite.
Keep aria snapshot templates small. A snapshot of the whole page breaks with every text change, and nobody in a review reads a 200-line YAML diff. Pick a region whose structure matters, such as a table, a menu or a form, and write the varying texts as regexes.
Custom matchers become the language of the suite. The ones that gather one rule of the application (the money format) in a single place, like toShowPrice, are valuable. A matcher used in a single test that hides a two-line check, on the other hand, forces the reader to go to its definition.
Common Pitfalls
expect without await. A web-first assertion returns a promise; if it is not awaited, the line moves on immediately and the check continues in the background. In my attempt Playwright caught it at the end of the test, and the last line of the call log was - Test ended.. But the failure is no longer tied to the moment of that line, and the outcome can depend on timing. The missing-playwright-await rule of eslint-plugin-playwright shows this while the code is being written.
Reading the value with textContent(), inputValue() or isVisible() and making a generic assertion. That is exactly what the test in the guessing section did: the value is read once, there is no retry. The prefer-web-first-assertions rule of the same plugin flags this pattern.
Using soft assertions everywhere. If a check that says the page is in the wrong place is soft, the test goes on and the later failures in the report hide the real cause. When reading the report, always start with the first failure.
Leaving toPass without a timeout, or putting web-first assertions inside it. In the first case the block spins until the test timeout; in the second, every failed attempt first fills its own 5 seconds and the total time multiplies.
A Small Experiment
★☆☆ Replace the toHaveText('Subtotal: ₺169.30') line in the cart test with toContainText('169.30') and add a third Dune to the cart. Which assertion caught the difference and which one missed it?
★★☆ Write a test that uses toShowPrice with .not: verify that after the coupon is applied the total no longer shows the subtotal. Does the this.isNot branch give the right message?
★★★ Replace the three soft assertions on the order page with a single toMatchAriaSnapshot. Write the values that change, such as the tracking number and the reference, as regexes and compare the failure output of the two approaches.
The tests in this post used the adminApi, data, catalog and cart fixtures, set timeouts one by one and depended on the setup project. In the next post we move to the place where all of these are defined, playwright.config.ts, and to writing fixtures: projects, timeout layers, test- and worker-scoped fixtures, test.step, tags and reporters.