Search code examples
playwrightplaywright-test

Playwright: how to create a static utility method?


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?


Solution

  • Here's an overview of some of the approaches you can take to write helper abstractions:

    1. Simply inline the function wherever you need it--don't write a utility. Use this if the abstraction is a very thin wrapper and doesn't offer much syntactical simplification, as is arguably the case here. Kent Dodds suggests following the "AHA method"--avoiding hasty abstractions. It's OK for tests and POMs to be a little bit WET. If they get too WET, then the cut points will be clearer and the abstractions will be more purposeful than if you break them out too soon.
    2. Create a module (or multiple modules grouped by utility category) that has a bunch of loose functions (or static methods in a class you don't plan to instantiate) that accept 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).
    3. Create a closure that lets you bind a page to a collection of functions that you can destructure. This is a great pattern that solves the main problem with the explicit page pattern above.
    4. Create a base POM class (or multiple) that has the utility functions inlined. Then, any POM can extend this POM and have access to its utilities. This is DRY, but I prefer composition over inheritance.
    5. Copy the helper into the POM, probably as a private internal function. This is only appropriate if the helper really is used only in one (or maybe two) POMs, which doesn't appear to be the case here.
    6. Proxy 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.
    7. Instantiate a utility class with 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.
    8. If you're using the helper in a test case rather than a POM, you can use 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.