PRACTICAL GUIDE / playwright locators for dynamic tables

Playwright Locators for Virtualized Tables and Repeating Rows

Learn reliable Playwright locators for virtualized tables, recycled rows, exact cell filters, scrolling, pagination, and stable row assertions.

By The Testing AcademyUpdated July 11, 20269 min read
All field guides
In this guide10 sections
  1. Define the Visible Row Contract
  2. Build a Semantic Row Locator
  3. Filter by Identity Before State
  4. Search Beyond the Mounted Window
  5. Separate Dataset Assertions from DOM Assertions
  6. Handle Pagination and Sorting Deliberately
  7. Diagnose Common Failures
  8. Weigh the Tradeoffs
  9. Operational Checklist
  10. Conclusion: Test the Window and the Record

What you will learn

  • Define the Visible Row Contract
  • Build a Semantic Row Locator
  • Filter by Identity Before State
  • Search Beyond the Mounted Window

A virtualized table is not a large HTML table. It is a moving window over a larger dataset, and its DOM may contain only a dozen rows even when the application reports thousands of records. Reliable Playwright locators for dynamic tables must identify the business row that is currently mounted, move the viewport when it is not mounted, and assert dataset facts through a separate product signal.

The central mistake is treating a rendered row index as a record identity. A recycled row element can represent order ORD-1042, disappear during scrolling, and later represent another order. Keep a Locator, which resolves against the current DOM when used, instead of caching an element handle or assuming that the fifth node still means the fifth record.

Define the Visible Row Contract

First document what the widget exposes. A native table should provide table, row, columnheader, and cell semantics. A grid may expose row and gridcell roles instead. Some virtualized components use a list with listitems. Choose the role that matches the accessibility tree rather than forcing every widget through a table tbody tr selector.

Also identify the viewport element, the record key, the loading signal, and the total-count signal. The total may be visible text such as "2,480 orders" or metadata such as aria-rowcount. Rendered row count and dataset count are intentionally different measurements.

The official Playwright locator guide explains why a locator resolves an up-to-date DOM element for each action. That behavior is especially valuable when virtualization replaces nodes during scrolling.

Animated field map

Locating a Record in a Virtualized Table

Treat mounting, row identity, movement, and the final assertion as separate responsibilities.

  1. 01 / visible contract

    Visible Row Contract

    Identify the mounted row role, viewport, and loading signal.

  2. 02 / live row locator

    Live Row Locator

    Resolve rows again whenever the virtualized DOM changes.

  3. 03 / semantic filter

    Semantic Filter

    Narrow by an exact, immutable cell value or explicit test id.

  4. 04 / move window

    Scroll or Paginate

    Move the data window until the target record is mounted.

  5. 05 / row assertion

    Row Assertion

    Verify identity and the requested state inside the same row.

Build a Semantic Row Locator

Start at the repeating unit and narrow inward. This keeps the locator readable and makes Playwright's strictness check work for you. In an orders grid, the stable identity might be an order number, not a customer name that can repeat.

TypeScript
import { expect, test } from "@playwright/test";

test("paid order exposes its invoice action", async ({ page }) => {
  await page.goto("/orders");

  const rows = page.getByRole("row");
  const order = rows.filter({
    has: page.getByRole("gridcell", { name: "ORD-1042", exact: true }),
  });

  await expect(order).toHaveCount(1);
  await expect(order.getByRole("gridcell", { name: "Paid", exact: true }))
    .toBeVisible();
  await expect(order.getByRole("button", { name: "Download invoice" }))
    .toBeEnabled();
});

The inner locator supplied to has is evaluated relative to each candidate row. Do not start that inner locator from an ancestor outside the row. If the cell role is unavailable, an explicit data-testid for the record key is a sound contract, but a long CSS chain tied to generated classes is not.

Filter by Identity Before State

