Resource library

QA How-To

How to Use Playwright expect toHaveCount (2026)

Learn playwright expect toHaveCount syntax, retry behavior, filtering, zero counts, dynamic lists, pagination, virtualization, and debugging in UI tests.

25 min read | 2,521 words

TL;DR

Use `await expect(locator).toHaveCount(expected)` to retry until a locator matches exactly the required number of DOM nodes. It counts hidden matches too, so scope the locator carefully and derive the expected value from stable test data or a clear product rule.

Key Takeaways

  • Use await expect(locator).toHaveCount(n) for a retrying exact DOM cardinality assertion.
  • Scope and filter the locator because hidden nodes, headers, skeletons, and duplicate components can affect count.
  • Derive expected numbers from seeded data, trusted APIs, or explicit product rules.
  • Use toHaveCount(0) for required DOM absence and toBeHidden for hidden-or-removed behavior.
  • Use array toHaveText when collection labels and order matter in addition to count.
  • Model pagination and virtualization according to mounted DOM behavior rather than logical dataset size.
  • Use immediate count and text reads for diagnostics, not as substitutes for web-first assertions.

Playwright expect toHaveCount is the retrying locator assertion for verifying that a locator resolves to an exact number of DOM nodes. Use await expect(locator).toHaveCount(expected) when a list, row set, card grid, badge collection, or disappearing element must reach a known cardinality.

Unlike locator.count() followed by a generic assertion, toHaveCount() keeps re-resolving the locator until the expected count appears or the assertion timeout expires. That makes it the right default for dynamic interfaces, provided the expected number comes from a trustworthy requirement or test-data source.

TL;DR

const rows = page.getByRole('row').filter({ hasText: 'Active' });
await expect(rows).toHaveCount(5);
Need Best assertion
Exact number of matching elements toHaveCount(n)
No matching elements remain toHaveCount(0)
Exact text and order for a collection toHaveText([...])
At least one item is visible expect(locator.first()).toBeVisible()
Current count for logging or branching await locator.count()
All expected items are individually visible Count first, then assert selected items or iterate a stable collection

toHaveCount() measures matching DOM nodes, not only visible nodes. Scope the locator and filter carefully so the count represents the business collection you intend to test.

1. What Does Playwright expect toHaveCount Assert?

toHaveCount(expected) is a Playwright Test locator assertion. It succeeds when the locator resolves to exactly the numeric count passed as its first argument. The expectation is strict: three matches does not satisfy an expectation of two or "at least two."

The signature is:

await expect(locator).toHaveCount(expectedCount);
await expect(locator).toHaveCount(expectedCount, { timeout: 10_000 });

The assertion retries. If a request populates a list over time, Playwright can observe zero, one, and then three matches before passing at three. If the count never stabilizes at the expected value before timeout, the assertion fails with the expected and received counts plus a call log.

Cardinality applies to the locator result in the DOM. Hidden templates, responsive duplicates, and collapsed content can be included if they match. toHaveCount(4) does not mean four visible cards. Use a locator that represents the active collection, then add visibility assertions when that property matters.

An exact count is most valuable when the source of truth is explicit: seeded records, an API response, a filter requirement, pagination size, or a user action that must add one item. A hard-coded count copied from today's shared environment is fragile and rarely proves a durable rule.

2. Runnable Playwright expect toHaveCount Example

This self-contained example adds three list items asynchronously after a button click. The count assertion waits for the final cardinality without a fixed delay.

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

test('adds three queued jobs', async ({ page }) => {
  await page.setContent(`
    <button type="button">Queue jobs</button>
    <ul aria-label="Queued jobs"></ul>
    <script>
      const button = document.querySelector('button');
      const list = document.querySelector('ul');
      button.addEventListener('click', () => {
        ['Lint', 'Unit tests', 'Browser tests'].forEach((name, index) => {
          setTimeout(() => {
            const item = document.createElement('li');
            item.textContent = name;
            list.appendChild(item);
          }, (index + 1) * 100);
        });
      });
    </script>
  `);

  const jobs = page.getByRole('list', { name: 'Queued jobs' })
    .getByRole('listitem');

  await expect(jobs).toHaveCount(0);

  await page.getByRole('button', { name: 'Queue jobs' }).click();

  await expect(jobs).toHaveCount(3);
  await expect(jobs).toHaveText([
    'Lint',
    'Unit tests',
    'Browser tests',
  ]);
});

The count assertion establishes cardinality. The array form of toHaveText establishes both values and order, so the test checks more than "three arbitrary items exist."

The locator is scoped to the named list. A global getByRole('listitem') could count navigation, help, or footer items and make the expectation depend on unrelated page structure.

