Skip to content

Blog / Playwright

Playwright

Playwright Assertions: Web-First Checks That Actually Prove Something

The Playwright assertions worth knowing, the ones that pass no matter what, and how to check values that change on every run.

October 23, 2026 · Blendra Team · 12 min read

A green run is a claim that the feature works, and Playwright assertions are the part of the test that makes that claim. This guide goes through the matchers you will use every day, then asks one question of every pattern: can this assertion fail? Five common ones can't, and each has a short fix. You will also see how to check order numbers, amounts and dates that change on every run, and when to reach for expect.soft, expect.poll and toPass.

Every example comes from one small project: an 80-line Node server for a fake checkout page, plus five spec files. Blocks marked "Fragment" are excerpts from those files. All of it was run with @playwright/test 1.61.1 in October 2026.

Key takeaways

  • Await a web-first matcher on a locator (await expect(locator).toBeVisible()). It retries until the page agrees or the timeout ends.
  • Assert the outcome of an action, not something that was on the page before it.
  • Prove an assertion can fail: run it once against a broken build and watch it go red.
  • For values that change, anchor your regex (^…$) and make it as narrow as the real format.
  • Use expect.poll or toPass for conditions outside the DOM, and give toPass a timeout.

How Playwright assertions wait: web-first vs generic

Playwright's expect has two families of matchers, and the difference matters more than any single matcher.

Web-first (auto-retrying) matchers take a locator or a page: toBeVisible, toHaveText, toContainText, toHaveValue, toHaveCount, toBeChecked, toHaveURL, toHaveTitle and more. They are async, so you await them. Playwright re-reads the page until the condition holds or the expect timeout (5 seconds by default) runs out. The full list is on the assertions page of the Playwright docs.

Generic matchers take a plain value: toBe, toEqual, toContain, toBeTruthy, toMatch. They check once, right now, and never retry. They are fine for values you already hold, such as a parsed JSON body. They are a trap when the value comes from a page that is still changing.

Compare these two lines:

// Fragment: one-shot. Reads visibility once and compares a boolean.
expect(await page.getByText('Order FW-48213 confirmed').isVisible()).toBe(true);

// Fragment: web-first. Retries until the text is visible or 5 s pass.
await expect(page.getByText('Order FW-48213 confirmed')).toBeVisible();

The first one is flaky: if the confirmation shows up 50 ms after the check, it fails. Playwright's own best practices call out exactly this pattern. The second one waits for the app, and when it fails, the error tells you what it looked for:

Error: expect(locator).toBeVisible() failed

Locator: getByRole('region', { name: 'Order summary' })
Expected: visible
Timeout: 10000ms
Error: element(s) not found

That message is real output from a broken version of the examples below. If flaky failures are what brought you here, the companion guide on fixing flaky Playwright tests covers the rest of the causes.

The core matchers and what each one proves

This test runs on its own with page.setContent, so you can paste it into any Playwright project:

import { test, expect } from '@playwright/test';

test('core matchers on a small form', async ({ page }) => {
  await page.setContent(`
    <h1>Newsletter</h1>
    <label>Email <input name="email"></label>
    <label><input type="checkbox" name="weekly"> Weekly digest</label>
    <button>Subscribe</button>
    <ul aria-label="Topics"><li>Playwright</li><li>Cypress</li><li>pytest</li></ul>
    <p role="status"></p>
    <script>
      document.querySelector('button').addEventListener('click', () => {
        setTimeout(() => {
          document.querySelector('[role=status]').textContent =
            'Subscribed ' + document.querySelector('input[name=email]').value;
        }, 500);
      });
    </script>
  `);

  const email = page.getByLabel('Email');
  await email.fill('ana@example.com');
  await expect(email).toHaveValue('ana@example.com');

  const weekly = page.getByLabel('Weekly digest');
  await weekly.check();
  await expect(weekly).toBeChecked();

  await expect(page.getByRole('list', { name: 'Topics' }).getByRole('listitem')).toHaveCount(3);

  await page.getByRole('button', { name: 'Subscribe' }).click();
  // The message appears 500 ms later; both assertions retry until it does.
  await expect(page.getByRole('status')).toBeVisible();
  await expect(page.getByRole('status')).toHaveText('Subscribed ana@example.com');
});

Here is what each matcher really proves, and what it doesn't:

