The Illustrated Guide to Using Promise.all in Playwright Tests

The Illustrated Guide to Using Promise.all in Playwright Tests

In this article, I will go over the way Playwright communicates between the browser automation runner and the browser, and explain how to correctly use Promise.all to avoid tests’ flakiness.

Playwright Communication

Playwright is running in two separate contexts:
- The Nodejs context is running the tests and sending the commands to the browser
- The Browser context is getting the commands, executing them together, and running your application code.

These two contexts run in parallel and are not synchronized in their actions. You cannot tell if something will happen in the node context before or after the browser has completed an action.

Browser and NodeJS are not synchronized in their actions

The two contexts are communicating over an asynchronous mechanism. NodeJS is sending requests to the browser and receives a response.

Playwright browser and node communication

You can imagine it like a Whatsapp conversation between two people, each one doing their own things during this chat:

(Hint: to see the real communication, run Playwright test with DEBUG=pw:api)

Waiting for an Activity

Assume you have a button that clicking on it will trigger a server request. You want to catch that request to verify it contains proper information.

Attempt #1

Initially, you may write a fairly straightforward code:

await page.locator('button').click();
await page.waitForRequest('example.com/resource');

This code will go anywhere from not working to flaky. Let’s look at the timelines:

First — the NodeJS will send a request to the browser to click the button. Once the button is clicked and the NodeJS receives the response, it will send the browser an instruction to check if the request is sent. But since we do not know how long it takes the NodeJS to send this request, it can hit the browser at any time during the purple rectangle. Now if the instruction hits the browser before the request is sent, marked as A zone, we are lucky, and the browser will catch it. But we can also hit the browser during the section marked as B, and then it is too late. Our request has already left the station and we are going to miss it.

It is like telling your kid to open the door, and then sending them a message to make sure the dog does not run away. Well, by the time they read the second message, the dog is probably already chasing all of the neighborhood’s cats.

Attempt #2

Ok, so let’s send the request to check the network request before hitting the button.

await page.waitForRequest('example.com/resource');
await page.locator('button').click();

Well, this is not going to work at all. Our browser will wait for the request to fire, but as our button was not clicked, it will not fire and the instruction will timeout with an error.

Attempt #3

The solution is to send both requests in parallel, using promise.all:

await Promise.all([
page.waitForRequest('example.com/resource'),
page.locator('button').click()
])

What happens here?

NodeJS instructs the browser to wait for the request, and immediately, before the request is resolved, it sends the browser the instruction to click on the button. We sent the waitForRequest before we sent the click, so the browser is already waiting for the request to fire.

Once the request is fired, the browser is already on the watch for it and intercepts it. The test is now stable.

Short-Lived Element

The other example we will look into is a short-lived element. Think of a loader that appears on the page after a button is clicked and the requests are sent and disappear when everything is ready. The browser of the timeline looks as follows:

We want to send 3 instructions to the browser:

  • Click the button
  • Make sure that the loader appears
  • Make sure that the loader disappears.

The “problematic” zone here is B. We cannot be sure that we will hit it to check that the loader appears before the loader disappears. Therefore, we need to send a request to check if the loader appears in parallel and before the loader appears.

Our code will then send:

await Promise.all([
page.locator('#loader').waitFor(),
page.locator('button').click()
];

This will make sure that we wait for the loader to appear in zone A above. Then, we just need to wait for it to disappear. We can do it after we get the response that the loader appeared:

await page.locator('#loader').waitFor({state: 'detached'};

And this is what the all flow looks like:

Our loader gone may hit the browser before or after the loader has disappeared. If the request hits in zone C it will wait until the loader disappears. If it will hit in zone D, it will resolve immediately.

Conclusion

Pkaywright is working in a really fast pace, and we cannot assume how the NodeJS and the Browser are synchronized. Promise.all( ) is extremely powerful in making your tests robust and reduce the flakiness.