This pattern applies to dynamic tables and card grids: define the business container first, then count its child records.

3. toHaveCount vs count, all, and Text Arrays

These APIs look related but have different waiting and assertion behavior.

API Retries for expected state? Returns data? Recommended purpose
expect(locator).toHaveCount(n) Yes No Assert exact collection size
locator.count() No assertion retry Yes, number Diagnostics or settled optional branching
locator.all() No Yes, locators Iterate an already stable collection
locator.allTextContents() No assertion retry Yes, strings Diagnostic snapshot
expect(locator).toHaveText([...]) Yes No Assert collection text and order

Avoid this timing-sensitive pattern:

expect(await page.getByTestId('result-row').count()).toBe(10);

Prefer:

await expect(page.getByTestId('result-row')).toHaveCount(10);

The generic assertion evaluates one returned number. The locator assertion retries the count condition.

locator.all() is not a waiting primitive. If the list is still loading, it returns locators for the elements present at that moment. Assert the final count first, then call all() only if per-item checks are genuinely necessary.

When exact labels and order are the requirement, an array text assertion is often stronger and more concise than count plus a loop:

await expect(page.getByRole('listitem')).toHaveText([
  'Draft',
  'In review',
  'Approved',
]);

For assertion retry concepts, see the Playwright web-first assertions guide.

4. Scope and Filter Before Counting

A count is only as meaningful as its locator. Start with a semantic container and filter records according to the user-facing rule.

const table = page.getByRole('table', { name: 'Applications' });
const bodyRows = table.getByRole('row').filter({
  has: page.getByRole('cell'),
});

await expect(bodyRows).toHaveCount(8);

Depending on markup, role row may include a header row. A product-provided test ID on data rows can be clearer:

const applicationRows = table.getByTestId('application-row');
await expect(applicationRows).toHaveCount(8);

Filter by text for a business subset:

const failedRows = table.getByTestId('application-row').filter({
  hasText: 'Failed',
});

await expect(failedRows).toHaveCount(2);

Or locate cards containing a specific nested control:

const selectablePlans = page.getByRole('article').filter({
  has: page.getByRole('button', { name: 'Select plan' }),
});

await expect(selectablePlans).toHaveCount(3);

Do not count a global CSS class shared by skeletons, hidden templates, and real records. If hidden duplicates are unavoidable, scope to the active named region. Counting locator(':visible') can be tempting, but CSS visibility filters tie the test to selector-engine behavior and may obscure the semantic contract. Prefer an active container and explicit state.

The Playwright locator strategy guide provides a broader hierarchy for robust selectors.

5. Assert Zero for Removal and Empty Results

toHaveCount(0) is a clean retrying assertion that no matching nodes remain. Use it when DOM absence is the requirement.

const row = page.getByTestId('user-row').filter({
  hasText: 'obsolete-user@example.test',
});

await expect(row).toHaveCount(1);
await row.getByRole('button', { name: 'Delete' }).click();
await page.getByRole('button', { name: 'Confirm deletion' }).click();

await expect(row).toHaveCount(0);

For a search with no matches, pair zero result rows with a positive empty state:

const results = page.getByRole('region', { name: 'Search results' });
const rows = results.getByTestId('result-row');

await expect(rows).toHaveCount(0);
await expect(
  results.getByRole('heading', { name: 'No results found' }),
).toBeVisible();

The empty-state assertion distinguishes a successful zero-result response from a list that never loaded.

Do not use count zero when the application intentionally keeps a matched node mounted but hidden. A closed dialog, inactive tabpanel, or dismissed banner may remain in the DOM. In that case, use toBeHidden().

Also avoid asserting zero before the page has entered the relevant state. A count of zero can pass immediately before an asynchronous request adds unwanted records. Trigger the search or deletion, wait for the product's completion signal, then assert the final empty state.

6. Derive Expected Count From API or Seeded Data

Dynamic environments make hard-coded counts unreliable. Derive the expectation from records created by the test or from a trusted API response.

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

type Order = {
  id: string;
  status: 'open' | 'closed';
};

test('renders every open order from the API', async ({ page, request }) => {
  const response = await request.get('/api/orders?status=open');
  expect(response.ok()).toBeTruthy();

  const orders = (await response.json()) as Order[];

  await page.goto('/orders?status=open');

  const rows = page.getByRole('table', { name: 'Open orders' })
    .getByTestId('order-row');

  await expect(rows).toHaveCount(orders.length);

  await expect(rows).toHaveText(
    orders.map((order) => new RegExp(order.id)),
  );
});