Status text is rarely unique. Five rows can be "Pending", and two customers can share a display name. Filter by the immutable record key first, then assert mutable fields within that row. This ordering prevents a test from passing against the wrong record simply because another row has the expected state.

Exact matching matters for identifiers. A substring search for ORD-1042 can also match ORD-10420. Use exact: true, a boundary-aware regular expression, or a dedicated test id. If a composite key is required, chain filters for tenant and order number and retain the one-row assertion.

TypeScript
const row = page
  .getByRole("row")
  .filter({ has: page.getByText("North region", { exact: true }) })
  .filter({ has: page.getByText("ORD-1042", { exact: true }) });

await expect(row, "one row must own this regional order key").toHaveCount(1);
await expect(row).toContainText("Ready to ship");

Search Beyond the Mounted Window

When the target is outside the viewport, a correct locator initially has zero matches. It does not need a weaker selector; the table needs movement. Prefer an application search or server-side filter when that is the user's normal route. It is faster, easier to diagnose, and validates a meaningful workflow. Use scrolling when infinite scroll or virtual navigation is itself relevant.

The following helper advances the declared scroll container and rechecks a live locator. It has a bounded timeout, notices the end of the viewport, and never stores a row element across renders.

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

async function revealOrder(page: Page, orderId: string): Promise<Locator> {
  const viewport = page.getByTestId("orders-scroll-viewport");
  const target = page.getByRole("row").filter({
    has: page.getByRole("gridcell", { name: orderId, exact: true }),
  });

  await expect.poll(async () => {
    const matches = await target.count();
    if (matches === 1) return "found";
    if (matches > 1) return "duplicate";

    return viewport.evaluate((element) => {
      const before = element.scrollTop;
      element.scrollBy({ top: element.clientHeight * 0.8 });
      return element.scrollTop > before ? "searching" : "end";
    });
  }, {
    message: `mount exactly one row for ${orderId}`,
    intervals: [100, 200, 400],
    timeout: 8_000,
  }).toBe("found");

  return target;
}

If scrolling triggers network loading, assert the widget's loading indicator or returned row state instead of adding a fixed sleep. A timeout that ends with end tells you the record was not reachable; duplicate tells you the data or locator contract is wrong.

Separate Dataset Assertions from DOM Assertions

expect(rows).toHaveCount(500) is invalid when the widget intentionally mounts 20 rows. Assert the complete result count through the product's count label, pagination summary, API contract, or aria-rowcount. Then assert only what the viewport promises: the desired row is mounted, unique, and correct.

For sorted results, position can be meaningful. After selecting "Newest first", asserting the first visible data row has the newest known order tests the sorting contract. Even then, exclude header rows explicitly and wait for the sort completion signal before using first().

Handle Pagination and Sorting Deliberately

Pagination changes the search strategy. If the target page is known from product data, navigate directly through the pager and then filter the mounted rows. If it is not known, advance pages while recording the current page label and stop when "Next" becomes disabled. Never write an unbounded while loop around a click.

Sorting can remount every row without changing the page. Keep locator definitions outside the action if they express stable semantics; Playwright will resolve them again. Recalculate extracted arrays after the sort because allTextContents() returns a snapshot, not a live collection.

Accessibility supplies an additional oracle for complex grids. Verify that a keyboard user can enter the grid, move through rendered rows, and encounter an accurate logical position when the component promises that behavior. Attributes such as aria-rowcount and aria-rowindex can describe the dataset even when only one window is mounted, but they must agree with the product's real ordering. Do not use those attributes merely as convenient selectors; assert them when logical position is part of the accessible contract. This catches defects where visual scrolling works while assistive technology receives repeated or impossible positions.

Diagnose Common Failures

A strict mode violation means the row key is not unique in the current DOM. Inspect both matches and improve the key; do not silence the evidence with .first(). A zero-count timeout at the bottom of the viewport usually means the filter does not match normalized visible text, the record is excluded by an active server filter, or the table requires pagination rather than scrolling.

