Playwright Assertions: Complete Matcher Reference

A Playwright assertion is expect(target).matcher(expected). The target picks the matcher family: a locator or page gets web-first matchers that retry until they pass or the 5-second assertion timeout expires; an API response gets toBeOK(); a plain value gets Jest-style matchers that run once. This page lists every matcher by target, with the rules that trip people up.
Updated for Playwright 1.62, with every signature checked against the official API pages on August 30, 2026. It is part of our UI testing guide.
| You pass | Matcher family | Retries | Must await | Typical use |
|---|---|---|---|---|
Locator | toBeVisible, toHaveText, toHaveCount, and 24 more | Yes, until it passes or times out | Yes | Element state, text, attributes, lists, screenshots |
Page | toHaveTitle, toHaveURL, toHaveScreenshot, toMatchAriaSnapshot | Yes | Yes | Navigation, document title, full-page visuals |
APIResponse | toBeOK | No, the response is already complete | Yes | Status 200 to 299 |
| Any value | toBe, toEqual, toContain, toThrow, and the rest | No, runs once | Only with .resolves, .rejects, or expect.poll | Parsed JSON, counts, computed values |
Value plus expect.poll | Any generic matcher | Yes, re-runs the function | Yes | Values that become true later |
What are Playwright assertions?
An assertion is the line that decides whether a test passed. You write expect(received).matcher(expected), where received is the thing under test and the matcher is the claim you are making about it.
What you pass to expect() matters more than which matcher you reach for. Playwright reads the received value and returns a different assertion object for each kind: a Locator gets LocatorAssertions, a Page gets PageAssertions, an APIResponse gets APIResponseAssertions, and any other value gets GenericAssertions. Your editor only offers the matchers that exist for that target, and the target also decides whether the check retries.
That rule is the whole reference. Learn the four families and you can guess where a matcher lives before you look it up. If you are still picking a framework, Playwright vs Cypress compares the two assertion models.
Choose an assertion by target
The table above is the decision. Every section below expands one row of it.
Web-first assertions re-fetch the element and re-check the condition until it holds or the assertion timeout runs out, 5,000 ms by default. They are asynchronous, so they have to be awaited. Generic assertions on plain values run once, on the value you already hold.
Here is one test that uses four of the five rows. Save it as tests/assertions.spec.ts in a fresh Playwright project and run it.
import { test, expect } from '@playwright/test';
test('the installation guide is published in the UI and over HTTP', async ({ page, request }) => {
const patientExpect = expect.configure({ timeout: 10_000 });
await page.goto('https://playwright.dev/docs/intro');
await patientExpect(page).toHaveURL(/\/docs\/intro$/);
await patientExpect(page).toHaveTitle(/Installation \| Playwright/);
const heading = page.getByRole('heading', { name: 'Installation', exact: true });
await expect.soft(heading, 'installation heading is visible').toBeVisible();
await expect(page.getByText('Page Not Found', { exact: true })).toHaveCount(0);
const response = await request.get('https://playwright.dev/docs/intro');
await expect(response).toBeOK();
expect(response.headers()['content-type']).toContain('text/html');
expect(test.info().errors).toHaveLength(0);
});
$ npx playwright test tests/assertions.spec.ts
Running 1 test using 1 worker
1 passed (1.5s)
Two page assertions on a configured instance, a soft locator check, a negative stated as an exact count, the response matcher, and two generic matchers on values already in hand. It leaves out toHaveScreenshot on purpose, because the first run has no baseline to compare against, so it writes one and reports the test as failed.
Locator assertions
Twenty-seven matchers, all auto-retrying, all awaited. Grouped by what they read off the element.
State.
await expect(locator).toBeAttached(options?)
await expect(locator).toBeChecked(options?)
await expect(locator).toBeDisabled(options?)
await expect(locator).toBeEditable(options?)
await expect(locator).toBeEmpty(options?)
await expect(locator).toBeEnabled(options?)
await expect(locator).toBeFocused(options?)
await expect(locator).toBeHidden(options?)
await expect(locator).toBeInViewport(options?: { ratio?: number; timeout?: number; signal?: AbortSignal })
await expect(locator).toBeVisible(options?)
toBeAttached is about the DOM and toBeVisible is about pixels, so an element can be attached and still be invisible. toBeHidden passes for a non-visible node or for no node at all, which is a weaker claim than "the element was removed". Five of these take a boolean option that flips the expectation, such as toBeChecked({ checked: false }), if you prefer that to .not. toBeInViewport takes a ratio between 0 and 1, so you can require that most of an element is on screen rather than a single pixel of it.
Text and value.
await expect(locator).toContainText(expected: string | RegExp | Array<string | RegExp>, options?)
await expect(locator).toHaveText(expected: string | RegExp | Array<string | RegExp>, options?)
await expect(locator).toHaveValue(value: string | RegExp, options?)
await expect(locator).toHaveValues(values: Array<string | RegExp>, options?)
toHaveText is a full match and toContainText is a substring match. Both read nested elements too. When you pass a string, Playwright normalises whitespace and line breaks on both sides before comparing; when you pass a regular expression, the text is matched as it is. Pass an array to toHaveText and the list has to have exactly that many elements in that order; toContainText accepts an ordered subset of a longer list. toHaveValue reads an input, toHaveValues reads the selected options of a multi-select. The two text matchers also take ignoreCase and useInnerText.
Accessibility.
await expect(locator).toHaveAccessibleDescription(description: string | RegExp, options?)
await expect(locator).toHaveAccessibleErrorMessage(errorMessage: string | RegExp, options?)
await expect(locator).toHaveAccessibleName(name: string | RegExp, options?)
await expect(locator).toHaveRole(role: AriaRole, options?)
These four read the accessibility tree instead of the DOM, so they assert what a screen reader would announce. toHaveRole takes one of the fixed ARIA role strings, not a regular expression, and it matches the role as written: asserting checkbox on an element whose role is switch fails, even though a switch is a kind of checkbox.
Attribute, class, CSS, id, JavaScript property.
await expect(locator).toHaveAttribute(name: string, value: string | RegExp, options?)
await expect(locator).toHaveAttribute(name: string, options?)
await expect(locator).toHaveClass(expected: string | RegExp | Array<string | RegExp>, options?)
await expect(locator).toContainClass(expected: string | Array<string>, options?)
await expect(locator).toHaveCSS(name: string, value: string | RegExp, options?)
await expect(locator).toHaveId(id: string | RegExp, options?)
await expect(locator).toHaveJSProperty(name: string, value: unknown, options?)
The two toHaveAttribute overloads make different claims: with a value the attribute has to equal it, with the name alone the attribute only has to be present. toHaveClass compares the whole class attribute, so one extra utility class fails it. toContainClass checks that the named classes are in the element's class list, in any order, and ignores the others, which is the one you want in a Tailwind codebase. toHaveCSS reads the computed value and takes a pseudo option for pseudo-elements. toHaveJSProperty reads the live property off the DOM node rather than the attribute, which is the difference that matters for a checkbox: clicking it changes .checked and leaves the checked attribute alone.
Lists.
await expect(locator).toHaveCount(count: number, options?)
The only matcher that expects a locator to resolve to many elements rather than one. toHaveCount(0) is the plainest way to say that nothing matches. For the contents of a list, toHaveText with an array checks every row in order in one assertion, and locator.nth(i) narrows to a single row when only one of them matters.
Screenshot and ARIA snapshot.
await expect(locator).toHaveScreenshot(name: string | string[], options?)
await expect(locator).toHaveScreenshot(options?)
await expect(locator).toMatchAriaSnapshot(expected: string, options?)
await expect(locator).toMatchAriaSnapshot(options?: { name?: string; timeout?: number; signal?: AbortSignal })
Both compare against a stored baseline. toHaveScreenshot compares pixels. toMatchAriaSnapshot compares the accessibility tree written as YAML, so it survives a restyle that a pixel diff would flag. Screenshots get their own section further down.
Every matcher in this section takes a timeout in milliseconds, and since 1.62 an optional signal holding an AbortSignal. Aborting the signal fails the assertion the way a timeout does. Passing a signal does not switch the timeout off; pass timeout: 0 for that.
Page assertions
await expect(page).toHaveTitle(titleOrRegExp: string | RegExp, options?)
await expect(page).toHaveURL(url: string | RegExp | URLPattern | ((url: URL) => boolean), options?)
await expect(page).toHaveScreenshot(name: string | string[], options?)
await expect(page).toHaveScreenshot(options?)
await expect(page).toMatchAriaSnapshot(expected: string, options?)
await expect(page).toMatchAriaSnapshot(options?: { name?: string; timeout?: number; signal?: AbortSignal })
// the four shapes toHaveURL accepts
await expect(page).toHaveURL('https://playwright.dev/docs/intro');
await expect(page).toHaveURL(/docs?\//);
await expect(page).toHaveURL(new URLPattern({ pathname: '/docs/*' }));
await expect(page).toHaveURL(url => url.searchParams.get('id') === '5');
Four matchers, all retrying. toHaveTitle checks the document title, which is the text in the browser tab, not the heading on the page. toHaveURL takes a string, a regular expression, a URLPattern, or a predicate that receives a URL object, and the predicate is the one to reach for when the check is about query parameters. If baseURL is set in the context options and you pass a string, the two are merged through new URL() before the comparison. It also takes ignoreCase.
There is no page matcher for "the navigation finished". Assert on the URL, or on an element that only the destination renders. Both retry, so both wait.
API response assertions
await expect(response).toBeOK()
// the normal shape of an API check
const response = await request.get('https://api.example.com/orders/42');
await expect(response).toBeOK();
const body = await response.json();
expect(body).toMatchObject({ id: 42, status: 'confirmed' });
expect(body.items).toHaveLength(3);
// an endpoint that becomes healthy later needs a fresh request per probe
await expect.poll(async () => {
const probe = await request.get('https://api.example.com/health');
return probe.status();
}, { timeout: 10_000 }).toBe(200);
One matcher, and it checks one thing: the status code is between 200 and 299. It does not look at the body. Everything about the payload is a generic assertion on the parsed JSON, which is why the pair above is the standard shape of an API check.
Playwright's own list of auto-retrying assertions includes toBeOK(), but in practice there is nothing to re-fetch. The response object you are holding is already complete, so re-checking it returns the same answer forever. That matters when an endpoint is expected to recover: wrap a new request in expect.poll, as above, so each probe actually goes over the wire.
Generic and snapshot assertions
Generic matchers work on any JavaScript value and run once. Twenty-three of them.
expect(value).toBe(expected: unknown)
expect(value).toBeCloseTo(expected: number, numDigits?: number)
expect(value).toBeDefined()
expect(value).toBeFalsy()
expect(value).toBeGreaterThan(expected: number | bigint)
expect(value).toBeGreaterThanOrEqual(expected: number | bigint)
expect(value).toBeInstanceOf(expected: Function)
expect(value).toBeLessThan(expected: number | bigint)
expect(value).toBeLessThanOrEqual(expected: number | bigint)
expect(value).toBeNaN()
expect(value).toBeNull()
expect(value).toBeTruthy()
expect(value).toBeUndefined()
expect(value).toContain(expected: string)
expect(value).toContain(expected: unknown)
expect(value).toContainEqual(expected: unknown)
expect(value).toEqual(expected: unknown)
expect(value).toHaveLength(expected: number)
expect(value).toHaveProperty(keyPath: string, expected?: unknown)
expect(value).toMatch(expected: string | RegExp)
expect(value).toMatchObject(expected: object | object[])
expect(value).toStrictEqual(expected: unknown)
expect(fn).toThrow(expected?: string | RegExp | Error | Function)
expect(fn).toThrowError(expected?: string | RegExp | Error | Function)
The four that get confused. toBe uses Object.is, so two objects with identical fields fail it. toEqual does deep equality but ignores undefined properties and the difference between a sparse array and an array of undefined. toStrictEqual checks both of those and the object types as well. toContain has two overloads, a case-sensitive substring check on a string and an identity check for an item in an array or set, so use toContainEqual when the items are objects and you want field-by-field comparison. toThrowError is an alias of toThrow.
Asymmetric helpers go inside another expectation instead of standing on their own, and .resolves and .rejects unwrap a promise before the matcher runs.
expect.any(constructor: Function)
expect.anything()
expect.arrayContaining(expected: unknown[])
expect.arrayOf(constructor: Function)
expect.closeTo(expected: number, numDigits?: number)
expect.objectContaining(expected: object)
expect.stringContaining(expected: string)
expect.stringMatching(expected: string | RegExp)
expect(body).toEqual(expect.objectContaining({
id: expect.any(Number),
status: expect.stringMatching(/confirmed|shipped/),
items: expect.arrayContaining([expect.objectContaining({ sku: 'A-1' })]),
}));
await expect(loadOrder(42)).resolves.toMatchObject({ id: 42 });
await expect(loadOrder(-1)).rejects.toThrow('not found');
One snapshot matcher is generic rather than page-bound. It takes a string or a Buffer and compares it with a file in the test snapshots directory.
expect(value).toMatchSnapshot(name: string | string[], options?)
expect(value).toMatchSnapshot(options?: {
name?: string | string[];
maxDiffPixels?: number;
maxDiffPixelRatio?: number;
threshold?: number;
})
For screenshots the docs point you at toHaveScreenshot instead, because it retries until the image settles. Keep toMatchSnapshot for text output such as a serialised payload or a generated file.
Auto-retrying versus non-retrying: the one pair to remember
// checks one moment, and passes or fails depending on timing
expect(await locator.inputValue()).toBe('Review checkout flow');
// re-reads the input until it holds that value or the timeout expires
await expect(locator).toHaveValue('Review checkout flow');
Those two lines look like the same check. They are not. The first calls inputValue(), gets a string, and hands that string to a generic matcher. Whatever the input held at that instant is the whole basis of the verdict. If the field is filled 30 ms later by a network response, the test fails and the application is fine.
The second passes the locator itself. Playwright re-runs the query, reads the value again, and keeps going until the condition holds or the assertion timeout expires. Nothing about the page changed between the two versions. What changed is whether the check gets a second look.
This is the single largest source of avoidable flakiness in a Playwright suite. The rule is short: pass the locator, not a value read off the locator. When the thing you need is genuinely a computed value rather than element state, wrap the computation in expect.poll so it gets the same treatment. Flaky tests covers the other sources, most of which are shared state rather than assertions.
Negation, soft assertions, and custom messages
// negation goes before the matcher, and still retries
await expect(locator).not.toContainText('error');
// often a direct matcher states the requirement better
await expect(banner).toBeHidden();
await expect(page.getByRole('alert')).toHaveCount(0);
// soft checks record the failure and let the test carry on
await expect.soft(page.getByTestId('status'), 'order status').toHaveText('Confirmed');
await expect.soft(page.getByTestId('eta')).toHaveText('1 day');
expect(test.info().errors).toHaveLength(0);
// the second argument to expect is a message shown in reports
await expect(page.getByText('Name'), 'should be logged in').toBeVisible();
.not reverses any matcher, and a negated web-first assertion still retries, this time until the opposite condition holds. That is worth saying out loud because not.toBeVisible() reads like an instant check and behaves like a patient one. Where a direct matcher says the same thing, prefer it: toBeHidden() and toHaveCount(0) are clearer about which invariant you actually want.
A failed assertion normally ends the test on the spot. expect.soft() records the failure, lets the run continue, and still marks the test failed at the end. Use it to collect several findings from one journey rather than fixing one, rerunning, and finding the next. Before any step that depends on the earlier checks, assert test.info().errors is empty so the test stops instead of piling failures on a broken state. Soft assertions only work under the Playwright test runner.
The optional second argument to expect is a message that appears in reports for both passing and failing checks. It costs one string and turns "expect.toBeVisible failed" into something a reviewer can read without opening the test.
Timeouts: expect.configure, expect.poll, and expect.toPass
// playwright.config.ts, the global assertion timeout
export default defineConfig({ expect: { timeout: 10_000 } });
// per call
await expect(locator).toHaveText('Submit', { timeout: 15_000 });
// a reusable instance with its own defaults
const patientExpect = expect.configure({ timeout: 10_000 });
const softExpect = expect.configure({ soft: true });
// poll a function until a generic matcher passes
await expect.poll(async () => (await request.get(url)).status(), {
message: 'make sure the API eventually succeeds',
intervals: [1_000, 2_000, 10_000],
timeout: 60_000,
}).toBe(200);
// retry a whole block; the default timeout here is 0
await expect(async () => {
const response = await request.get(url);
expect(response.status()).toBe(200);
}).toPass({ timeout: 15_000 });
Six separate timeouts can affect one line of test code: the assertion timeout, the action timeout, the navigation timeout, the test timeout, the expect.poll timeout, and the toPass timeout. Reading a stack trace without knowing which one fired is how people end up raising the wrong number and watching nothing change.
The assertion timeout defaults to 5,000 ms and is set globally with expect.timeout, per call with a timeout option, or per file with an instance from expect.configure. A configured instance is the tidy option when one page is genuinely slow: you get one named expect to use there and the default everywhere else. The same call takes soft: true if you want an instance whose checks never stop the test.
expect.poll turns any generic matcher into a polling one. It defaults to a 5,000 ms timeout and intervals of 100, 250, 500, and 1,000 ms, and it re-runs your function on each probe, so it is the right tool for a value that arrives late. toPass retries a whole block instead of a single matcher, which suits a setup step that sometimes needs a second go. Its default timeout is 0, meaning no limit, and it does not inherit the configured expect timeout. Always pass one explicitly, or a hanging block will eat the test timeout instead.
Custom matchers with expect.extend
import { expect as baseExpect } from '@playwright/test';
import type { Locator } from '@playwright/test';
export const expect = baseExpect.extend({
async toHaveCartAmount(locator: Locator, expected: number, options?: { timeout?: number }) {
const name = 'toHaveCartAmount';
let pass: boolean;
let actual: string | undefined;
try {
const assertion = this.isNot ? baseExpect(locator).not : baseExpect(locator);
await assertion.toHaveAttribute('data-amount', String(expected), options);
pass = true;
} catch (e: any) {
actual = e.matcherResult?.actual;
pass = false;
}
if (this.isNot) pass = !pass;
return {
name,
pass,
expected,
actual,
message: () => this.utils.matcherHint(name, undefined, undefined, { isNot: this.isNot }),
};
},
});
A custom matcher is a function that returns pass and a message callback. Build it on top of a built-in locator matcher, as above, and it inherits the retry behaviour for free. Handle this.isNot in both places or .not.toHaveCartAmount() will report the wrong verdict, and return expected and actual so the report shows numbers rather than a bare failure.
Two things to watch. Extend Playwright's own expect, not the separate expect package, which is not wired into the test runner. And pick a name no built-in uses: a report against 1.61 showed a custom matcher named after a built-in replacing the built-in inside the same expect instance. Prefixing with your domain, as with toHaveCartAmount above, avoids the question.
Visual checks with toHaveScreenshot
await expect(page).toHaveScreenshot('checkout.png', {
animations: 'disabled',
caret: 'hide',
mask: [page.getByTestId('order-clock')],
stylePath: './screenshot.css',
maxDiffPixels: 50,
fullPage: true,
});
await expect(page.getByTestId('totals')).toHaveScreenshot({ threshold: 0.15 });
// a lossless WebP baseline, new in 1.62
await expect(page).toHaveScreenshot('checkout.webp');
The first run finds no baseline, writes the actual screenshot to the snapshots folder next to the spec, and reports the test as failed so the file gets reviewed before it is committed. Later runs compare against it. When a change is intentional, rerun with --update-snapshots. The matcher takes screenshots until two consecutive ones match, then compares the last of those with the baseline, which is what stops a mid-animation frame from becoming the answer.
The controls that matter are animations, caret, mask, maskColor, stylePath, threshold, maxDiffPixels, maxDiffPixelRatio, scale, omitBackground and timeout, plus clip and fullPage on a page. Baselines are stored as PNG unless the name ends in .webp, and the file name carries the browser and platform, because rendering differs between them.
Rendering also varies with the operating system, the browser version, hardware, power source and headless mode. Generate baselines in the same environment that compares them, which in practice means a container in CI rather than somebody's laptop. Visual regression testing covers the workflow around that.
Common assertion mistakes
1. Forgetting await. The assertion returns a promise. Without await the test can finish before the check settles, and a failure surfaces as an unhandled rejection in some other test.
expect(locator).toBeVisible(); // wrong
await expect(locator).toBeVisible(); // right
2. Forgetting the parentheses. This reads the matcher function and throws it away. Nothing is asserted and the test passes, which is worse than failing.
await expect(locator).toBeVisible; // wrong
await expect(locator).toBeVisible(); // right
3. Asserting an immediate boolean. isEnabled() and isVisible() return the state now. They do not wait, and no matcher wrapped around their result can wait either.
expect(await locator.isEnabled()).toBe(true); // wrong
await expect(locator).toBeEnabled(); // right
4. A broad locator with a single-element matcher. toBeVisible() fails when the locator resolves to more than one node. Tighten the locator, add .first() if any match will do, or assert the list. Element locators covers picking a selector that resolves to one thing.
await expect(page.getByRole('listitem')).toBeVisible(); // wrong, three items match
await expect(page.getByRole('listitem')).toHaveCount(3); // right
5. Hidden when you meant absent. toBeHidden() also passes when no node matches at all. If the requirement is that the node is gone from the DOM, say that.
await expect(banner).toBeHidden(); // passes whether it is hidden or missing
await expect(banner).toHaveCount(0); // right, when removal is the requirement
6. Inventing an or on the matcher. There is no .or() on an assertion. Put the alternation in a regular expression for either text, or in locator.or() for either element.
await expect(status).toHaveText('Saved').or.toHaveText('Synced'); // wrong
await expect(status).toHaveText(/Saved|Synced/); // right, either text
await expect(saved.or(synced).first()).toBeVisible(); // right, either element
7. Raising the wrong timeout. Changing expect.timeout does nothing for a toPass block, which starts at 0 and ignores the configured value. Check which of the six timeouts owns the failure before editing a number.
await expect(async () => { /* ... */ }).toPass(); // ignores expect.timeout
await expect(async () => { /* ... */ }).toPass({ timeout: 15_000 }); // right
8. Trusting a screenshot diff from an unpinned machine. Font weight and kerning shift between environments, so a diff can be real and mean nothing. Pin the environment first, then mask what moves.
await expect(page).toHaveScreenshot(); // baseline from a laptop, compared in CI
await expect(page).toHaveScreenshot({ mask: [page.getByTestId('clock')], maxDiffPixels: 50 });
One change, four layers of evidence
A pull request changes checkout so a promo code applies before tax rather than after. Four assertions cover it, one per layer, and they age differently.
The locator assertion states what a user sees: toHaveText('$81.00') on the order total. The page assertion states that the flow arrived: toHaveURL on the review step. The response assertion plus a body check states that the backing call agreed: toBeOK() on the POST, then toMatchObject on the parsed total. The negative check states what must not be possible: toHaveCount(0) on the Apply promo button once a code is applied, so it cannot be stacked twice.
Three of those four replay unchanged on the next commit, because they name behaviour. A screenshot assertion on the same screen does not: any restyle of the totals block needs a fresh baseline whether or not the arithmetic moved. That is the trade to make per screen rather than per suite. Behaviour assertions survive refactors. Appearance assertions catch the defects behaviour misses and cost a baseline every time the design changes. Which mix you pick decides how much of a suite still passes six months later, which is the subject of AI regression testing.
How Qodex writes these assertions
Qodex is an agent that writes the assertions rather than a place to run the ones you already wrote. Give it a sentence and it crawls the application, drives a real browser, and saves the run as standard Playwright using the same matchers as this page. A failure arrives with the failing step, the failing request and a screenshot attached, so triage starts with evidence instead of a red build. Stale tests come back as a proposed diff you approve. Saved scenarios replay with no model call, on every pull request or on a schedule, so the hundredth run costs what the first one did.
See how Qodex UI testing works.
Frequently Asked Questions
What is an assertion in Playwright?
An assertion is a check written as expect(target).matcher(expected), and it decides whether the test passed. Checks on a locator or a page retry until they hold or the assertion timeout expires, five seconds by default. Checks on a plain value run once, against the value you already have.
Which Playwright assertions auto-wait?
All twenty-seven locator matchers, all four page matchers, and anything wrapped in expect.poll or expect.toPass. Generic matchers on plain values do not, because the value is fixed by the time expect sees it. toBeOK() on a response you already fetched has nothing left to re-read either.
What is the difference between hard and soft assertions?
A hard assertion stops the test at the first failure. expect.soft() records the failure and lets the run continue, and the test still ends as failed. Use soft checks to collect several findings from one journey, then assert test.info().errors is empty before any step that depends on them.
How do I change the timeout for a Playwright assertion?
Per call, pass a timeout option to the matcher. Per file, build an instance with expect.configure({ timeout: 10_000 }). Globally, set expect.timeout in the config. toPass is the exception: its default is 0 and it ignores the configured value, so give it its own.
How do I use .not when an element should disappear?
await expect(locator).not.toBeVisible() retries until the element is hidden or gone, so it waits rather than failing straight away. toBeHidden() says the same thing more directly, and toHaveCount(0) is the one to pick when the requirement is that the node leaves the DOM.
How do I assert either of two values or elements?
For one element whose text could be either value, pass a regular expression: toHaveText(/Saved|Synced/). For either of two elements, combine the locators with locator.or() and add .first() when both can appear. There is no .or() on a matcher, which is the usual first guess.
How do I assert an API response body?
Check the status with await expect(response).toBeOK(), then read the payload with await response.json() and assert on it with generic matchers. toMatchObject is the usual choice because it ignores extra fields, and toHaveLength covers array sizes. toBeOK() never looks at the body.
How do I stop toHaveScreenshot from producing flaky diffs?
Generate baselines in the environment that compares them, with the operating system and browser version pinned. Wait for fonts and images to settle, mask or hide clocks and animations with mask and stylePath, and keep maxDiffPixels tight enough that a real change still fails the check.