The response success check is hard because the expected count depends on valid data. The array text assertion assumes API and UI ordering should match. If the UI has a documented sort that differs, sort the expected identifiers according to that public rule before asserting.

Be cautious when the API and UI use different authorization or filters. An admin request context can return records a standard user cannot see. The expected source must share the same business scope.

Seeding records in the test often provides stronger isolation:

const createdOrders = await createOrders(request, 3);
await page.goto('/orders?owner=test-run');

await expect(page.getByTestId('order-row')).toHaveCount(createdOrders.length);

Clean up through fixtures or APIs according to suite policy so later tests do not inherit the count.

7. Filters, Search, and Result Count Transitions

A filter test should establish the initial collection, apply the filter, and assert the new exact count plus representative content.

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

test('filters applications by status', async ({ page }) => {
  await page.goto('/applications');

  const rows = page.getByTestId('application-row');
  await expect(rows).toHaveCount(12);

  await page.getByLabel('Status').selectOption('interview');
  await expect(page.getByRole('status')).toHaveText('Filter applied');

  await expect(rows).toHaveCount(4);
  await expect(rows.first()).toContainText('Interview');
});

The illustrative counts must come from seeded test data. In a shared environment, derive them from the test fixture or API instead of assuming 12 and 4.

If the application updates the URL, assert it as another state signal:

await expect(page).toHaveURL(/status=interview/);
await expect(rows).toHaveCount(expectedInterviewCount);

Do not assert only that the count changed. A change from 12 to 11 may still be wrong when four records should remain. Exact count is the purpose of the matcher.

For debounced search, fill the query and assert final results. Avoid sleeping for the debounce duration because the UI outcome already supplies a retrying condition. If a stale request can overwrite a newer one, a count plus expected labels can catch the race.

8. Pagination, Load More, and Infinite Lists

Pagination changes which collection exists in the DOM. Define whether the contract is page size, cumulative loaded count, or total result metadata.

For a "Load more" design:

const cards = page.getByTestId('product-card');

await expect(cards).toHaveCount(20);
await page.getByRole('button', { name: 'Load more' }).click();
await expect(cards).toHaveCount(40);

This works when the product appends 20 records and keeps the first set mounted. If it replaces the page, the expected count may remain 20 while labels change.

For next-page replacement:

await expect(cards).toHaveCount(20);
await page.getByRole('button', { name: 'Next page' }).click();
await expect(page).toHaveURL(/page=2/);
await expect(cards).toHaveCount(20);
await expect(cards.first()).toContainText('Product 21');

Infinite scroll requires an observable trigger and a deterministic dataset. Scroll the sentinel or final current card into view, then assert the cumulative count:

await cards.last().scrollIntoViewIfNeeded();
await expect(cards).toHaveCount(40);

Do not confuse a "48 results" label with 48 mounted cards. Products can paginate or virtualize, so assert total metadata separately from DOM cardinality.

9. Virtualized Lists and Why DOM Count Can Mislead

Virtualized components mount only a window of rows and reuse DOM nodes as the user scrolls. A list may represent 10,000 records while only 20 row elements exist. toHaveCount(10_000) is wrong for that implementation and can never pass.

Test each layer with the right contract:

Requirement Suitable assertion
Total result metadata Assert the "10,000 results" text
Visible virtual window size Count mounted rows only if window size is a requirement
Specific record can be found Use search, scroll, or product navigation, then assert identity
Accessible collection metadata Assert supported attributes or accessible snapshot
Pagination page size Count page rows when all page rows are mounted

A virtual list test might search for a record:

await page.getByLabel('Search users').fill('ava.patel@example.test');

const rows = page.getByTestId('virtual-user-row');
await expect(rows).toHaveCount(1);
await expect(rows.first()).toContainText('ava.patel@example.test');

The search reduces the logical dataset so DOM count becomes meaningful. Alternatively, assert that a target row becomes visible after the component's documented scroll operation.

Do not disable virtualization in an end-to-end test simply to make counting convenient. That changes production behavior. A component test can use a deterministic viewport or data adapter when the virtualization algorithm itself is under test.

10. Timeouts and Stabilization

The assertion timeout comes from expect.timeout unless you pass an option. Use a local timeout for a collection that legitimately loads more slowly.

await expect(page.getByTestId('report-row')).toHaveCount(50, {
  timeout: 15_000,
});

A larger timeout is not a fix for an unstable expected count. Some interfaces stream records and briefly reach the expected number before adding more. Because the assertion passes as soon as it observes the exact count, it does not prove that count remains stable afterward.

If stable completion matters, wait for an authoritative completion signal first:

await expect(page.getByRole('status')).toHaveText('Import complete');
await expect(page.getByTestId('import-row')).toHaveCount(expectedRows);

