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.
In this guide10 sections
- Define the Visible Row Contract
- Build a Semantic Row Locator
- Filter by Identity Before State
- Search Beyond the Mounted Window
- Separate Dataset Assertions from DOM Assertions
- Handle Pagination and Sorting Deliberately
- Diagnose Common Failures
- Weigh the Tradeoffs
- Operational Checklist
- 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.
01 / visible contract
Visible Row Contract
Identify the mounted row role, viewport, and loading signal.
02 / live row locator
Live Row Locator
Resolve rows again whenever the virtualized DOM changes.
03 / semantic filter
Semantic Filter
Narrow by an exact, immutable cell value or explicit test id.
04 / move window
Scroll or Paginate
Move the data window until the target record is mounted.
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.
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.
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.
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.
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.
- 01Playwright documentation
Microsoft
Canonical API, locator, fixture, browser, and test-runner behavior.
- 02Playwright best practices
Microsoft
Official guidance for resilient tests, isolation, and user-facing locators.
- 03
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.
RELATED GUIDES
Continue the learning route
GUIDE 01
Playwright Locators Guide: Find Elements Reliably
Playwright locators guide for stable UI tests with role selectors, filters, assertions, strict mode, debugging, and flaky selector fixes in CI.
GUIDE 02
Playwright Tutorial: End-to-End Testing from Scratch
Playwright tutorial for beginners: install, write your first test, TypeScript setup, codegen, fixtures, API testing, debugging, and CI tips in one guide.
GUIDE 03
Retrying Async State with expect.poll and expect.toPass in Playwright
Use Playwright expect.poll and expect.toPass for bounded asynchronous checks, with intentional intervals, idempotent probes, and useful failures.
GUIDE 04
Debug Playwright Strict Mode Violations in Repeating and Dynamic UI
Trace Playwright strict mode violations to duplicate UI states, inventory every match, refine semantic locators, and lock uniqueness with regression tests.