Wrong-row clicks often come from nth() or from locating a button globally after locating the row. Scope every child action through the filtered row. Detached-element errors usually indicate an element handle or evaluated node was kept across a render; return to locators. Flaky total counts indicate that the test is confusing mounted nodes with dataset metadata.

Weigh the Tradeoffs

Role and accessible-name locators validate user-facing semantics but depend on the widget exposing a coherent accessibility tree. Test ids create a stable engineering contract for opaque grids, although they do not verify accessibility. Text filters read well but need exact, immutable values. API-assisted setup can place a known record in the dataset quickly, while UI scrolling remains necessary when the scrolling behavior is the risk under test.

Choose the narrowest strategy that preserves the behavior you intend to prove. A data-export test may filter by order id and inspect one row. A virtual-scroll regression test should exercise movement, loading, focus, and row replacement across several viewport boundaries.

Operational Checklist

  • Identify the row or listitem role from the rendered accessibility tree.
  • Name the actual scroll container instead of assuming the document scrolls.
  • Filter by an immutable cell or explicit record test id.
  • Assert one row before selecting controls within it.
  • Move the viewport with a bounded end condition.
  • Wait on loading, page, or result signals rather than elapsed time.
  • Assert total records through dataset metadata, not mounted node count.
  • Re-read values after sorting, filtering, or pagination.
  • Preserve traces and a screenshot when a seek operation times out.

Conclusion: Test the Window and the Record

Treat a virtualized table as two contracts: the dataset contract describes which records exist, while the viewport contract describes which rows are mounted and actionable now. Build a live row locator around a unique key, move the window through an observable workflow, and assert identity and state inside the same row. That approach remains stable even when the component recycles DOM nodes aggressively.

// LIVE COURSE / THE TESTING ACADEMY

Playwright Automation Mastery

Go beyond Selenium. Master Playwright with JS/TS in 90 days.

From the instructor behind this guide.

Playwright jobs are growing 8x faster than Selenium. 90 days / 75+ live hrs / Tue-Thu-Sat 7 AM IST.

Code PROMODE / 10% offJoin the batch

The Testing Academy editorial desk

Practical QA guidance built around test evidence, production tradeoffs, and interview-ready explanations.

Published July 11, 2026 / Reviewed July 11, 2026

PRIMARY REFERENCES

Verify the details at the source

QABattle guides are practical explanations. Product behavior, standards, and APIs can change, so use these primary references for the canonical details.

  1. 01
    Playwright documentation

    Microsoft

    Canonical API, locator, fixture, browser, and test-runner behavior.

  2. 02
    Playwright best practices

    Microsoft

    Official guidance for resilient tests, isolation, and user-facing locators.

  3. 03
    WebDriver standard

    W3C

    The browser automation protocol specification used by major automation stacks.

FAQ / QUICK ANSWERS

Questions testers ask

Why does Playwright count only some rows in a virtualized table?

A virtualized table usually mounts only the rows near the viewport. Locator count therefore describes the rendered DOM, not the complete dataset, so assert the product's total-count signal separately.

How should I locate a table row by text in Playwright?

Start from the row collection and filter it by a unique cell, preferably an immutable business key. Confirm the filtered locator has one match before acting inside that row.

Can I use nth() for repeating rows?

Use nth() only when position itself is the requirement, such as the first visible row after sorting. It is fragile as an identity selector because filtering, sorting, and virtualization can change which record occupies that position.

Should a test scroll with the mouse wheel or change scrollTop?

Use the interaction users rely on when the scrolling behavior is under test. For a targeted data assertion, moving the documented viewport programmatically can be more deterministic, provided the test still verifies the mounted row and its user-visible state.

How do I detect duplicate dynamic-table matches?

Assert that the filtered row locator has a count of one before clicking a child control. A strictness failure is useful evidence that the chosen key is incomplete or duplicated.