Home / Blog / Playwright assertions
Playwright assertions: what to check, and which ones wait for you
Playwright has two kinds of assertion.
await expect(locator).toBeVisible() retries until the page agrees or the
assertion times out. expect(await locator.textContent()).toBe('Saved') reads the
page once and judges that reading, and a page that has not caught up yet fails it. That second
shape is how a suite picks up a race and then blames the framework.
One kind of broken assertion runs before the page is ready and fails, loudly, on the morning CI is busy. The quieter kind is satisfied by something other than the thing you meant, so it passes, and goes on passing after the feature stops working.
What does expect() do in a Playwright test?
expect() takes one argument, and everything about the assertion follows from
which kind of argument it is. Hand it a locator and you have handed Playwright a description it
can run again — the query is stored, the node is not. Hand it a value and you have handed over
a reading taken at one instant, which will be the same reading a second later and an hour
later.
The two calls sit a few characters apart in a spec file.
import { test, expect } from '@playwright/test';
test('what expect() is handed', async ({ page }) => {
await page.setContent(`
<p id="total">Total: 0 items</p>
<script>
// The application is slow here, the way a real one is on a busy morning.
setTimeout(() => {
document.getElementById('total').textContent = 'Total: 3 items';
}, 300);
</script>`);
const total = page.getByText(/^Total:/); // a description of how to find something
const readNow = await total.textContent(); // a string, taken at this instant
await expect(total).toHaveText('Total: 3 items'); // re-runs the description until it agrees
expect(readNow).toBe('Total: 0 items'); // judges the string above, and nothing else
});
Playwright 1.62 · TypeScript · passes
Every sample on this page was written against Playwright 1.62 and run before it was published.
Both assertions in that test pass and they are describing two different worlds. The first one
outlives the redraw because it never held the paragraph, only the recipe for finding it. The
second is a claim about a string that stopped changing the moment
await total.textContent() returned, so it is still asserting about a screen that
no longer exists.
Which assertions retry, and which judge once?
The documentation sorts the matchers into two lists and states what each list does. Under
Auto-retrying assertions: "The following assertions will retry until the assertion
passes, or the assertion timeout is reached." and "Note that retrying assertions are
async, so you must await them."
Under Non-retrying assertions, the same page says: "These assertions allow to test any conditions, but do not auto-retry." and "Most of the time, web pages show information asynchronously, and using non-retrying assertions can lead to a flaky test." The people who ship the matchers wrote that, so this page does not have to argue it.
Which bucket a given matcher sits in is published in two tables on that page, and there is no
reason to reprint them here. Carry one thing into code review instead: look at where
await sits. Outside expect the assertion retries, and inside it the
assertion has already been handed its answer.
import { test, expect } from '@playwright/test';
const SLOW_STATUS = `
<div id="status">Queued</div>
<script>
setTimeout(() => {
document.getElementById('status').textContent = 'Delivered';
}, 300);
</script>`;
test('await outside expect', async ({ page }) => {
await page.setContent(SLOW_STATUS);
await expect(page.locator('#status')).toHaveText('Delivered');
});
test('await inside expect', async ({ page }) => {
await page.setContent(SLOW_STATUS);
expect(await page.locator('#status').textContent()).toBe('Delivered');
});
Playwright 1.62 · TypeScript · the first test passes, the second fails
Same element, same claim, same spec file. The second test reports this:
Error: expect(received).toBe(expected) // Object.is equality
Expected: "Delivered"
Received: "Queued"
17 | await page.setContent(SLOW_STATUS);
> 18 | expect(await page.locator('#status').textContent()).toBe('Delivered');
| ^
Playwright 1.62 · the run output, reproduced against the spec above
Nothing in that failure mentions waiting, because no waiting was attempted. The reading was
taken while the status still said Queued, and the comparison was correct about it.
On a fast machine the same line goes green, which is how a suite acquires a race that fails
intermittently in CI and gets blamed on the runner. A retrying assertion polls until the page
agrees or until it runs out of time, and the second half of that sentence belongs to its own
article: which clock it was on and which one
ran out.
What should you assert?
An assertion is only ever as good as the locator inside it, and choosing that locator is a decision with its own ranking. Assume you have made it well. The question left over is the one a reviewer asks, and it is harder: given that your matcher will run against the right element and will retry until it agrees, what should it be checking?
Every assertion below describes a DOM in a browser. That includes a mobile browser — an emulated viewport, a user agent and touch input — and it never includes a native iOS or Android app, which has no document for a matcher to read. Playwright drives browsers, so nothing here is a claim about a store build on a device.
Five assertions turn up in nearly every suite, and each one is satisfied by something other than the thing its author had in mind.
| What you meant to prove | The assertion people write | What it also passes on | What to write instead |
|---|---|---|---|
| The search results loaded | expect(page.getByTestId('spinner')).toBeHidden() | An error path that removed the spinner on its way out | An assertion on the content: a row, a heading, a count |
| The order was placed | expect(page).toHaveURL(/\/order\/confirmed/) | Any navigation to that path, including one nothing on the server agreed to | An assertion on the order number the confirmation is supposed to carry |
| Search returned something | expect(page.getByTestId('results')).toBeVisible() | A results panel that rendered with nothing inside it | toHaveCount on the rows, plus a check on the first row's content |
| The heading reads exactly this | expect(heading).toContainText('Payment received') | A heading carrying a status badge, a stale total or a second line of copy | toHaveText where the whole string is the claim you are making |
| The screen showed the data | expect(response.status()).toBe(200) | A server that answered while the screen stayed blank | An assertion on the element the response was supposed to fill |
1. A spinner leaving is not the content arriving
The spinner goes away on the happy path. It also goes away when the request returns a 500 and the error path replaces the whole panel, which is the run you most wanted the test to catch. Here is a search that always fails, and two specs that disagree about whether it worked.
import { test, expect } from '@playwright/test';
// The search request fails. The error path removes the spinner on its way out.
const FAILING_SEARCH = `
<button id="go">Search</button>
<div id="panel"></div>
<script>
document.getElementById('go').onclick = () => {
const panel = document.getElementById('panel');
panel.innerHTML = '<p data-testid="spinner">Loading</p>';
setTimeout(() => {
panel.innerHTML = '<p role="alert">Something went wrong. Try again.</p>';
}, 200);
};
</script>`;
test('the spinner went away', async ({ page }) => {
await page.setContent(FAILING_SEARCH);
await page.getByRole('button', { name: 'Search' }).click();
await expect(page.getByTestId('spinner')).toBeHidden();
});
test('the results arrived', async ({ page }) => {
await page.setContent(FAILING_SEARCH);
await page.getByRole('button', { name: 'Search' }).click();
await expect(page.getByTestId('result-count')).toHaveText('3 invoices');
});
Playwright 1.62 · TypeScript · the first test passes, the second fails
The first test is green on a page showing an error message, and it was perfectly correct about the spinner. Nobody needed to know about the spinner. Assert the thing the user opened the page for. A spinner check is fine as a step on the way to that assertion, and a test whose last line is a spinner check has proved that something finished.
2. A URL is not the thing the URL was supposed to produce
toHaveURL is documented as one sentence, and the sentence is exact:
"Ensures the page is navigated to the given URL." A checkout spec that ends on
/order/confirmed has proved the router works. It has not touched the order, the
payment or the confirmation email. Assert the order number on the page, and let the URL be the
thing that got you there.
3. The assertion that is happy with an empty list
This is the trap that survives longest, because it never fails. A search test asserting the results panel appeared will stay green forever after the day search stopped returning anything, and the suite will report a clean run every night while the feature is dead.
import { test, expect } from '@playwright/test';
// Search ran, the panel rendered, and there is nothing in it.
const EMPTY_RESULTS = `
<section data-testid="results">
<h2>Invoices</h2>
<ul id="rows"></ul>
</section>`;
test('an assertion that will pass forever', async ({ page }) => {
await page.setContent(EMPTY_RESULTS);
await expect(page.getByTestId('results')).toBeVisible();
});
test('an assertion that notices', async ({ page }) => {
await page.setContent(EMPTY_RESULTS);
const rows = page.getByTestId('results').getByRole('listitem');
await expect(rows).toHaveCount(3);
await expect(rows.first()).toContainText('INV-8814');
});
Playwright 1.62 · TypeScript · the first test passes, the second fails
toHaveCount is documented as ensuring "the Locator resolves to an exact number
of DOM nodes", so it is a claim with a number in it that a container's visibility can never
make. Pair it with a check on the first row and the pair fails on an empty panel, on a panel of
the wrong length, and on a panel full of somebody else's invoices.
4. toContainText where toHaveText was meant
The two descriptions in the API reference differ by three words.
toHaveText: "Ensures the Locator points to an element with the given text. All
nested elements will be considered when computing the text content of the element."
toContainText: "Ensures the Locator points to an element that contains the
given text. All nested elements will be considered when computing the text content of the
element." Both walk into nested elements; the difference is whether your string has to be
the whole of what comes back.
So the choice is a decision about how much of the rendering you are willing to pin.
toHaveText on a heading fails when a designer adds a badge inside it, which is
correct if the heading's exact wording is a product requirement and noise if it is not.
toContainText keeps passing through that change and keeps passing when a second,
stale invoice number is rendered beside the right one. Neither answer is right everywhere, and
a spec that mixes them without a reason is a spec nobody can review.
5. The assertion with no locator in it at all
expect(response.status()).toBe(200) inside a UI test proves that the server
answered. It says nothing about whether a single pixel of that response reached the screen, and
it does not retry, because a status code is a value. Assertions at the API level are a real and
useful thing in their own right, and they have a
guide of their own.
Why do "not visible" assertions flake?
The API reference documents toBeHidden as: "Ensures that Locator either does
not resolve to any DOM node, or resolves to a non-visible one." Read that against a page
that has not finished rendering and the whole failure falls out. An element that has not
appeared yet satisfies the assertion exactly as well as an element that has been removed. The
reasoning is ours; the sentence it rests on is the documentation's, and
not.toBeVisible() accepts the same two conditions from the other direction, since
toBeVisible is documented as ensuring "Locator points to an attached and
visible DOM node."
So the spec passes locally, where the render is instant and the check happens to run after it. It also passes in CI on the run where the render is slow, because the check ran before the element existed. It is an assertion that was never checking what its author meant, and it will report success either way.
import { test, expect } from '@playwright/test';
// The dialog is drawn by script a moment after the document is served.
const LATE_DIALOG = `
<div id="app"></div>
<script>
setTimeout(() => {
document.getElementById('app').innerHTML =
'<div role="dialog" aria-label="Delete invoice">' +
'<p>Delete INV-8814?</p><button id="close">Close</button></div>';
document.getElementById('close').onclick = () => {
document.getElementById('app').innerHTML = '';
};
}, 300);
</script>`;
test('green, and the dialog was never on screen', async ({ page }) => {
await page.setContent(LATE_DIALOG);
await expect(page.getByRole('dialog')).not.toBeVisible();
});
test('the positive assertion pins the test to a moment', async ({ page }) => {
await page.setContent(LATE_DIALOG);
const dialog = page.getByRole('dialog', { name: 'Delete invoice' });
await expect(dialog).toBeVisible();
await dialog.getByRole('button', { name: 'Close' }).click();
await expect(dialog).not.toBeVisible();
});
Playwright 1.62 · TypeScript · both pass, and the first one is the bug
Both tests are green and only one of them looked at a dialog. Delete the click from the second test and it goes red, which is the property the first test does not have.
A reviewer should prefer the repairs in this order.
Assert the precondition first. Put a positive, retrying assertion on state you know follows the action, then assert the disappearance underneath it. The passing assertion pins the test to a point in time, and it is the reason the second spec above cannot run early.
Assert what replaced it. A dialog closing usually reveals something — a row that has gone from the table, a toast, a heading. Checking for that is the same move as trap 1 above, arriving from the other side.
Count, when the thing is a set.
await expect(page.getByTestId('cart').getByRole('listitem')).toHaveCount(0) is a
statement about a container that has to exist before the count can be taken, which is stronger
than a statement that a node is absent from a document that may not have loaded.
And one that looks like a fourth repair and is not. toBeVisible
takes a visible option that the reference lists by name with no description, and the
shipped type definition describes it no better. Run on 1.62.1,
toBeVisible({ visible: false }) passes and fails on exactly the same inputs as
not.toBeVisible() — on a visible element, on a hidden one, and on an element
that is not in the document at all, where both of them pass. The only thing that changes is the
wording of the failure, Expected: hidden rather than Expected: not visible.
It reads better and asserts the same thing, so it carries the same race this whole section is
about. Use it if you prefer it; do not use it thinking you have repaired anything.
When are soft assertions a trap?
The mechanism is documented and the documentation is right about it: "By default, failed assertion will terminate test execution." and "failed soft assertions do not terminate test execution, but mark the test as failed." The case for them is real. A form with six fields and one report telling you which four are wrong beats six runs telling you one thing each.
The case against arrives one step later. Once the first soft assertion has failed, the rest of the test is running against a state the product never intended to be in, and the failures after it are frequently echoes of the first. A reviewer then opens a report with five failures in it, has nothing to rank them by, and fixes the third one. Soft assertions are for independent checks on one state. The moment a later step depends on an earlier check having held, they stop being cheap, and the documentation supplies the gate for exactly that boundary.
What that reviewer sees is not a guess. A run with five soft failures and one hard one, opened
in the HTML report on 1.62.1: in the run list there is no distinction at all
— the same red, no badge, and the header counts the soft test as one failed test rather
than five. Open it and the five arrive as five Error: blocks under a single
heading, each opening with the same line the hard failure opens with. The one place the report
tells you which kind you are looking at is the call log, where the matcher is named
soft toHaveText instead of toHaveText. It is buried four lines inside
a block a reviewer scrolls past.
import { test, expect } from '@playwright/test';
test('four checks on one state, then a gate', async ({ page }) => {
await page.setContent(`
<dl>
<dt>Order</dt><dd data-testid="order-number">INV-8814</dd>
<dt>Status</dt><dd data-testid="status">Confirmed</dd>
<dt>Items</dt><dd data-testid="item-count">3</dd>
<dt>Delivery</dt><dd data-testid="eta">Thursday</dd>
</dl>
<a href="#receipt">Download receipt</a>`);
await expect.soft(page.getByTestId('order-number')).toHaveText('INV-8814');
await expect.soft(page.getByTestId('status')).toHaveText('Confirmed');
await expect.soft(page.getByTestId('item-count')).toHaveText('3');
await expect.soft(page.getByTestId('eta')).toHaveText('Thursday');
// Avoid running further if there were soft assertion failures.
expect(test.info().errors).toHaveLength(0);
await page.getByRole('link', { name: 'Download receipt' }).click();
});
Playwright 1.62 · TypeScript · passes
The four checks above are independent — any of them can be wrong without making the others
meaningless. The click is not, because downloading the receipt for an order whose number came
back wrong tells you nothing you can act on. expect(test.info().errors) is the
documented way to ask mid-test whether any soft assertion has failed, and putting it on the
seam keeps the report readable.
One documented constraint bounds all of it: "Note that soft assertions only work with Playwright test runner."
How do you retry something that is not an element?
Every assertion so far has been about a node in a document, which is the ground Playwright's own matchers cover. Suites also need to check things with no DOM behind them: a value the application holds in memory, a background job that has to reach a state. Those have no matcher, and the tools below exist to keep the retrying property one level out from the page.
expect.poll wraps a function of your own and re-runs it until the matcher on the
end of it passes.
import { test, expect } from '@playwright/test';
declare global {
interface Window { importState?: string }
}
test('poll a value that no element on the page shows', async ({ page }) => {
await page.setContent(`
<script>
window.importState = 'running';
setTimeout(() => { window.importState = 'done'; }, 400);
</script>`);
await expect.poll(
() => page.evaluate(() => window.importState),
{ message: 'the import should reach a finished state' },
).toBe('done');
});
Playwright 1.62 · TypeScript · passes
A reporter prints the message option when the poll gives up, and a poll without one
produces a failure nobody can read. Polling until a condition holds is not a
measurement of how fast anything is, so a green poll is not a performance result and this site
does not sell load testing.
expect.toPass does the same job for a block instead of a value: wrap several
statements in await expect(async () => { ... }).toPass() and the whole block is
retried until it stops throwing. Both of them exist for conditions with no matcher. Reaching
for either because a toBeVisible failed is how a suite ends up polling politely
around a real defect.
expect.extend is the one to reach for when the same check appears twenty times.
A custom matcher returns a pass flag and a message callback for the failure, and it puts the
reason for a failure in one place. The cost is that a reviewer now opens a
second file to find out what the assertion means, which is worth paying at twenty uses and not
at three.
Some assertion families are left out here:
screenshots and toHaveScreenshot belong with
visual regression,
toMatchAriaSnapshot belongs with
accessibility checks, and
toBeOK on an API response belongs with the API guide linked above.
What rule catches this in code review?
Write one rule down and put it where a reviewer will see it. Every assertion about page state
is await expect(locator). A bare expect on a value read out of the
page is a review comment rather than a matter of taste. A negative assertion has a positive one
above it, always, and a reviewer who sees a lone not.toBeVisible() asks what
pinned the test to a moment.
Then one question, asked of every assertion in a diff: if this feature were broken, which line goes red? It costs nothing to ask, and it catches the spinner check, the URL check and the results-panel check in the same pass, because none of them has an answer. A suite full of assertions nobody can answer that question for is a suite that produces green builds and shipped defects, and the team living with it usually describes the problem as flakiness.
They are not wrong to. A suite whose assertions read the page once produces intermittent failures that look exactly like timing problems, so the team that eventually calls somebody about a suite it cannot trust is often looking at assertion style. It is one of the causes we find most often when we read the failures.
The rule survives contact with a real team because it is short and mechanical. A reviewer does
not have to know the feature to apply it — they only have to read where the
await is and whether a negative assertion is standing on its own.
Questions
What is the difference between expect(locator) and expect(await locator.textContent())?
The first is handed a description Playwright can run again, so the matcher on it re-reads the page until the page agrees with you. The second is handed a string that was read once, before expect was called, and the matcher judges that string and nothing else. The keyword to look at in a diff is await: outside expect it retries, inside expect it does not.
Why does my not.toBeVisible() assertion pass when the element is missing?
Because an element that has not rendered yet satisfies that assertion as completely as one that has been removed. The documentation says toBeHidden "Ensures that Locator either does not resolve to any DOM node, or resolves to a non-visible one", and a locator on a screen that is still drawing resolves to no node at all. Put a positive assertion above the negative one so the test cannot run before the thing exists.
When should I use soft assertions in Playwright?
Use them for independent checks on one state, such as six fields of a form you want reported in a single run. Stop using them the moment a later step depends on an earlier check having held, because the steps after a failure are running against a state the product never meant to be in. The documented gate for that boundary is expect(test.info().errors).toHaveLength(0), placed before the dependent step.
Do Playwright assertions wait for the element?
The auto-retrying ones do: hand expect a locator, await the matcher, and Playwright re-runs the query until it passes or the assertion times out. Anything built from a value you read yourself does not wait, because the reading was already taken by the time expect saw it. How long the retrying kind polls, and which timeout ends it, is a separate subject with its own article.
The assertions you did not write, and nobody has read since
A rule works on the next assertion you write. It does nothing for the ones already in the repository, put there by whoever was on the team at the time, which are the reason a green build is hard to believe. Where those specs also fail at random, we start from the failures: the flake register names every intermittent spec with a cause against it, and the quarantine list carries an owner and the condition that puts a spec back in the run. Send us the build that went green on the week a feature was broken, and the spec you thought was covering it.