Matcher Proves Does not prove
toBeVisible() The element is attached, has a non-empty box and is not visibility: hidden That a person can see it: opacity: 0 still counts as visible
toHaveText('…') The whole text equals the string, after whitespace is normalised Anything about other elements with similar text
toContainText('…') The text includes the string That nothing else is in the text
toHaveValue('…') The input's current value That the app saved it
toHaveCount(n) Exactly n matches Which items they are
toBeChecked() The checkbox or radio is checked That the app reacted to it
toHaveURL(…) The page address That the page content loaded

Two details from that table catch people out. First, the visibility rule comes from Playwright's actionability definition: an element with opacity: 0 passes toBeVisible(), while display: none and zero-size elements do not. Second, a fill followed by toHaveValue with the same string mostly proves that typing works. It says nothing about what the app did with the value, so assert the effect (a saved message, a new row) instead.

Other everyday matchers work the same way: toBeEnabled, toHaveAttribute, toHaveClass, toMatchAriaSnapshot, and toBeOK for an API response. The LocatorAssertions reference lists them all.

Five assertions that can never fail

The simplest way to judge an assertion is to break the app and run it. The example shop has a switch for that: /checkout?bug=1 makes payment fail. There is no confirmation, and "Payment failed. Try again." appears 600 ms after the click. A useful assertion must go red on that page. These five stay green.

Comparison of assertions that check the outcome of an action and assertions that cannot fail

All five run after the same helper, which opens the page and clicks Place order:

// Fragment from tests/never-fail.spec.ts
async function placeOrder(page: Page, url: string) {
  await page.goto(url);
  await page.getByRole('button', { name: 'Place order' }).click();
}

1. isVisible() without await

// Fragment. Broken: passes on the failing checkout.
expect(page.getByText(/Order .* confirmed/).isVisible()).toBeTruthy();

// Fixed
await expect(page.getByText(/^Order FW-\d{5} confirmed$/)).toBeVisible();

isVisible() returns a Promise. Without await, toBeTruthy() checks the Promise object, and every object is truthy. With await, it still checks only once, so it becomes flaky instead of useless. The fix is the web-first matcher.

2. An element that is always on the page

// Fragment. Broken: the button was there before the click.
await expect(page.getByRole('button', { name: 'Place order' })).toBeVisible();

// Fixed: check what the click changed
await expect(page).toHaveURL(/\/orders\/FW-\d{5}$/);
await expect(page.getByRole('region', { name: 'Order summary' })).toBeVisible();

Recorders produce this one a lot. It proves the page loaded, not that the action worked. Ask "was this already true before the step?" If yes, it is not the step's outcome.

3. toBeTruthy() on a locator

// Fragment. Broken: a Locator is an object, so it is always truthy.
expect(page.getByText('confirmed')).toBeTruthy();

// Fixed
await expect(page.getByText('confirmed')).toBeVisible();

Creating a locator doesn't touch the page at all. It is a description of how to find an element later. Only an awaited matcher, or an action, makes Playwright look.

4. A negative check that runs before the error appears

// Fragment. Broken: passes at once; the error is drawn 600 ms later.
await expect(page.getByRole('alert')).toBeHidden();

// Fixed: wait for the outcome first, then check the absence
await expect(page.getByTestId('order-number')).toHaveText(/^Order FW-\d{5} confirmed$/);
await expect(page.getByRole('alert')).toBeHidden();

toBeHidden() and not.toBeVisible() retry too, but they retry until the element is gone. Right after the click it hasn't appeared yet, so they pass on the first try. The same trap hides in "wait for the spinner to disappear": if the spinner hasn't shown yet, it is already "hidden".

When there is no positive outcome to wait for, wait for the server's answer instead. This is weaker, because the UI still has to render, but it is far better than checking at 0 ms:

// Fragment from tests/never-fail.spec.ts
const created = page.waitForResponse(
  (r) => r.url().endsWith('/api/orders') && r.request().method() === 'POST',
);
await page.getByRole('button', { name: 'Place order' }).click();
await created;
await expect(page.getByRole('alert')).toBeHidden();

5. A regex that matches text you did not mean

// Fragment. Broken: "Place order" already contains "order".
await expect(page.getByRole('main')).toContainText(/order/i);

