Lock playwright locator to a specific (existing) dom element

2 days ago 1
ARTICLE AD BOX

This seems like a possible xy problem. The core philosophy of Playwright is to think and test in terms of user-visible behavior.

Automated tests should verify that the application code works for the end users, and avoid relying on implementation details such as things which users will not typically use, see, or even know about such as the name of a function, whether something is an array, or the CSS class of some element. The end user will see or interact with what is rendered on the page, so your test should typically only see/interact with the same rendered output.

Concepts like "which DOM instance of a load button am I clicking?" is a white-box testing mentality based on programmer-centric implementation details. A test should not break if in some future version of the application, the button is persisted across clicks. The user likely sees it as the same button after each click.

A better approach, which is almost certainly possible here, is to predicate on the user-visible side effect of the button click. If "Load more" triggers more items to appear in a list, or causes a spinner to appear, wait for that instead of a specific node detaching.

Consider this example page:

const addLoadMoreButton = () => { document.body.innerHTML += "<button>Load more</button>"; const button = document.querySelector("button"); button.addEventListener("click", () => { button.remove(); setTimeout(() => { // simulate a network request const ul = document.querySelector("ul"); ul.innerHTML += "<li>c</li><li>d</li>"; if (document.querySelectorAll("li").length < 7) { addLoadMoreButton(); } }, 1000) }); }; addLoadMoreButton(); <ul><li>a</li><li>b</li></ul>

We can test this with the following length check:

import {expect, test} from "@playwright/test"; // ^1.46.1 const html = ` <ul><li>a</li><li>b</li></ul><script> const addLoadMoreButton = () => { document.body.innerHTML += "<button>Load more</button>"; const button = document.querySelector("button"); button.addEventListener("click", () => { button.remove(); setTimeout(() => { // simulate a network request const ul = document.querySelector("ul"); ul.innerHTML += "<li>c</li><li>d</li>"; if (document.querySelectorAll("li").length < 7) { addLoadMoreButton(); } }, 1000) }); }; addLoadMoreButton();</script>`; test("Loads all pages of results", async ({page}) => { const pageSize = 2; await page.setContent(html); while (await page.getByRole("button").isVisible()) { const itemCount = await page.getByRole("listitem").count(); await page.getByRole("button").click(); await expect(page.getByRole("listitem")).toHaveCount(itemCount + pageSize); } });

Or if you don't know thew page size, replace the above expect with either:

await expect(page.getByRole("listitem")).not.toHaveCount(itemCount);

or

await page.waitForFunction(`document.querySelectorAll("li").length > ${itemCount}`);

The bottom approach is more precise, since .not.toHaveCount(itemCount); could happen if the number of items in a list were reducing due to a bug. But realistically this is unlikely, and a final assertion after the loop can confirm the correct number of items.

It's very seldom that a button click does absolutely nothing visible to the user. An action that triggers absolutely no user-visible effect is nearly always either a bug or poor UX that needs to be improved (e.g. disable "Load more" if a request is in flight, and remove it when all pages are loaded, as your site appears to do).

I should also mention that await this.page.waitForLoadState('networkidle'); is an imprecise predicate tending to flakiness. On complex pages, multiple requests could be in flight. Or maybe there's a bug and the request was never even sent, so the network is erroneously idle and we pass the test when we shouldn't! waitForResponse covers both cases, a positive assertion rather than a negative "absence of network requests" assertion.

Read Entire Article