Do not use two identical count assertions with a sleep between them. Prefer the product's completion state, network contract, or disabled load control.

Remember that a long assertion still operates inside the test timeout. Configure these values coherently:

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

export default defineConfig({
  timeout: 30_000,
  expect: { timeout: 5_000 },
});

The Playwright timeout diagnosis guide covers call logs, traces, and timeout scopes.

11. Debug a Failing Count Assertion

A count failure usually comes from one of four sources: wrong expectation, wrong locator scope, unfinished application state, or extra hidden markup.

Capture a current diagnostic snapshot:

const rows = page.getByTestId('result-row');

console.log('current count:', await rows.count());
console.log('current text:', await rows.allTextContents());

Then inspect representative markup:

for (const row of await rows.all()) {
  console.log(await row.evaluate((element) => ({
    html: element.outerHTML,
    hidden: element.hasAttribute('hidden'),
    ariaHidden: element.getAttribute('aria-hidden'),
  })));
}

Use these snapshots only for diagnosis. The final expectation should remain a retrying assertion.

Review in this order:

  1. Is the expected count derived from a stable requirement or dataset?
  2. Does the locator include a header, skeleton, template, or hidden duplicate?
  3. Is it scoped to the correct table, dialog, or region?
  4. Did the filter, search, or navigation action complete?
  5. Does pagination or virtualization limit mounted nodes?
  6. Can background polling add or remove records after the assertion?
  7. Do the trace and network log show an application error?

A broad global test ID can still be wrong if the same component is rendered twice. Test IDs improve stability, but they do not replace scope.

12. Playwright expect toHaveCount Page Object Pattern

A page object can expose collection locators and business-focused count expectations.

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

export class ApplicationsTable {
  readonly root: Locator;
  readonly rows: Locator;
  readonly emptyState: Locator;

  constructor(page: Page) {
    this.root = page.getByRole('table', { name: 'Applications' });
    this.rows = this.root.getByTestId('application-row');
    this.emptyState = page.getByRole('heading', {
      name: 'No applications found',
    });
  }

  async expectRowCount(expected: number): Promise<void> {
    await expect(
      this.rows,
      'application table should show the expected records',
    ).toHaveCount(expected);
  }

  async expectEmpty(): Promise<void> {
    await expect(this.rows).toHaveCount(0);
    await expect(this.emptyState).toBeVisible();
  }
}

The helper receives an expected count rather than hard-coding environment data. The test owns how that number is derived.

Avoid a page object method that returns await rows.count() and forces every caller to build a generic assertion. The native locator matcher provides retry behavior and better diagnostics. Keep an immediate count method only if callers truly need a settled numeric value for branching or logging.

If order and content matter, add a separate method using toHaveText(expectedLabels). Do not make a single helper accept every possible list condition through loosely typed options.

13. Best Practices for Exact Collection Assertions

Apply these rules in production suites:

  • Derive expected counts from seeded data, a trusted API, or an explicit product rule.
  • Scope locators to the intended table, list, grid, region, or dialog.
  • Account for header rows, skeletons, templates, and responsive duplicates.
  • Use toHaveCount(0) only when DOM absence is required.
  • Pair empty collections with a positive empty-state assertion.
  • Use array text assertions when labels and order matter.
  • Assert completion before counting streaming or eventually stable data.
  • Model pagination and virtualization according to mounted DOM behavior.
  • Use count() and allTextContents() for diagnostics, not asynchronous expectations.
  • Avoid fixed sleeps and manual polling loops.
  • Set local timeouts only for legitimate longer operations.
  • Preserve call logs, traces, and network evidence when the count never arrives.

A useful review question is: "What real product fact does this number represent?" If the answer is "whatever happened to be in the environment," the test needs better data control before it needs a count assertion.

Interview Questions and Answers

Q: What does toHaveCount() do in Playwright?

It retries until a locator resolves to exactly the expected number of DOM nodes or the assertion timeout expires. It is the web-first alternative to taking one immediate count and asserting that number.

Q: How is toHaveCount() different from locator.count()?

toHaveCount() is a retrying assertion with diagnostics. count() returns the current number and is useful for logging, debugging, or settled branching, but a generic assertion on it does not auto-retry.

Q: Does toHaveCount() count only visible elements?

No. It counts every DOM node matched by the locator, including hidden matches. I scope the locator to the active collection and use visibility assertions separately when required.

Q: When should you assert count zero?

I use it when no matching DOM node should remain, such as after deleting a row. If hidden or unmounted states are both acceptable, toBeHidden() better expresses the visual requirement.

