playwright end to end testing testing best practices

Playwright Tips and Tricks

(Updated: May 23, 2025)
Playwright Tips and Tricks

Playwright Tips and Tricks

Playwright is one of the most powerful tools for end-to-end testing, it’s currently taking over the scene with its speed and reliability. However, writing tests that are both fast and reliable can be tricky. And if not done well, playwright tests become more of a technical debt and burden more than a tool to verify product quality.

I’ve compiled some of my most used tricks in the past two years of using Playwright, and I hope they help you write better tests.


⌛ Avoid waitForLoadState('networkidle') – You Definitely Waited Too Much

You might be tempted to use this when loading your test page because it guarantees that the page and all API calls are done loading. networkidle waits for a moment when there are no network requests, which might never come for apps that continuously poll or load analytics. Playwright docs strongly discourage this.

Use more specific criteria, like waitForSelector on a key UI element, to reduce flakiness and improve speed.


✅ Don’t use waitForTimeout – You’ll Never Wait Just Right

Sometimes you’re waiting for an animation to finish or interaction to complete. You might think waitForTimeout is the answer, but it’s not. Your tests will run on different machines and sometimes in CI environments, with different processing powers and different network speeds.

And while timeouts might seem like a good idea locally, when run by different environments, you’ll start seeing flaky tests. Playwright docs also discourage this.

Prefer built-in waiting mechanisms that wait for conditions like visibility or element stability. For example, use waitForSelector or waitForFunction to wait for specific conditions instead of arbitrary timeouts.


🔎 Use Playwright’s Built-in Assertions - The biggest Flaky Test Killer (edited)

While in the past I used to do things like

const text = await page.getByRole('button').textContent();
expect(text).toBe('Submit');

This is very prone to flakiness if the text is not there yet, since there is no default retrial and waiting mechanism.

Playwright exposes its own set of assertions that are built on top of the expect library. They wait for the condition to be true before proceeding. For example, you can do:

await expect(page.getByRole('button')).toHaveText('Submit');

Check out the entire list of Playwright assertions for more details.


⚔️ Use Promise.all([...]) for Racing Conditions

Sometimes you’re triggering one action and then waiting for another to finish. For example, you might be clicking a button and then waiting for a navigation event. If you do this sequentially, you might run into a race condition where the navigation happens before the click event is fully processed.

Other times, you might be waiting for a network request to finish before clicking a button. This can lead to flaky tests if the network request takes longer than expected.

Use Promise.all([...]) to handle these situations. For example, if you’re clicking a button and waiting for a navigation event, do it like this:

await Promise.all([
    page.waitForNavigation(),
    page.click('text=Submit'),
]);

This ensures that both actions are handled concurrently, reducing the chance for a wait that never fully completes.


🧑‍🦯 Prefer Human-Readable and Accessible Selectors

Selectors like [data-testid="submit-button"] or role=button[name="Submit"] make tests more robust and readable. Avoid brittle CSS chains like .button-wrapper > button:nth-child(2) which easily break with UI changes. Leverage Playwright’s accessibility selectors whenever possible.


🧪 Shard and Run in Parallel — Then Merge Reports

Speed up test suites by sharding tests across CI nodes. Use Playwright’s built-in support to run in parallel. Playwright also supports outputting reports in blob format that you can later in a next step merge using playwright merge-report.

This is especially useful for large test suites or when you have slow tests. Giving an overall better DevXperience.


🐢 Use test.slow() for Intentionally Slower Tests

If a test interacts with large datasets, animations, or intentionally slow workflows, annotate it with test.slow() to adjust timeouts and avoid false negatives in CI. This also documents why a test takes longer.


🚫 Use test.skip() for Platform/Env Specific Tests

When a test doesn’t apply to a certain browser, OS, or environment, use test.skip() or test.skip(condition, reason). This keeps the test suite green and understandable without removing valuable tests:

test.skip(({ browserName, isMobile }) => {
    return browserName === 'firefox' && isMobile;
}, 'Firefox mobile is not supported');

🔀 Integrate msw (Mock Service Worker) — But Only for Unstable APIs

While end-to-end tests usually require using real APIs sometimes you run into situations where you need a certain state in the APIs that isn’t achievable easily. For instance when you want to mock an infinite loading state or an API failure to test how your app behaves in those situations.

In those cases, you can use msw to mock the API responses. I found this library playwright-msw to work really well in integrating Playwright with msw


Throughout my last two years of using Playwright, I’ve seen times when I had to debug flaky tests for hours, and times when I was able to write tests that were fast and reliable. The difference was always in the patterns I used.

By following these best practices, you can write tests that are not only fast and reliable but also easy to maintain and read. Hope this was helpful, and happy testing!