// Fixed: the right element, anchored, specific
await expect(page.getByTestId('order-number')).toHaveText(/^Order FW-\d{5} confirmed$/);

A wide container plus a loose pattern will find a match somewhere. Point at the element that carries the outcome, and make the pattern describe the whole text.

Prove the fixes can fail

The spec file runs each fix twice: once on the healthy page, where it must pass, and once on the broken page inside test.fail(). test.fail() marks a test as expected to fail, so the run stays green only if the assertion really catches the bug:

  ✓ five assertions that pass on the broken checkout › 1. isVisible() without await (186ms)
  ✓ five assertions that pass on the broken checkout › 2. an element that is always on the page (123ms)
  ...
  ✓ the fixes pass on the healthy checkout › 1. web-first toBeVisible (932ms)
  ...
  ✘ the fixes fail on the broken checkout › 1. web-first toBeVisible (10.1s)
  ✘ the fixes fail on the broken checkout › 2. assert what the click changed (10.1s)
  ...
  27 passed (58.2s)

The ✘ rows are expected failures, which is why the whole run still reports 27 passed. Note that strict TypeScript compiles all five broken versions without a complaint. You don't need this harness for every test. Do it once for a new kind of check, or whenever you are not sure a check is pulling its weight.

Values that change on every run

Order numbers, totals, timestamps and ids are where assertions get either too strict (they fail tomorrow) or too loose (they pass on garbage). Some rules that hold up:

Anchor and narrow the regex. With toHaveText, an unanchored regex behaves like "contains". /Order FW-\d{5}/ passes on "Order FW-48213 cancelled". Use ^ and $, and the real shape of the id:

// Fragment from tests/dynamic-values.spec.ts
await expect(page.getByTestId('order-number')).toHaveText(/^Order FW-\d{5} confirmed$/);

Mind whitespace with regexes. For a string, Playwright normalises whitespace before comparing. For a regex, it matches the text as it is. Against Total&nbsp;$5.00, the string 'Total $5.00' passes and the regex /^Total \$5\.00$/ fails. An anchored regex also fails when the markup puts a line break inside the text. Use \s+ where the page may wrap or use a no-break space:

// Fragment from tests/core-matchers.spec.ts
const total = page.locator('#total'); // <p id="total">Total&nbsp;$5.00</p>
await expect(total).toHaveText('Total $5.00'); // string: normalised, passes
await expect(total).not.toHaveText(/^Total \$5\.00$/, { timeout: 1_000 }); // regex: raw text
await expect(total).toHaveText(/^Total\s+\$5\.00$/); // \s also matches the no-break space

Prefer exact values you control. If the test data fixes the amount, assert the amount. Use a pattern only for values the app invents. When you do need a pattern for money, escape $ and . and require the cents, so $NaN and $undefined can't sneak through the way they would with /\$.*/:

// Fragment from tests/dynamic-values.spec.ts
await expect(page.getByTestId('summary-total')).toHaveText('Total $1,234.50');
await expect(page.getByTestId('summary-total')).toHaveText(/^Total \$\d{1,3}(,\d{3})*\.\d{2}$/);

Freeze the clock instead of matching any date. page.clock.setFixedTime pins Date.now() and new Date() in the page (timers keep running), so the date becomes an exact string:

// Fragment from tests/dynamic-values.spec.ts
await page.clock.setFixedTime(new Date('2026-10-22T10:00:00'));
await page.goto('/checkout');
await page.getByRole('button', { name: 'Place order' }).click();
await expect(page.getByTestId('summary-placed')).toHaveText('Placed on 22 Oct 2026');

If you must match a date pattern, test it against every month. In current Chromium and in Node 24 (both ship recent ICU data), toLocaleDateString('en-GB', { month: 'short' }) writes September as "Sept", four letters, so [A-Z][a-z]{2} would fail once a year. [A-Z][a-z]{2,3} doesn't.

Reuse the value the app generated. Read the id once, then check that it shows up everywhere it should:

// Fragment from tests/dynamic-values.spec.ts
const toast = page.getByTestId('order-number');
await expect(toast).toHaveText(/^Order FW-\d{5} confirmed$/);
const orderId = (await toast.textContent())!.match(/FW-\d{5}/)![0];
await expect(page).toHaveURL(new RegExp(`/orders/${orderId}$`));

