โšก๏ธ Stop Writing Flaky Tests: Your Foundational Guide to Async in Playwright

Ivan DavidovIvan Davidov
7 min read

๐Ÿค– In our last article, we built a scalable Page Object Model, giving our tests a solid architectural blueprint. We organized our locators and actions into clean, reusable classes. But even the best blueprint can't prevent a house from collapsing if the foundation is shaky.

In test automation, that shaky foundation is often a misunderstanding of asynchronicity.

This article tackles that head-on. We'll explore why Playwright is inherently asynchronous and how to manage it. You will learn the three essential pillars of async mastery:

  • async/await: The fundamental syntax for controlling the flow of your tests.

  • Promise.all: The secret to speeding up test execution by running operations in parallel.

  • try/catch: The safety net for building robust tests that don't crash unexpectedly.

Mastering these concepts is the difference between tests that are a flaky liability and an automated suite that is a truly dependable asset.


โœ… Prerequisites

This guide builds upon the concepts from our previous article on the Page Object Model. You should be comfortable with the following:


๐Ÿค” The Problem: Why Are Web Tests Asynchronous?

Modern web applications are not static. When you click a "Login" button, you don't instantly see the next page. Your browser sends a request to a server, the server processes your credentials, and then sends a response back. This takes time.

This is the core of asynchronicity. You start an action, and you get back a Promiseโ€”a placeholder for a future result.

A Promise is like an order receipt from a coffee shop. You have proof you ordered, but you don't have your coffee yet. It can be in one of three states:

  • pending: You've just ordered. The barista is making your coffee. The operation isn't finished.

  • fulfilled: Success! Your name is called, and you have your coffee. The operation completed, and the Promise returns a value.

  • rejected: Something went wrong. They're out of almond milk. The operation failed, and the Promise returns an error.

Nearly every Playwright command (.click(), .fill(), .locator()) returns a Promise because it interacts with this live, unpredictable web environment. Without telling our code to wait for these promises to resolve, our tests will fail.


๐Ÿ› ๏ธ The Solution, Part 1: async/await for Correctness

The async and await keywords are the fundamental tools for managing Promises.

  • async: You add this to a function declaration (like async ({ page }) => { ... }) to tell JavaScript that this function will contain asynchronous operations.

  • await: You place this before any command that returns a Promise. It tells your code to pause execution at that exact line and wait until the Promise is either fulfilled or rejected before moving to the next line.

The Failing Test (Without await)

Imagine you forget to use await. Your test code would look like this:

// ๐Ÿšจ THIS IS THE "BEFORE" - A FAILING TEST ๐Ÿšจ
test('A Failing test that forgets to await', async ({ page }) => {
    // We tell Playwright to click, but we don't wait for it to finish!
    page.getByRole('button', { name: 'Login' }).click();

    // The script jumps to this line INSTANTLY.
    // The next page hasn't loaded, so this locator doesn't exist yet.
    // ๐Ÿ’ฅ TEST FAILS!
    await expect(page.getByText('idavidov')).toBeVisible();
});

This test fails because the expect command runs immediately after the click command is dispatched, not after the action is completed.

The Robust Fix (With await)

The solution is simple: await every single Playwright action.

// โœ… THIS IS THE "AFTER" - A ROBUST TEST โœ…
test('A robust test that correctly uses await', async ({ page }) => {
    // 1. await pauses the test here until the click is complete
    // and the resulting page navigation has started.
    await page.getByRole('button', { name: 'Login' }).click();

    // 2. The test only proceeds to this line AFTER the click is done.
    //    Playwright's auto-waiting will handle the rest.
    await expect(page.getByText('idavidov')).toBeVisible();
});

Rule of Thumb: If it's a Playwright command, put await in front of it.


๐Ÿš€ The Solution, Part 2: Promise.all for Speed

Waiting is good, but waiting sequentially isn't always smart. What if you need to do two independent async things at once?

For example:

  1. Click a "Publish Article" button (an async UI action).

  2. Start listening for the API response to confirm the publish was successful (an async network action).

The Right Way: Concurrent Operations Promise.all solves this. It takes an array of promises and runs them all at the same time. It creates a new Promise that fulfills only when all the input promises are fulfilled.

