Search code examples
playwright

How can I expect two things when I don't know which will happen first?


I'm trying to write a Playwright test that does something after a form is submitted. After that form is submitted, two things happen:

  1. a "drawer" closes
  2. a "notification" pops up

The problem is, the order of those two things can change, just due to randomness. So whether I do:

await expectDrawerToClose();
await expectNotification();

or:

await expectNotification();
await expectDrawerToClose();

... either way it's going to be wrong sometimes. Is there any way in Playwright to somehow do:

await expectTwoThingsToHappenInAnyOrder(
    expectDrawerToClose(),
    expectNotification()
);

I know that I can chain locators together with or, but that isn't the same thing: I don't want to say "I'll see one of these two things happen" ... I want to say "I'll see both happen; I just don't care about the order."


Solution

  • There's no need, most of the time. The sequential assertions just work, as long as the behavior isn't performing a complete block on the page (as would be the case with an alert(), which prevents rendering) or if one state may disappear while Playwright is still waiting for the other condition.

    Consider the following example:

    import {expect, test} from "@playwright/test"; // ^1.39.0
    
    const html = `<!DOCTYPE html>
    <html>
    <body>
    <button>click me</button>
    <div class="drawer closed">closed</div>
    <script>
    const openDrawer = () => {
      const drawer = document.querySelector(".drawer");
      drawer.classList.remove("closed");
      drawer.classList.add("open");
      drawer.textContent = "open";
    };
    
    const showNotification = () => {
      const el = document.createElement("div");
      el.textContent = "notification!";
      document.body.append(el);
    };
    
    document.querySelector("button")
      .addEventListener("click", e => {
        setTimeout(openDrawer, Math.random() * 2000);
        setTimeout(showNotification, Math.random() * 2000);
      });
    </script>
    </body>
    </html>`;
    
    test("button triggers drawer and notification", async ({page}) => {
      await page.setContent(html);
      await page.getByRole("button", {name: "click me"}).click();
      await expect(page.getByText("notification!")).toBeVisible();
      await expect(page.locator(".open")).toBeVisible();
    });
    

    Although the two events happen in arbitrary order in the 2 seconds following the click, this test is guaranteed to pass. The order of the assertions doesn't matter.

    Based on one of your comments, you note that the notification may be dismissed after a short duration. In that case, order matters. Assert the temporary state before the longer/permanent state:

    import {expect, test} from "@playwright/test";
    
    const html = `<!DOCTYPE html>
    <html>
    <body>
    <button>click me</button>
    <div class="drawer closed">closed</div>
    <script>
    const openDrawer = () => {
      const drawer = document.querySelector(".drawer");
      drawer.classList.remove("closed");
      drawer.classList.add("open");
      drawer.textContent = "open";
    };
    
    const showNotification = () => {
      const el = document.createElement("div");
      el.textContent = "notification!";
      document.body.append(el);
      setTimeout(() => el.remove(), 1000);
    };
    
    document.querySelector("button")
      .addEventListener("click", e => {
        setTimeout(openDrawer, Math.random() * 2000);
        setTimeout(showNotification, Math.random() * 2000);
      });
    </script>
    </body>
    </html>`;
    
    test("button triggers drawer and notification", async ({page}) => {
      await page.setContent(html);
      await page.getByRole("button", {name: "click me"}).click();
    
      // assert temporary state first
      await expect(page.getByText("notification!")).toBeVisible();
      await expect(page.locator(".open")).toBeVisible();
    });
    

    If both states disappear nondeterministically, and need to be asserted truly concurrently, then you can use Promise.all:

    import {expect, test} from "@playwright/test";
    
    const html = `<!DOCTYPE html>
    <html>
    <body>
    <button>click me</button>
    <div class="drawer closed">closed</div>
    <script>
    const openDrawer = () => {
      const drawer = document.querySelector(".drawer");
      drawer.classList.remove("closed");
      drawer.classList.add("open");
      drawer.textContent = "open";
      setTimeout(
        () => {
          el.classList.remove("open");
          el.textContent = "closed";
        },
        Math.random() * 1000 + 500
      );
    };
    
    const showNotification = () => {
      const el = document.createElement("div");
      el.textContent = "notification!";
      document.body.append(el);
      setTimeout(
        () => el.remove(),
        Math.random() * 1000 + 500
      );
    };
    
    document.querySelector("button")
      .addEventListener("click", e => {
        setTimeout(openDrawer, Math.random() * 2000);
        setTimeout(showNotification, Math.random() * 2000);
      });
    </script>
    </body>
    </html>`;
    
    test("button triggers drawer and notification", async ({page}) => {
      await page.setContent(html);
      await page.getByRole("button", {name: "click me"}).click();
      await Promise.all([
        expect(page.getByText("notification!")).toBeVisible(),
        expect(page.locator(".open")).toBeVisible(),
      ]);
    });
    

    If you find you're using the Promise.all() pattern often in your tests, then either you have an unusually dynamic and remarkable website, or you're overusing it (probably the latter). Prefer properly-ordered straight-line assertions like snippet 2 above, unless there's truly no other option.

    If you want to take the first of multiple states and ignore the rest, use Promise.race instead of Promise.all. See this post for an example. Use judiciously.

    if you're not using @playwright/test, you can remove the expects and use .waitFor() instead of .toBeVisible().