This catches a bug a pattern alone never would: the confirmation and the address bar showing two different orders. For toasts that vanish after a few seconds, see testing toast messages without flaky timing.

Playwright soft assertions: when collecting failures helps

expect.soft records a failure and lets the test carry on. That suits a panel of independent facts, where you want all the wrong values in one report rather than one per run:

// Fragment from tests/soft-and-poll.spec.ts
const summary = page.getByRole('region', { name: 'Order summary' });
await expect(summary).toBeVisible(); // hard: without the panel, the rest is noise

await expect.soft(page.getByTestId('summary-items')).toHaveText('2 items');
await expect.soft(page.getByTestId('summary-total')).toHaveText('Total $1,234.50');
await expect.soft(page.getByTestId('summary-placed')).toHaveText(/^Placed on \d{1,2} [A-Z][a-z]{2,3} \d{4}$/);

expect(test.info().errors).toHaveLength(0); // stop before doing more work on a bad order

Keep the gatekeeper hard. If the panel never appears, three soft failures say the same thing three times. And don't make a whole flow soft: once a step fails, later steps fail for the wrong reason and bury the first error.

Conditions outside the page: expect.poll and toPass

Some outcomes are not in the DOM: an API that becomes ready, a row in a database, a file on disk. expect.poll turns any async function into a retrying assertion. Its default intervals are 100, 250, 500 and then 1000 ms, and its timeout defaults to your expect timeout (5 seconds unless you change it in the config):

// Fragment from tests/soft-and-poll.spec.ts
await expect
  .poll(async () => (await request.get(`/api/orders/${orderId}`)).status(), {
    message: `order ${orderId} never became readable`,
    timeout: 10_000,
  })
  .toBe(200);

toPass retries a whole block, which helps when you have to do something before you can check, like clicking a refresh button on a page that never updates by itself:

// Fragment from tests/soft-and-poll.spec.ts
await expect(async () => {
  await page.getByRole('button', { name: 'Refresh status' }).click();
  await expect(page.getByTestId('payment-status')).toHaveText('Paid', { timeout: 1_000 });
}).toPass({ timeout: 10_000 });

Two warnings. toPass has a default timeout of 0, which means it retries until the test itself times out, and it ignores the expect timeout from your config, so always pass timeout, or set a project default with expect: { toPass: { timeout: 10_000 } } in playwright.config.ts. And keep the inner assertion's timeout short, or one inner wait eats the whole budget.

Use neither of them for things a web-first matcher already handles. expect.poll(() => locator.textContent()) is a slower, less helpful toHaveText.

Timeouts: per assertion and in the config

Each web-first matcher has its own timeout, separate from the test timeout (30 seconds by default, per the timeouts page). Raise the default for the whole project in playwright.config.ts:

import { defineConfig, devices } from '@playwright/test';

export default defineConfig({
  testDir: './tests',
  reporter: 'list',
  expect: {
    // Every auto-retrying assertion waits up to 10 s (the default is 5 s).
    timeout: 10_000,
  },
  use: {
    ...devices['Desktop Chrome'],
    baseURL: 'http://127.0.0.1:4319',
  },
  webServer: {
    command: 'node server.mjs',
    url: 'http://127.0.0.1:4319/checkout',
    reuseExistingServer: !process.env.CI,
  },
});

Or raise it for one slow step, with a custom message that shows up in the report:

// Fragment from tests/soft-and-poll.spec.ts
await expect(
  page.getByTestId('order-number'),
  'confirmation after Place order',
).toHaveText(/^Order FW-\d{5} confirmed$/, { timeout: 15_000 });

Raise timeouts for known slow operations, not to quiet a flaky test. If a check needs 30 seconds on a fast machine, the wait is hiding a bug or a missing outcome to wait for.

A pre-merge checklist for assertions

Before you merge a new test, read each assertion and ask:

  1. Is every web-first matcher awaited? A missing await on expect(locator) or on isVisible() silently turns the check into nothing.
  2. Was this already true before the step? If so, it isn't the step's outcome.
  3. Does every negative check run after a positive signal? Outcome first, absence second.
  4. Is every regex anchored and narrow? Could it match an error message, an empty string or $NaN?
  5. Does a value the test typed get checked only by reading it back? Check what the app did with it instead.
  6. Does every toPass have a timeout, inline or from expect.toPass in the config?
  7. Have you seen it fail? Break the app, or edit the expected value, and run it once.

