- 0
- 1,742 word
Every QA team knows the feeling. A test passes on your laptop, fails in CI, then passes again when you hit rerun. Nothing in the app changed. The test was just flaky.
Flaky tests are more than an annoyance. They slow down releases, and they teach the team to ignore red builds, which is exactly when real bugs slip through. Most flakiness comes from two sources: timing (the test acts before the page is ready) and fragile selectors (the test finds elements by details that change).
Playwright was designed to fix both. In this guide, we’ll look at what makes Playwright testing reliable by default, how to choose and write Playwright locators that survive UI changes, and the practices that keep a growing suite fast, stable, and easy to maintain.
Why Playwright testing is built for reliability
Playwright is an open-source framework from Microsoft for automating Chromium, Firefox and WebKit through one API. It supports TypeScript, JavaScript, Python, Java and .NET. Several design choices make Playwright testing more stable than older approaches:
- Auto-waiting. Before clicking, typing or checking, Playwright waits until the element is attached, visible, stable (not animating), enabled and able to receive events. You don’t need manual sleeps.
- Web-first assertions. Assertions like expect(locator).toBeVisible() retry automatically until the condition is met or the timeout runs out, so tests don’t fail just because the UI was a moment slow.
- Test isolation. Each test gets a fresh browser context, like a brand-new browser profile. Cookies, storage and sessions never leak between tests.
- Strict locators. If a locator matches more than one element when you act on it, Playwright throws an error instead of guessing. Ambiguity gets caught early, not in production.
- Cross-browser by default. The same test runs on Chromium, Firefox and WebKit (the engine behind Safari), so browser-specific bugs surface in CI.
The framework handles a lot, but it can’t save a test built on a bad selector. That’s where Playwright locators come in.
Playwright locators: choose them in the right order
A locator is how a test finds an element on the page. Playwright locators are lazy: they’re resolved fresh each time you use them, so they keep working even when the page re-renders.
The golden rule is to locate elements the way a user sees them, not by how the DOM happens to be built. Playwright’s built-in locators make this easy. Use them in roughly this order of preference:
| Priority | Locator | Best for | Example |
| 1 | getByRole() | Buttons, links, headings, checkboxes, most interactive elements | page.getByRole(‘button’, { name: ‘Sign in’ }) |
| 2 | getByLabel() | Form fields with a label | page.getByLabel(‘Email address’) |
| 3 | getByPlaceholder() | Inputs with no label but a placeholder | page.getByPlaceholder(‘Search products’) |
| 4 | getByText() | Non-interactive text such as messages | page.getByText(‘Order confirmed’) |
| 5 | getByAltText() / getByTitle() | Images and elements with a title | page.getByAltText(‘Company logo’) |
| 6 | getByTestId() | Elements with no stable user-facing text | page.getByTestId(‘cart-total’) |
| Last resort | locator() with CSS or XPath | Legacy markup with no better option | page.locator(‘.checkout-btn’) |
getByRole() sits at the top because it matches how users and assistive technology perceive the page. A side benefit: if a role locator can’t find your button, there’s a good chance screen readers can’t either. Your tests double as a light accessibility check.
Here’s what that looks like in a real test:
import { test, expect } from ‘@playwright/test’;
test(‘user can sign in’, async ({ page }) => {
await page.goto(‘/login’);
await page.getByLabel(‘Email address’).fill(‘user@example.com’);
await page.getByLabel(‘Password’).fill(‘s3cret-pass’);
await page.getByRole(‘button’, { name: ‘Sign in’ }).click();
await expect(page.getByRole(‘heading’, { name: ‘Dashboard’ })).toBeVisible();
});
There’s no CSS class, no XPath and no wait in that test. It reads like a description of what the user does, and it keeps passing even if a designer renames every class on the page.
Playwright locator best practices
Choosing the right locator type is half the job. These practices handle the rest.
Chain and filter to narrow the scope
Real pages repeat elements: many “Add to cart” buttons, many table rows. Instead of writing a long, brittle selector, start broad and narrow down:
const product = page.getByRole(‘listitem’).filter({ hasText: ‘Wireless Mouse’ });
await product.getByRole(‘button’, { name: ‘Add to cart’ }).click();
This finds the list item for one product, then the button inside it. It reads clearly and survives layout changes.
Use test IDs as an agreed contract
When an element has no stable text or role, such as a dynamic total or an icon-only control, add a dedicated attribute like data-testid=”cart-total”. Treat it as a contract between developers and QA: it exists only for testing, so no one changes it by accident during a redesign. If your app uses a different attribute, set testIdAttribute in playwright.config.ts.
Avoid these common mistakes
- Long CSS or XPath chains like div > div:nth-child(3) > span. One layout change breaks them.
- Auto-generated class names from CSS-in-JS tools, such as .css-1x2y3z. They change between builds.
- Using .first() or .nth() to silence strictness errors. If a locator matches several elements, make it more specific instead. Position-based picks break as soon as the order changes.
- Matching on text that changes, like prices, dates or counts. Use a regular expression or a test ID instead.
- Hard-coded waits like waitForTimeout(3000). They make tests slower and still flaky. Let auto-waiting and assertions do the work.
Let Playwright suggest locators
Run npx playwright codegen <your-url> and click around your app. Playwright records your actions and generates code using the most resilient locator it can find, which is usually a role or label locator. It’s a fast way to learn good habits and to bootstrap new tests.
Write assertions that don’t flake
Good locators find the right element. Good assertions check it at the right moment. The key is to always use Playwright’s web-first assertions, which wait and retry, rather than reading a value once and comparing it.
// Flaky: reads the text once, right now
expect(await page.getByTestId(‘status’).textContent()).toBe(‘Paid’);
// Reliable: retries until the text matches or the timeout ends
await expect(page.getByTestId(‘status’)).toHaveText(‘Paid’);
The first version fails if the status updates 200 ms later. The second simply waits for it. Useful web-first assertions include toBeVisible(), toHaveText(), toHaveValue(), toBeEnabled(), toHaveCount() and toHaveURL().
A few more tips:
- Assert on what the user sees, such as a confirmation message or a new URL, rather than internal state.
- Use expect.soft() when you want to check several things on one page and see every failure in one run, not just the first.
- Use expect.poll() for values that don’t come from the page, such as polling an API until a background job finishes.
Structure a suite that stays maintainable
A handful of tests is easy to manage. A few hundred needs structure. These patterns keep Playwright testing manageable as the suite grows.
Keep locators in page objects
The Page Object Model puts each page’s Playwright locators and actions in one class. When the UI changes, you update one file instead of fifty tests.
export class LoginPage {
constructor(private page: Page) {}
emailInput = () => this.page.getByLabel(‘Email address’);
passwordInput = () => this.page.getByLabel(‘Password’);
signInButton = () => this.page.getByRole(‘button’, { name: ‘Sign in’ });
async login(email: string, password: string) {
await this.emailInput().fill(email);
await this.passwordInput().fill(password);
await this.signInButton().click();
}
}
Use fixtures for setup
Playwright’s fixtures give each test exactly what it needs, such as a logged-in page or a seeded test user, and clean up afterwards. They’re more flexible than beforeEach hooks and keep setup code out of the test body.
Log in once, reuse the session
Logging in through the UI before every test is slow and adds failure points. Instead, log in once in a setup project, save the session with storageState, and load it in every test that needs an authenticated user.
Keep tests independent
Each test should create its own data and never rely on another test running first. Independent tests can run in any order and in parallel, which is what makes a large suite fast. Where you can, create test data through an API call instead of clicking through the UI.
Debug fast and scale in CI
Debugging tools
- UI Mode (npx playwright test –ui) lets you run tests, step through each action and watch the browser in a time-travel view.
- Trace Viewer records screenshots, DOM snapshots, network calls and console logs for every step. Set trace: ‘on-first-retry’ in your config, and every CI failure comes with a full trace you can open locally.
- The Playwright Inspector (npx playwright test –debug) pauses execution and lets you try out Playwright locators live against the page.
Running at scale
- Run in parallel. Playwright runs test files in parallel workers by default. Set fullyParallel: true to parallelize tests within a file too.
- Shard across machines. Split a large suite with –shard=1/4, –shard=2/4 and so on, then merge the reports.
- Retry carefully. Use retries: 2 in CI to absorb rare network blips, but track tests that pass only on retry. Playwright marks them as “flaky” in the report. Fix them; don’t hide them.
- Test on real environments. Headless browsers in CI catch most issues, but real users bring real devices, networks and locations. Running your Playwright suite on real browsers and devices, for example through a cloud platform like HeadSpin, helps surface issues that only appear in real-world conditions.
A quick reliability checklist
- Locators use getByRole(), getByLabel() or other user-facing methods first
- Test IDs are used for elements with no stable text or role
- No long CSS or XPath chains, and no auto-generated class names
- No waitForTimeout() anywhere in the suite
- All checks use web-first assertions like toHaveText() and toBeVisible()
- Locators live in page objects, not scattered across tests
- Login happens once and is reused with storageState
- Every test is independent and can run in parallel
- Traces are captured on failure in CI
- Flaky tests are tracked and fixed, not just retried
Conclusion
Reliable web automation isn’t about writing more tests. It’s about writing tests that only fail when something is broken. Playwright testing gives you a strong head start with auto-waiting, isolated contexts and retrying assertions.
The rest is up to how you write your tests. Pick Playwright locators that match what users see, keep them in one place, assert with web-first checks and treat every flaky result as a bug. Do that, and your suite becomes something the whole team trusts: a green build that actually means ready to ship.
