QA How-To
Playwright expect toHaveCount: Examples and Best Practices
Learn playwright expect toHaveCount examples for dynamic lists, filters, tables, zero states, timeouts, debugging, and reliable Playwright test design.
24 min read | 2,682 words
TL;DR
Use `await expect(locator).toHaveCount(number)` to retry until a locator resolves to an exact number of DOM nodes. Keep the locator scoped, derive the number from test-owned data, and add identity checks when cardinality alone cannot prove the expected records.
Key Takeaways
- Pass a Locator directly to toHaveCount so Playwright can retry the collection size.
- Scope the locator to the intended region and business subset before asserting its count.
- Use exact counts only with controlled data and a clear final-state signal.
- Treat DOM count, visible item count, current-page rows, and backend totals as separate contracts.
- Pair cardinality with text or identity assertions when duplicates and wrong records are realistic risks.
- Use expect.poll only when a genuine range condition cannot be expressed as an exact locator count.
- Diagnose shared data, virtualization, skeletons, and weak locators before increasing timeouts.
The best playwright expect toHaveCount examples assert an exact, user-meaningful number of DOM nodes through a Locator. The matcher retries until the locator resolves to the expected count or the assertion timeout expires, so it is usually more reliable than reading locator.count() and comparing one immediate snapshot.
That simple rule has important consequences. A good count assertion depends on a locator whose scope represents the business concept, a trigger that causes the collection to change, and an expected number derived from controlled test data. This guide moves from a runnable first test to dynamic lists, filters, empty states, pagination, virtualized UIs, reusable helpers, debugging, and interview-ready reasoning.
TL;DR
| Need | Recommended assertion | Why |
|---|---|---|
| Wait for exactly five rows | await expect(rows).toHaveCount(5) |
Retries the locator until five nodes match |
| Prove no alerts are rendered | await expect(alerts).toHaveCount(0) |
Expresses absence through the same retrying contract |
| Check a stable numeric snapshot | expect(await rows.count()).toBe(5) |
Suitable only when timing is already established |
| Wait for at least one row | Assert a meaningful first row or use a deliberate polling condition | toHaveCount accepts an exact number, not a range |
Use toHaveCount when exact cardinality is part of the expected UI state. Narrow the locator first, control the fixture data, and keep the assertion close to the action that changes the collection.
1. What playwright expect toHaveCount examples actually prove
toHaveCount is a Playwright locator assertion. Its received value must be a Locator, and its expected value is one number. During the assertion window, Playwright repeatedly resolves that locator and compares the number of matching DOM nodes with the expectation.
import { test, expect } from '@playwright/test';
test('renders three assigned tickets', async ({ page }) => {
await page.setContent(`
<main>
<h1>Assigned tickets</h1>
<ul aria-label="Assigned tickets">
<li>QA-101</li>
<li>QA-102</li>
<li>QA-103</li>
</ul>
</main>
`);
const tickets = page.getByRole('list', { name: 'Assigned tickets' })
.getByRole('listitem');
await expect(tickets).toHaveCount(3);
});
This test is runnable without an application server because it supplies its own HTML. More importantly, the locator says which tickets count. A broad page.locator('li') could accidentally include navigation, notifications, or hidden templates and still produce the expected number for the wrong reason.
Count assertions prove DOM cardinality, not backend record count, visual uniqueness, or business correctness of every item. If the risk is that the three expected ticket IDs appear in order, add toHaveText(['QA-101', 'QA-102', 'QA-103']). If the risk is a server total, verify the API or displayed total separately. Precise test intent prevents one number from carrying more meaning than it can support.
2. Why toHaveCount is different from locator.count plus toBe
Both forms can compare a number, but they use different timing models.
// Web-first assertion, retries the locator result.
await expect(page.getByRole('row')).toHaveCount(6);
// Snapshot assertion, reads once and compares once.
expect(await page.getByRole('row').count()).toBe(6);
The first line keeps the locator intact. If a table renders its header immediately and appends five data rows after a request completes, Playwright can observe counts such as 1, 3, and finally 6 during one assertion. The second form captures whichever count exists at the instant count() resolves. It does not retry merely because toBe follows it.
| Technique | Retries collection size | Best use | Main risk |
|---|---|---|---|
expect(locator).toHaveCount(n) |
Yes | Dynamic UI with an exact final size | Weak locator can count unrelated nodes |
expect(await locator.count()).toBe(n) |
No | Stable snapshot or diagnostic | Race between rendering and read |
Manual loop around count() |
Only if you build it correctly | Rare custom range condition | Reinvents timeouts and reporting |
Fixed delay, then count() |
No meaningful condition retry | None in routine tests | Slow and still timing-dependent |
The snapshot form is not forbidden. It is useful after another assertion has established stability, or when logging a diagnostic value. For the main behavioral check, prefer the web-first form. The related guide on fixing a received value that must be a Locator explains why extracting the number changes which matcher family can be used.
3. A runnable dynamic-list example
The following test demonstrates the behavior that makes toHaveCount valuable. Clicking the button starts an asynchronous update. The test does not sleep or poll manually.
import { test, expect } from '@playwright/test';
test('waits for queued jobs to appear', async ({ page }) => {
await page.setContent(`
<button type="button">Load jobs</button>
<ul aria-label="Queued jobs"></ul>
<script>
document.querySelector('button').addEventListener('click', () => {
setTimeout(() => {
document.querySelector('ul').innerHTML =
'<li>Accessibility</li><li>API</li><li>Visual</li>';
}, 150);
});
</script>
`);
const jobs = page.getByRole('list', { name: 'Queued jobs' })
.getByRole('listitem');
await page.getByRole('button', { name: 'Load jobs' }).click();
await expect(jobs).toHaveCount(3);
await expect(jobs).toHaveText(['Accessibility', 'API', 'Visual']);
});
Notice the sequence. The locator is created before the items exist, which is safe because a locator is a reusable query, not a stored element list. The click owns the state transition. toHaveCount(3) waits for the collection boundary, and toHaveText proves identity and order.
Do not replace the assertion with waitForTimeout(1000). A delay waits for elapsed time, not the required UI state. On a fast run it wastes time, and on a slow run it can still finish before rendering. If the application streams items continually and never has a stable exact final count, select a different observable contract, such as a completion status plus the final rendered set.
4. Count filtered collections, not the whole page
Most production pages contain several repeated structures. The robust approach is to start with a meaningful region and narrow the collection by user-visible properties.
import { test, expect } from '@playwright/test';
test('filters open critical incidents', async ({ page }) => {
await page.setContent(`
<section aria-label="Incidents">
<article><h2>API outage</h2><span>Critical</span><span>Open</span></article>
<article><h2>Slow search</h2><span>High</span><span>Open</span></article>
<article><h2>Login outage</h2><span>Critical</span><span>Closed</span></article>
<article><h2>Payment outage</h2><span>Critical</span><span>Open</span></article>
</section>
`);
const incidents = page.getByRole('region', { name: 'Incidents' })
.getByRole('article');
const openCritical = incidents
.filter({ hasText: 'Critical' })
.filter({ hasText: 'Open' });
await expect(openCritical).toHaveCount(2);
});
filter({ hasText }) narrows each candidate article based on descendant text. In a real application, role-based child locators or test IDs may better distinguish status and severity. The principle is stable: define the business subset in the locator, then assert its exact count.
A common mistake is to assert the full card count before and after a visual filter while the application hides nonmatching cards with CSS. If hidden cards remain in the DOM, the count does not change. In that design, count visible cards through a locator that identifies the rendered subset, or assert the visible state of the matching and nonmatching items. DOM cardinality and visible cardinality are not automatically the same concept.
5. Zero-count assertions and meaningful absence
await expect(locator).toHaveCount(0) is valid and useful for absence that may become true asynchronously. Examples include removed cart lines, dismissed banners, deleted comments, and search results cleared by a filter.
test('removes the archived notification', async ({ page }) => {
await page.setContent(`
<ul aria-label="Notifications">
<li>Build complete <button>Archive</button></li>
</ul>
<script>
document.querySelector('button').addEventListener('click', event => {
setTimeout(() => event.target.closest('li').remove(), 100);
});
</script>
`);
const notifications = page.getByRole('list', { name: 'Notifications' })
.getByRole('listitem');
await page.getByRole('button', { name: 'Archive' }).click();
await expect(notifications).toHaveCount(0);
});
Absence needs careful scope. page.getByText('Build complete') reaching zero proves that text is no longer matched, but it may not prove the notification record was deleted. A hidden node, changed copy, or navigation to another page could also satisfy it. Pair a zero count with the positive state that should replace the item, such as an archive confirmation or an empty-state heading.
Negative assertions deserve the same scrutiny. await expect(rows).not.toHaveCount(0) means the collection eventually has a count other than zero. It does not express which count is acceptable and may pass on a loading skeleton if the locator includes it. When the expected fixture is known, an exact positive count is clearer. When only nonempty behavior matters, assert a meaningful result element and the completed loading state.
6. Tables, pagination, and totals
Tables often expose three related numbers: rows rendered on the current page, total matching records, and selected rows. Treat them as separate contracts.
test('shows the second page of results', async ({ page }) => {
await page.goto('/users?page=2');
const table = page.getByRole('table', { name: 'Users' });
const dataRows = table.getByRole('row').filter({ has: page.getByRole('cell') });
await expect(dataRows).toHaveCount(10);
await expect(page.getByTestId('result-range')).toHaveText('11 to 20 of 24');
await expect(page.getByRole('button', { name: 'Next page' })).toBeEnabled();
});
This example assumes an application available through the configured baseURL. The row locator excludes a column-header-only row by requiring a cell. Adapt it to the accessible structure your table actually exposes. If row headers are used instead of regular cells, choose a stable data-row marker rather than copying this filter blindly.
On the final page, the rendered count might be four while the total remains 24. Asserting 24 DOM rows would confuse the data contract with the pagination contract. Test the displayed range, next-button state, and current page rows independently. This separation also improves diagnosis: a failure says whether pagination math, rendering, or control state is wrong.
For data grids that recycle rows while scrolling, DOM count may remain constant even as records change. Test visible record identifiers and scroll behavior instead of claiming that DOM node count equals dataset size. The Playwright locator best practices guide provides more strategies for scoping complex widgets.
7. Timeouts, loading signals, and deterministic data
The assertion accepts a per-call timeout option:
await expect(page.getByRole('listitem')).toHaveCount(25, { timeout: 10_000 });
Use a longer timeout only when the product contract justifies it, such as an intentionally slow batch operation. Do not extend every count assertion to conceal uncontrolled data, an incorrect locator, or a missing trigger. Configure a reasonable project-wide assertion timeout in playwright.config.ts, then override only the exceptional operation.
import { defineConfig } from '@playwright/test';
export default defineConfig({
use: {
baseURL: 'http://127.0.0.1:3000',
},
expect: {
timeout: 7_000,
},
});
Deterministic data matters more than generous waiting. If a shared environment can receive records from other tests, toHaveCount(3) may never become true or may pass transiently for the wrong records. Create uniquely identified fixtures, query within the test-owned account or container, and clean up through isolated contexts or APIs.
Loading indicators can supplement count assertions. First trigger the operation, then assert the final collection and the disappearance of the progress state when both are part of the UI contract. Avoid waiting only for a spinner to disappear, since it could vanish after an error. Likewise, avoid asserting count before the action that is supposed to establish the list. Clear ownership of state transitions is one of the strongest defenses against flaky UI tests.
8. Exact counts versus ranges and eventual growth
toHaveCount intentionally checks equality. It has no built-in atLeast, greaterThan, or range option. This is good when the expected fixture contains exactly eight cards, but a poor fit for an unbounded activity feed.
If a requirement genuinely says at least one result, prefer a user-facing condition such as a visible first result and completed loading state. For a numeric range that must be polled, expect.poll can sample locator.count() and apply a generic matcher:
await expect.poll(
async () => page.getByRole('article').count(),
{
message: 'activity feed should render at least five entries',
timeout: 10_000,
},
).toBeGreaterThanOrEqual(5);
This is a documented Playwright pattern for retrying non-locator values. Use it deliberately. The callback may run several times, so it should only read state and remain safe to repeat. A direct toHaveCount(5) is simpler and reports collection expectations more naturally when exact equality is the real contract.
Also consider whether eventual growth is the right end-to-end assertion. A feed may contain variable server data, making a minimum number fragile and low value. Inject a known event, locate the card by its unique content, and assert that specific card instead. Exact data ownership usually produces a stronger test than accommodating uncertainty with loose numeric ranges.
There is another subtle range problem: an exact count can appear briefly on the way to a larger final value. Suppose a stream renders five entries and then appends three more. toHaveCount(5) can pass as soon as five nodes exist, even if the settled collection contains eight. The assertion correctly proves that the count was five during its retry window, but it cannot know that the application planned another update. Anchor the check to an explicit completion signal, controlled response, or product state when final stability matters. For example, assert that a progress indicator reports Complete, then assert all eight known records.
This is not a reason to add a universal quiet-period helper. Quiet periods guess that no later change will occur and extend test time. Prefer an observable application contract: a completed request, an enabled Continue button, a finished status, or a known fixture boundary. When the product offers no stable completion signal, that absence can itself be a testability issue worth raising with developers.
9. How to review playwright expect toHaveCount examples in a framework
In a page object, expose a locator that represents the collection. Let the test retain the business expectation instead of hiding every assertion inside a generic utility.
import type { Locator, Page } from '@playwright/test';
export class OrdersPage {
readonly orders: Locator;
constructor(private readonly page: Page) {
this.orders = page
.getByRole('table', { name: 'Orders' })
.getByRole('row')
.filter({ has: page.getByRole('cell') });
}
async open(): Promise<void> {
await this.page.goto('/orders');
}
}
import { test, expect } from '@playwright/test';
import { OrdersPage } from './OrdersPage';
test('shows orders created by the fixture', async ({ page }) => {
const ordersPage = new OrdersPage(page);
await ordersPage.open();
await expect(ordersPage.orders).toHaveCount(4);
});
This design keeps the selector knowledge in one place and the expected count in the scenario. A method named expectOrderCount(4) can be reasonable when it adds domain diagnostics, but thin wrappers around every Playwright matcher often obscure stack traces and make tests harder to read.
During review, ask four questions: Does the locator count only the intended nodes? Is the exact number controlled by this test? Does a product action or fixture explain why that number should appear? Would identity or content assertions catch duplicates and wrong records? These questions are more useful than debating selector syntax in isolation.
10. Debugging failed count assertions
When a count times out, do not immediately add a retry. Read the call log and inspect the locator in the trace. Then determine whether the actual count was always wrong, changed but never settled, or reached the target before moving away.
A focused diagnostic can capture the current count and text after the primary assertion fails during local investigation:
const rows = page.getByRole('table', { name: 'Orders' })
.getByRole('row');
console.log('row count:', await rows.count());
console.log('row text:', await rows.allInnerTexts());
Do not leave noisy logging as the fix. Use the evidence to correct scope, data, or timing. If skeleton rows inflate the count, locate real rows through a stable attribute or accessible content. If records from another test appear, isolate the account. If the page renders fewer nodes because of virtualization, change the assertion to visible records. If the locator matches zero because it is inside an iframe, build it from the correct frame locator.
Run the failing test with the Playwright trace enabled and inspect the DOM snapshots around the action. A screenshot alone may hide detached or offscreen nodes, while the trace shows locator resolution and action history. The guide to debugging Playwright tests with Trace Viewer expands this workflow.
Reproduce the test alone and in its normal parallel shard. A failure only under parallel execution often points to shared records, reused accounts, or cleanup races rather than Playwright's assertion engine. Preserve the failing trace and fixture identifiers before rerunning, since an automatic retry may pass after the conflicting record is removed. Treat retries as diagnostic evidence, not proof that the original failure was harmless.
Interview Questions and Answers
Q: Why is toHaveCount called a web-first assertion?
It receives a locator and repeatedly resolves that locator while waiting for the exact count. That behavior aligns the check with asynchronous browser rendering. A generic assertion on await locator.count() receives only one number and therefore cannot re-resolve the collection.
Q: Does toHaveCount(0) prove that a database record was deleted?
No. It proves that the locator currently resolves to zero matching DOM nodes within the assertion contract. I would pair it with the replacement UI state and use an API or database-level check when persistence is an explicit requirement.
Q: How would you assert at least five cards?
toHaveCount only expresses exact equality. If five is an exact controlled result, I use toHaveCount(5). If the real requirement is a minimum, I first look for a stronger user-visible condition, otherwise I use expect.poll on locator.count() with toBeGreaterThanOrEqual(5) and a bounded timeout.
Q: Why can a broad CSS locator make a count test pass incorrectly?
It can include unrelated elements such as hidden templates, navigation items, or skeletons. The total may accidentally equal the expected number even though the feature is broken. I scope to an accessible region or stable component and narrow to the business subset before asserting.
Q: How do virtualized lists affect toHaveCount?
Virtualized widgets often keep only a window of records in the DOM and recycle nodes during scrolling. DOM node count therefore does not equal dataset size. I assert displayed totals, visible record identities, scrolling behavior, and the data contract separately.
Q: Where should count assertions live in a page-object design?
The page object should usually expose a well-scoped Locator, while the test states the scenario-specific expected number. A domain assertion method is useful only when it adds meaningful behavior or diagnostics. Thin wrappers around every matcher add indirection without improving the contract.
Common Mistakes
- Calling
count()and then expecting automatic retries fromtoBe. - Counting a page-wide selector that includes headers, templates, skeletons, or unrelated lists.
- Using an exact count against shared or uncontrolled environment data.
- Treating current-page DOM rows as the server's total record count.
- Assuming hidden or virtualized items disappear from the DOM.
- Using
not.toHaveCount(0)when a known exact fixture count is available. - Adding
waitForTimeoutbefore a snapshot count instead of waiting for the required state. - Asserting only cardinality when duplicate or incorrect item identities are the real risk.
- Extending timeouts before investigating whether the action fired and the locator is correct.
Conclusion
Strong playwright expect toHaveCount examples preserve the locator, assert an exact business-relevant size, and rely on controlled data. Use await expect(locator).toHaveCount(number) for dynamic collections, scope the locator before counting, and add identity or text assertions when the number alone cannot prove correctness.
Start with one unstable list test in your suite. Replace any fixed delay plus count() snapshot with a scoped toHaveCount, then verify that the expected number comes from test-owned data. That small refactor usually improves both reliability and the quality of failure diagnostics.
Interview Questions and Answers
Explain how Playwright toHaveCount works.
`toHaveCount` is a web-first locator assertion that expects an exact number. Playwright repeatedly resolves the locator and compares the number of matching DOM nodes during the assertion timeout. This makes it suitable for collections that render asynchronously.
Why is expect await locator count toBe less reliable for a dynamic list?
`locator.count()` produces one numeric snapshot, and the generic `toBe` matcher checks it once. It cannot re-resolve the DOM after the value is captured. I preserve the locator and use `toHaveCount` when the expected final size is exact.
How would you assert a minimum collection size in Playwright?
I first look for a stronger user-facing condition, such as a known result and completed loading state. If the requirement truly is numeric, I use `expect.poll` with an idempotent callback that returns `locator.count()`, then apply `toBeGreaterThanOrEqual`. I give that poll a bounded timeout and meaningful message.
What can make a toHaveCount assertion pass for the wrong reason?
A broad locator can include skeletons, hidden templates, navigation elements, or records owned by another test. Their combined number may accidentally equal the expectation. I scope the locator to a business region and add content or identity checks when cardinality is insufficient.
How do you test a virtualized list with Playwright?
I do not equate DOM node count with dataset size because virtualized controls recycle a small rendered window. I verify the displayed total, visible record identities, scroll or paging behavior, and server data separately. The assertion strategy follows the widget's observable contract.
Where should toHaveCount live in a page object framework?
The page or component object should usually expose a typed, well-scoped Locator. The scenario should state its expected count because it owns the fixture and business rule. I add a domain assertion method only when it provides meaningful coordination or diagnostics beyond a thin matcher wrapper.
How would you debug a flaky count assertion?
I inspect the call log and trace to see how the count changed over time. Then I check for skeletons, hidden nodes, virtualization, wrong frames, shared records, and a missing completion signal. I reproduce alone and in parallel before changing timeouts or adding retries.
Frequently Asked Questions
How do I use Playwright toHaveCount?
Create a locator for the repeated elements and write `await expect(locator).toHaveCount(expectedNumber)`. Playwright re-resolves the locator and retries until the exact count matches or the assertion timeout expires.
What is the difference between toHaveCount and locator.count in Playwright?
`toHaveCount` is a retrying locator assertion. `locator.count()` returns one numeric snapshot, so `expect(await locator.count()).toBe(n)` can race with asynchronous rendering unless another condition has already established stability.
Can Playwright toHaveCount check for at least one element?
`toHaveCount` checks exact equality and has no at-least option. Prefer a meaningful positive element assertion, or use `expect.poll` with `locator.count()` and a numeric matcher when a real minimum-count requirement exists.
Is toHaveCount zero valid in Playwright?
Yes. `await expect(locator).toHaveCount(0)` retries until no DOM nodes match, which is useful after deletion or dismissal. Pair it with the expected replacement state when absence alone could pass for the wrong reason.
Does toHaveCount count only visible elements?
It counts every DOM node resolved by the locator, including hidden matches when the locator includes them. Build a locator that represents the intended rendered subset, or use visibility assertions when visible state is the real requirement.
How do I set a timeout for Playwright toHaveCount?
Pass `{ timeout: milliseconds }` as the second matcher argument, for example `toHaveCount(10, { timeout: 10000 })`. Use local overrides for legitimately slow operations and keep ordinary assertion timing in the Playwright configuration.
Why does a table row count differ from the total records?
Pagination and virtualization often render only a subset of records as DOM rows. Assert current-page rows, displayed totals, pagination controls, and dataset behavior as separate contracts rather than expecting one DOM count to prove all of them.
Related Guides
- Playwright drag and drop: Examples and Best Practices
- Playwright expect soft: Examples and Best Practices
- Playwright expect toBeVisible: Examples and Best Practices
- Playwright expect toHaveText: Examples and Best Practices
- Playwright expect toHaveURL: Examples and Best Practices
- Playwright expect toHaveValue: Examples and Best Practices