The community eslint-plugin-playwright has rules aimed at some of these mistakes, for example missing-playwright-await, prefer-web-first-assertions, require-to-pass-timeout and no-unnecessary-assertions. A linter catches the syntax; only the "have you seen it fail" step catches a check that looks fine and proves nothing.

How Blendra turns what the app showed into checks

Most of the mistakes above come from guessing what to assert. Blendra starts from evidence instead. You record a flow in Chrome, and Blendra lists what the app showed after each step: toasts, validation messages, dialogs, status text and URL changes. You tick the outcomes that matter, and each tick becomes a check with the real value the app showed.

Values that change, such as numbers, amounts, dates, times, ids, UUIDs and emails, become patterns (the app's Pattern match, next to Exact and Contains), so Order FW-‹number› passes for any order number and still fails when the confirmation is missing. A built-in linter flags checks that can't fail or can't pass. For example, it warns when a check only looks at the element the next step clicks, because that proves the page loaded, not what the click did (case 2 above), and it flags a pattern so loose it would pass for almost any message.

When you export to Playwright TypeScript, each check becomes a call to a generated helper that runs Playwright's web-first expect, with its plain sentence as a comment above it. Playwright Python and Cypress exports are in beta, on the Pro+ and Team plans. The docs explain how checks appear in exported code and how to add one by hand.

FAQ

Should I use toHaveText or toContainText?

Use toHaveText when you know the whole text, because it also fails on extra words such as "Order FW-48213 cancelled". Use toContainText when the element holds other text you don't control, like a paragraph with a name in it. With a regex, toHaveText only demands the whole text if you anchor it with ^ and $.

Why does not.toBeVisible pass too early?

It retries until the element is not visible, and an element that hasn't been rendered yet is not visible. So right after an action it passes at once. Wait for something the action is sure to produce (a confirmation, a URL change, a response), then check the absence.

Does toBeVisible mean a user can see the element?

No. It means the element is attached, has a non-empty bounding box and isn't visibility: hidden. An element with opacity: 0, or one covered by a modal, still passes. If being on screen matters, look at toBeInViewport(), or assert something the user would actually see change.

Can I add custom matchers?

Yes, with expect.extend. Keep a custom matcher retrying by building it on expect.poll or on a web-first matcher, otherwise it becomes a one-shot check again. This one reads a money string and compares it in cents, so $1,234.50 and $1234.50 both equal 123450:

import { test, expect as baseExpect, type Locator } from '@playwright/test';

const expect = baseExpect.extend({
  async toShowAmount(locator: Locator, cents: number, options?: { timeout?: number }) {
    const name = 'toShowAmount';
    let actual: number | undefined;
    let pass: boolean;
    try {
      await baseExpect
        .poll(
          async () => {
            const text = (await locator.textContent()) ?? '';
            const m = text.match(/\$([\d,]+)\.(\d{2})/);
            actual = m ? Number(m[1].replace(/,/g, '')) * 100 + Number(m[2]) : undefined;
            return actual;
          },
          { timeout: options?.timeout ?? 5_000 },
        )
        .toBe(cents);
      pass = true;
    } catch {
      pass = false;
    }
    return {
      name,
      pass,
      expected: cents,
      actual,
      message: () =>
        `${this.utils.matcherHint(name, undefined, undefined, { isNot: this.isNot })}\n\n` +
        `Expected: ${this.isNot ? 'not ' : ''}${cents} cents\nReceived: ${actual ?? 'no amount'}`,
    };
  },
});

test('custom matcher for amounts', async ({ page }) => {
  await page.goto('/checkout');
  await page.getByRole('button', { name: 'Place order' }).click();
  await expect(page.getByTestId('summary-total')).toShowAmount(123450);
  await expect(page.getByTestId('summary-total')).not.toShowAmount(999, { timeout: 1_000 });
});

It runs against the example checkout page. Note that with .not, a poll-based matcher waits for its full timeout before it can pass, so give negative calls a short one.

How many assertions should one test have?

As many as the outcome needs, and no more. One flow test can check the confirmation, the URL and the total. What matters is that each one can fail on its own and points at a different thing that could break.

AssertionsPlaywrightTest designTypeScript

Start with the flow you test by hand every release.

Start free →