I'm trying to think of the best way to make a utility method that utilizes the page.waitForResponse
from Playwright. See: https://playwright.dev/docs/api/class-page#page-wait-for-response
Essentially, in my Page Objects I want to be able to utilize a custom method where I pass a URL/status/etc into a method and that can return a waitForResponse
promise. So something like this:
waitForNetworkResponse(responseURL: string, status: number, timeout?: 30): Promise<Response> {
return this.page.waitForResponse(
(response: Response) => response.url().includes(responseURL) && response.status() === status,
{ timeout },
);
}
However, this is essentially a static function, right? It seems unnecessary to create a network class so I can pass the page
fixture to be able to access waitForResponse
as I'd rather this be a utility function. But maybe creating a network class is the only way? (Although I hate having to create an instance of the network class to be able to use this).
Is there a better way I can create a utility function that uses Playwright's fixtures without needing to create an entire class to utilize the fixtures?
Here's an overview of some of the approaches you can take to write helper abstractions:
page
as the first parameter. This is a great pattern, but the downside is you have to pass your page
into function calls repeatedly, which gets tiresome. I'd probably avoid using a class, unless you want to have the Utilities.
prefix in front of all your calls to help distinguish them clearly from other functions you might be calling (probably unnecessary).page
pattern above.page
to add your utilities alongside the Playwright API calls. I'd avoid this outright for all the same reasons as extending library function prototypes--it's too confusing to tell who owns which calls, and can clash with future Playwright methods. Keep your code separate from third party libraries.page
passed to the constructor. Don't do this, either--it's sort of the worst of many of the approaches above. Static methods should really be static.base.extend
as shown in this comment to inject it alongside page
cleanly. One downside is that there's a fair amount of Playwright-specific setup syntax to get this working and it doesn't seem to help much with POMs, which is often where you'll want to use helpers.Here's a simplified proof of concept of the page
parameter approach:
utilities.ts:
import {Page, Response} from "@playwright/test";
export function waitForResponse(
page: Page,
url: string,
{status, timeout} = {
status: 200,
timeout: 30_000,
}
): Promise<Response> {
return page.waitForResponse(
(response: Response) =>
response.url().includes(url) &&
response.status() === status,
{timeout}
);
};
// ... more helpers ...
index.test.ts:
import {expect, test} from "@playwright/test"; // ^1.42.1
import {waitForResponse} from "./utilities.ts";
const html = `<!DOCTYPE html><html><body>
<button>click</button>
<script>
document
.querySelector("button")
.addEventListener("click", () =>
fetch("https://jsonplaceholder.typicode.com/users")
);
</script>
</body></html>`;
test("button sends request", async ({page}) => {
const url = "https://jsonplaceholder.typicode.com/users";
await page.setContent(html);
const responseP = waitForResponse(page, url);
await page.getByRole("button").click();
const response = await responseP;
expect(response.status()).toBe(200);
});
Now, here's a simplified proof of concept of the binding approach:
utilities.ts:
import {Page, Response} from "@playwright/test";
export const helpersFor = (page: Page) => ({
waitForResponse(
url: string,
{status, timeout} = {
status: 200,
timeout: 30_000,
}
): Promise<Response> {
return page.waitForResponse(
(response: Response) =>
response.url().includes(url) &&
response.status() === status,
{timeout}
);
},
// ... more helpers ...
});
index.test.ts:
import {expect, test} from "@playwright/test";
import {helpersFor} from "./utilities.ts";
const html = `same as above`;
test("button sends request", async ({page}) => {
const {waitForResponse, /* more helpers */ } = helpersFor(page);
const url = "https://jsonplaceholder.typicode.com/users";
await page.setContent(html);
const responseP = waitForResponse(url); // page already bound!
await page.getByRole("button").click();
const response = await responseP;
expect(response.status()).toBe(200);
});
Typically, you'll be importing this into a POM, and the helpers are usually macros for easing locators in the constructor. Most of the actions taken on locators in POM action methods are straightforward. Actions such as waitForResponse
could be assigned to the instance in the constructor (although then you're back to this.
, which is a bit odd), but at that point the waitForResponse(page, ...)
pattern seems preferable. How to handle these nuances requires a bit more case-specific context.
If you're using the bound-style helpers repeatedly in a test file rather than in a POM, you can use a beforeEach
or beforeAll
block to handle the one-off helpersFor
call:
let waitForResponse;
// ...
test.beforeEach(({page}) => {
({waitForResponse, /* more helpers */} = helpersFor(page));
});
...or switch to the "page
as first parameter" approach which doesn't require binding or initialization.
A downside of the binding approach is that it's unclear which page you've bound the functions to. However, if you're only working with a single page, as is the common case scenario, this is mostly a non-issue. The syntactical savings of having to add helpers.
, page.
or use page
as the first argument to all of your helper function calls may be worth it.
Your code also has a bug: timeout?: 30
should be timeout?: 30_000
. All timeouts are in milliseconds, not seconds. Even if you * 1000
inside your helper, I don't suggest switching to seconds because it's nonstandard.