将Playwright的定位器绑定到一个已存在的DOM元素
在Playwright(1.58.2)中,我有以下代码:
while (await this.loadMoreButton.isVisible()) {
const detachEvent = this.loadMoreButton.waitFor({ state: 'detached' }); // bind to button we click, not the next one
await this.loadMoreButton.click();
await detachEvent;
await this.page.waitForLoadState('networkidle');
}
这并不稳定。
这是一个不完整的列表,我可以通过点击一个按钮来加载更多元素(page.getByRole('button', { name: "Load more entries"}))。在任意时刻,存在一个loadMoreButtonA。我点击它。然后等待它消失。接着我等待网络空闲状态,并检查是否还有另一个loadMoreButtonB(与同一选择器匹配!)。
如果我不等待,可能会发生我多次点击loadMoreButtonA。
如果我等待,可能会发生loadMoreButtonB已经存在,而waitFor(detached) 这一等待永远不会返回。
为了让我的测试稳定,我需要一种方法将第2 行的定位器固定到loadMoreButtonA的具体DOM元素,这样即使已经存在一个loadMoreButtonB,也能返回。不幸的是,waitFor() 会在同一次执行中反复重新解析定位器,可能会在同一次执行过程中将定位从loadMoreButtonA切换到loadMoreButtonB。
解决方案
这似乎是一个可能的 xy problem。Playwright的核心理念是以“用户可见的行为”为测试与思考的单位。
自动化测试应该验证应用代码对最终用户有效,并避免依赖实现细节,例如用户通常不使用、看到,甚至不知道的事情,如函数名、某物是否是数组,或某个元素的CSS类名等。最终用户将看到或交互的是页面上呈现的内容,因此你的测试通常只应看到/与相同的呈现输出进行交互。
诸如“我到底点的是哪一个DOM实例的加载按钮?”这样的概念,是基于程序员中心实现细节的白盒测试思维。测试不应因为在应用的未来版本中按钮在多次点击后仍然存在而崩溃。用户很可能把它视为每次点击后的同一个按钮。
一个更好的方法,几乎可以肯定在这里可行,是以上按钮点击引发的用户可见副作用为前提。如果“加载更多”会让列表中出现更多项,或出现一个旋转加载图标,请等待那些副作用,而不是等待某个特定节点的分离。
请看这个示例页面:
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>
我们可以用以下长度检查来测试这个:
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);
}
});
或者如果你不知道页面的大小,可以把上述 expect 替换为任意以下两种之一:
await expect(page.getByRole("listitem")).not.toHaveCount(itemCount);
或
await page.waitForFunction(`document.querySelectorAll("li").length > ${itemCount}`);
后者的方法更精确,因为 .not.toHaveCount(itemCount); 可能在列表项数量因某个bug而减少时发生。但现实情况这不太可能发生,循环结束后的最终断言可以确认正确的项数。
按钮点击很少会完全对用户不可见地无效。触发完全没有用户可见效果的行为几乎总是一个bug或需要改进的用户体验(例如:在请求进行中时禁用“加载更多”,并在所有页面加载完成后再移除它,正如你的网站所显示的那样)。
另外要提的是,await this.page.waitForLoadState('networkidle'); 是一个不精确的谓词,容易导致易出错。在复杂页面上,可能有多条请求同时进行。也有可能存在一个bug,请求根本没有发送,因此网络处于错误的空闲状态,我们在不该通过测试时却通过了测试!waitForResponse 同时覆盖这两种情况,给出一个正向断言,而不是一个否定的“没有网络请求”的断言。