// โœ… The "right" way - running in parallel with Promise.all
const [publishActionPromise, responsePromise] = await Promise.all([
    // Operation 1: Start publishing the article
    articlePage.publishArticle(title, description, body, tags),

    // Operation 2: Start listening for the API response SIMULTANEOUSLY
    page.waitForResponse('**/api/articles/'),
]);

// Now you can work with the results after both are complete
const responseBody = await responsePromise.json();

By running these operations concurrently, you save you have the opportunity to perform actions that would be impossible in a sequential test, like catching an API response triggered by a UI action (example).


๐Ÿ›ก๏ธ The Solution, Part 3: try/catch for Resilience

What happens when a failure is acceptable? Sometimes an element might not be present, and that's okay. For example, a promotional popup or a cookie banner might not appear for every user on every visit.

The Brittle Test (Without try/catch)

If you write a test to dismiss a popup, it will crash if the popup isn't there.

// ๐Ÿšจ This test will CRASH if the popup doesn't appear
await page.locator('#promo-popup-close-button').click();

// The rest of the test will never run...
await expect(page.locator('.main-content')).toBeVisible();

The Resilient Fix (try/catch)

A try/catch block lets you attempt a "risky" action and handle the failure gracefully without stopping the test. Check the implementation of try/catch for API request.

// โœ… A resilient test that won't crash
try {
    // We TRY to click the button, but with a short timeout.
    await page.locator('#promo-popup-close-button').click({ timeout: 2000 });
} catch (error) {
    // If the click fails (e.g., timeout), the code jumps here.
    // We can log it and the test continues on its merry way!
    console.log('Promotional popup was not present. Continuing test.');
}

// The test continues, whether the popup was there or not.
await expect(page.locator('.main-content')).toBeVisible();

Throwing Better Errors

Sometimes, you want the test to fail, but with a better error message. You can catch a generic Playwright error and throw a new, more descriptive one.

try {
    await expect(page.locator('#user-welcome-message')).toBeVisible();
} catch (error) {
    // Catch the vague "TimeoutError"
    // And throw a custom error that explains the business impact.
    throw new Error(
        'CRITICAL FAILURE: User welcome message not found after login. Authentication failed.'
    );
}

This makes your test reports infinitely more useful, telling you why the failure matters.


๐Ÿš€ Your Mission: Refactor for Robustness

You now have the complete toolkit for async mastery. Go back to your own tests and look for opportunities to improve them:

  1. Audit for await: Are you awaiting every single action?

  2. Look for Parallel Ops: Can any of your sequential steps be run concurrently with Promise.all to speed things up?

  3. Identify Flaky Points: Could a try/catch block make your test more resilient to optional elements or intermittent failures?

Mastering asynchronicity is non-negotiable for professional test automation. It elevates your tests from a source of frustration to a rock-solid foundation for quality.


๐Ÿ™๐Ÿป Thank you for reading! Building robust, scalable automation frameworks is a journey best taken together. If you found this article helpful, consider joining a growing community of QA professionals ๐Ÿš€ who are passionate about mastering modern testing.

Join the community and get the latest articles and tips by signing up for the newsletter.

0
Subscribe to my newsletter

Read articles from Ivan Davidov directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Ivan Davidov
Ivan Davidov

Automation QA Engineer, ISTQB CTFL, PSM I, helping teams improve the quality of the product they deliver to their customers. โ€ข Led the development of end-to-end (E2E) and API testing frameworks from scratch using Playwright and TypeScript, ensuring robust and scalable test automation solutions. โ€ข Integrated automated tests into CI/CD pipelines to enhance continuous integration and delivery. โ€ข Created comprehensive test strategies and plans to improve test coverage and effectiveness. โ€ข Designed performance testing frameworks using k6 to optimize system scalability and reliability. โ€ข Provided accurate project estimations for QA activities, aiding effective project planning. โ€ข Worked with development and product teams to align testing efforts with business and technical requirements. โ€ข Improved QA processes, tools, and methodologies for increased testing efficiency. โ€ข Domain experience: banking, pharmaceutical and civil engineering. Bringing over 3 year of experience in Software Engineering, 7 years of experience in Civil engineering project management, team leadership and project design, to the table, I champion a disciplined, results-driven approach, boasting a record of more than 35 successful projects.