Q: How do you test a virtualized list count?

I do not compare logical total records with mounted row nodes. I assert total metadata separately, then test a visible window, search result, or target record through the component's supported behavior.

Q: How do you get a trustworthy expected count?

I derive it from records created by the test, a successful API response with matching authorization and filters, or a fixed fixture. I avoid hard-coded counts from mutable shared environments.

Q: When is an array toHaveText assertion better?

It is better when exact collection labels and order are part of the requirement. It verifies cardinality, content, and order more strongly than a count alone.

Q: How do you debug an unexpected extra match?

I inspect count, text, and representative markup, then check header rows, skeletons, hidden templates, responsive copies, and incorrect container scope. The trace helps confirm when extra nodes entered the DOM.

Common Mistakes

  • Writing expect(await locator.count()).toBe(n) for an asynchronous list.
  • Assuming only visible elements contribute to the count.
  • Counting header rows or skeleton cards accidentally.
  • Hard-coding counts from shared, mutable data.
  • Asserting zero before the search or deletion has completed.
  • Treating spinner disappearance as proof of final list size.
  • Calling locator.all() before the collection stabilizes.
  • Expecting total dataset size from a virtualized DOM window.
  • Ignoring pagination replacement versus cumulative loading.
  • Using a long timeout to mask continuously changing data.
  • Verifying count without labels when content and order matter.
  • Returning immediate counts from every page object helper.

Conclusion

Playwright expect toHaveCount is the correct assertion when a well-scoped locator must reach an exact DOM cardinality. It retries the collection state, reports expected and observed counts, and removes the need for sleeps or homemade polling.

Make the number trustworthy. Derive it from controlled data or an explicit rule, account for hidden nodes and virtualization, and pair count with content, order, visibility, or empty-state assertions when those properties matter.

Interview Questions and Answers

Explain toHaveCount in Playwright.

It is a web-first locator assertion that retries until the locator resolves to exactly the expected number of DOM nodes. It uses the configured assertion timeout and reports the received count when it fails.

Why is expect(await locator.count()).toBe(n) less reliable?

It asserts one immediate numeric snapshot, so it can run before asynchronous rendering completes. `toHaveCount(n)` keeps resolving the locator and retrying the condition.

Does toHaveCount imply visibility?

No. Hidden and visible matching nodes both contribute. I build a locator around the active semantic container and assert visibility separately if the requirement includes it.

How would you assert an empty search result?

I wait for the search completion signal, assert result rows have count zero, and assert a positive empty-state heading or message. That distinguishes valid emptiness from a list that never loaded.

How do you test count after pagination?

I first determine whether the product appends records or replaces the current page. For append behavior I assert cumulative count, while replacement pagination may retain page size and requires URL and content assertions.

What changes for virtualized tables?

Only a window of records is mounted, so DOM count cannot represent the total dataset. I test total metadata, supported search or scrolling, and specific record accessibility instead of expecting all logical rows.

How do you avoid hard-coded count failures in shared environments?

I seed owned records or query a trusted API under the same authorization and filters, then derive the expected number. I also isolate cleanup so parallel tests cannot alter the collection.

How do you debug a count that is one too high?

I inspect current texts and markup for all matches, then check headers, skeletons, templates, responsive duplicates, and container scope. I also inspect the trace to see whether stale or background-updated nodes remain.

Frequently Asked Questions

How do I assert an element count in Playwright?

Create a locator for the collection and use `await expect(locator).toHaveCount(expected)`. The assertion retries until the exact count is reached or the expect timeout expires.

What is the difference between toHaveCount and locator count?

`toHaveCount()` is a retrying assertion with failure diagnostics. `locator.count()` returns the current number once, so a generic assertion on that value can race with dynamic rendering.

Does Playwright toHaveCount count hidden elements?

Yes. It counts all DOM nodes matched by the locator, regardless of visibility. Scope to the active collection or add separate visibility assertions.

How do I wait until no Playwright elements remain?

Use `await expect(locator).toHaveCount(0)` when the nodes must be absent. Use `toBeHidden()` if either hidden or removed satisfies the requirement.

How do I set a timeout for toHaveCount?

Pass an options object, such as `toHaveCount(20, { timeout: 10000 })`. Prefer a local override and make sure the surrounding test timeout leaves enough budget.

How should I test the count of a virtualized list?

Do not equate mounted DOM rows with total logical records. Assert total metadata separately and test a visible window, search result, or specific record through supported scrolling or filtering.

Should I use toHaveCount or toHaveText for a list?

Use count when only cardinality matters. Use the array form of `toHaveText` when exact labels and order matter, because it provides a stronger collection assertion.

Related Guides