Playwright Best Practices: A Simple Guide for Testers and Developers

On this page19 sections
Good Playwright tests do three things: they find elements the way a user does, they never use fixed waits, and they never depend on each other. Follow those three rules and most flaky tests disappear.
This guide covers the most useful Playwright best practices in simple words. Each one has a short reason, a bad example and a good example, so you can apply it straight away.
Quick checklist
| # | Best practice | In one line |
|---|---|---|
| 1 | Use user-facing locators | Prefer getByRole(), getByLabel(), getByTestId() over CSS and XPath |
| 2 | Never use fixed waits | No waitForTimeout(); Playwright waits for you |
| 3 | Use web-first assertions | await expect(locator).toBeVisible() retries until it passes |
| 4 | Keep tests independent | Each test sets up its own data and can run alone |
| 5 | Log in once, reuse it | Save login state with storageState |
| 6 | Organise with page objects and fixtures | Keep page details out of your test steps |
| 7 | Set up data through the API | Faster and more stable than clicking through the UI |
| 8 | Mock third-party services | Don't let other people's servers break your tests |
| 9 | Fix flaky tests, don't hide them | Retries in CI only; treat "flaky" as a bug |
| 10 | Run tests in parallel | Use workers and sharding for speed |
| 11 | Debug with the right tools | UI mode locally, traces in CI |
| 12 | Test all browsers, stay updated | Chromium, Firefox, WebKit, latest Playwright |
1. Use user-facing locators
What is a locator? A locator tells Playwright how to find an element on the page, like a button or a text box.
Why it matters: CSS classes and XPath change when developers redesign the page, even if nothing changes for the user. Locators based on what the user sees (a button's name, a field's label) keep working.
Bad:
await page.locator('#root > div.form-wrap > button.btn-primary').click();
await page.locator('//div[2]/form/input[1]').fill('asha@example.com');Good:
await page.getByLabel('Email').fill('asha@example.com');
await page.getByRole('button', { name: 'Sign in' }).click();Which locator to pick, in order:
| Locator | Use it for | Example |
|---|---|---|
getByRole() | Buttons, links, headings, checkboxes | getByRole('button', { name: 'Save' }) |
getByLabel() | Form fields with a label | getByLabel('Password') |
getByPlaceholder() | Fields with only placeholder text | getByPlaceholder('Search') |
getByText() | Plain text on the page | getByText('Order placed') |
getByTestId() | When nothing above works | getByTestId('cart-total') |
locator('css') | Last resort only | locator('.price') |
Narrow down a list instead of using indexes. Positions like "the 3rd item" break when the order changes.
// Bad: depends on position
await page.getByRole('listitem').nth(2).getByRole('button').click();
// Good: depends on content
await page
.getByRole('listitem')
.filter({ hasText: 'Blue T-shirt' })
.getByRole('button', { name: 'Add to cart' })
.click();Tip: not sure which locator to use? Run npx playwright codegen your-site.com, click the element, and Playwright suggests the best locator for you.
2. Never use fixed waits
What is auto-waiting? Before every action (click, fill, check), Playwright automatically waits until the element is visible, enabled and not moving. You don't need to add waits yourself.
Why it matters: a fixed wait like waitForTimeout(5000) is either too long (slow tests) or too short (flaky tests). Usually both, on different days.
Bad:
await page.getByRole('button', { name: 'Submit' }).click();
await page.waitForTimeout(5000); // hope the page loadedGood:
await page.getByRole('button', { name: 'Submit' }).click();
await expect(page.getByText('Form submitted')).toBeVisible(); // waits only as long as neededAlso avoid waitForLoadState('networkidle'). Modern apps keep making network calls in the background, so "network idle" may never happen or may happen too early. Wait for something the user can see instead.
3. Use web-first assertions
What is a web-first assertion? It is an expect() that takes a locator and keeps retrying until the condition is true or the timeout ends (5 seconds by default).
Why it matters: a normal assertion checks only once. If the page is a little slow, it fails even though the app works fine.
Bad:
// Checks once, right now. No retry.
expect(await page.getByText('Welcome').isVisible()).toBe(true);Good:
// Retries until visible, or fails after the timeout.
await expect(page.getByText('Welcome')).toBeVisible();The difference in one table:
| Normal assertion | Web-first assertion | |
|---|---|---|
| Looks like | expect(await el.isVisible()).toBe(true) | await expect(el).toBeVisible() |
| Checks | Once | Again and again until it passes |
| Result on a slow page | Flaky failure | Passes |
Useful web-first assertions: toBeVisible(), toHaveText(), toHaveValue(), toBeChecked(), toHaveURL(), toHaveCount().
Don't forget await. Without it, the assertion may not run before the test ends, and the test can pass when it should fail. Turn on the ESLint rule @typescript-eslint/no-floating-promises to catch this automatically.
Soft assertions: use expect.soft() when you want to check several things and see all failures at once, instead of stopping at the first one.
4. Keep every test independent
What does independent mean? Each test can run alone, in any order, and still pass. It creates its own data and doesn't rely on what another test did.
Why it matters: if test B needs test A to run first, B fails whenever you run it alone, run tests in parallel, or A fails. One broken test then causes a chain of failures that is hard to debug.
Bad:
test('create a product', async ({ page }) => {
// creates "Blue T-shirt"
});
test('edit the product', async ({ page }) => {
// expects "Blue T-shirt" to already exist ← depends on the test above
});Good:
test('edit a product', async ({ page, request }) => {
// create its own product first
await request.post('/api/products', { data: { name: 'Blue T-shirt' } });
await page.goto('/products');
// ...edit it and check the result
});Playwright already helps here: every test gets a fresh browser context, which is like a brand-new incognito window with no cookies or saved data. Don't undo this by sharing one page between tests.
Use beforeEach for common steps. If every test in a file opens the same page, put it in a hook:
test.beforeEach(async ({ page }) => {
await page.goto('/dashboard');
});5. Log in once and reuse it
What is storageState? It is a file where Playwright saves cookies and local storage after login. Other tests load this file and start already logged in.
Why it matters: logging in through the UI in every test is slow and adds a place where every test can fail.
Step 1: a setup file that logs in once
// tests/auth.setup.ts
import { test as setup } from '@playwright/test';
setup('log in', async ({ page }) => {
await page.goto('/login');
await page.getByLabel('Email').fill(process.env.TEST_USER!);
await page.getByLabel('Password').fill(process.env.TEST_PASSWORD!);
await page.getByRole('button', { name: 'Sign in' }).click();
await page.waitForURL('/dashboard');
await page.context().storageState({ path: 'playwright/.auth/user.json' });
});Step 2: tell other tests to use it
// playwright.config.ts
projects: [
{ name: 'setup', testMatch: /.*\.setup\.ts/ },
{
name: 'chromium',
use: { ...devices['Desktop Chrome'], storageState: 'playwright/.auth/user.json' },
dependencies: ['setup'],
},
],Keep secrets safe: read passwords from environment variables, as above, and add playwright/.auth to .gitignore so login files never reach your repository.
6. Organise code with page objects or fixtures
Why it matters: if 50 tests each contain getByLabel('Email'), a label change means 50 edits. Put page details in one place and change them once.
Page Object Model (POM)
Definition: a page object is a class that holds the locators and actions for one page. Tests call simple methods like login() instead of repeating clicks.
// pages/login-page.ts
import { Page } from '@playwright/test';
export class LoginPage {
constructor(private page: Page) {}
async goto() {
await this.page.goto('/login');
}
async login(email: string, password: string) {
await this.page.getByLabel('Email').fill(email);
await this.page.getByLabel('Password').fill(password);
await this.page.getByRole('button', { name: 'Sign in' }).click();
}
}// tests/login.spec.ts
test('shows error for wrong password', async ({ page }) => {
const loginPage = new LoginPage(page);
await loginPage.goto();
await loginPage.login('asha@example.com', 'wrong-password');
await expect(page.getByText('Invalid email or password')).toBeVisible();
});Keep assertions in the test, not in the page object. The page object says how to do things; the test says what should happen.
Fixtures
Definition: a fixture is something Playwright prepares and hands to your test, like page and request. You can create your own, so tests receive a ready-made page object.
// fixtures.ts
import { test as base } from '@playwright/test';
import { LoginPage } from './pages/login-page';
export const test = base.extend<{ loginPage: LoginPage }>({
loginPage: async ({ page }, use) => {
await use(new LoginPage(page));
},
});
export { expect } from '@playwright/test';// tests/login.spec.ts
import { test, expect } from '../fixtures';
test('shows error for wrong password', async ({ page, loginPage }) => {
await loginPage.goto();
await loginPage.login('asha@example.com', 'wrong-password');
await expect(page.getByText('Invalid email or password')).toBeVisible();
});POM vs fixtures: which one?
You don't have to choose. They work best together: page objects hold the page details, and fixtures deliver them to tests.
| Page object | Fixture | |
|---|---|---|
| What it is | A class for one page | Something Playwright sets up for a test |
| Main job | Group locators and actions | Set up and clean up automatically |
| Example | LoginPage, CartPage | A logged-in page, test data, a page object |
Name tests clearly and use steps
A good test name says what should happen: 'shows error for wrong password', not 'test 1'. For long tests, group actions with test.step() so the report reads like a story:
await test.step('Add item to cart', async () => {
await page.getByRole('button', { name: 'Add to cart' }).click();
});
await test.step('Check out', async () => {
await page.getByRole('link', { name: 'Checkout' }).click();
});7. Set up test data through the API
Why it matters: if you are testing "edit a product", you don't need to test "create a product" through the UI every time. Creating data with an API call takes milliseconds and rarely breaks.
Bad: click through five screens to create a product, then test editing it.
Good:
test('edit product name', async ({ page, request }) => {
// Setup through the API (fast)
const res = await request.post('/api/products', { data: { name: 'Blue T-shirt' } });
const product = await res.json();
// Test through the UI (what we actually care about)
await page.goto(`/products/${product.id}/edit`);
await page.getByLabel('Name').fill('Red T-shirt');
await page.getByRole('button', { name: 'Save' }).click();
await expect(page.getByRole('heading', { name: 'Red T-shirt' })).toBeVisible();
});Rule of thumb: use the UI for the thing you are testing, and the API for everything else.
8. Mock third-party services
What is mocking? Instead of calling a real outside service (payments, maps, email), Playwright returns a fake response that you control.
Why it matters: you can't control other companies' servers. If their sandbox is slow or down, your tests fail for reasons that have nothing to do with your app. Mocking also lets you test error cases that are hard to trigger for real.
test('shows message when payment fails', async ({ page }) => {
// Fake a failed payment response
await page.route('**/api/payment', route =>
route.fulfill({ status: 500, json: { error: 'Card declined' } })
);
await page.goto('/checkout');
await page.getByRole('button', { name: 'Pay now' }).click();
await expect(page.getByText('Card declined')).toBeVisible();
});Don't mock everything. Keep your own backend real in most end-to-end tests; mock only the outside services you don't own.
9. Handle flaky tests the right way
What is a flaky test? A test that sometimes passes and sometimes fails without any code change.
Why it matters: once people stop trusting the suite, they start ignoring real failures.
Do:
- Use
retriesin CI only (for example,retries: process.env.CI ? 2 : 0), so a one-off network blip doesn't block a release. - Treat a test marked flaky in the report (failed, then passed on retry) as a bug to fix, not as a pass.
- Find the root cause: usually a fixed wait, a CSS locator, shared data, or a missing
await.
Don't:
- Add more
waitForTimeout()to "fix" it. - Raise retries to 5 and forget about it.
10. Run tests in parallel
What is parallel testing? Running several tests at the same time in separate workers (separate processes), instead of one after another.
// playwright.config.ts
export default defineConfig({
fullyParallel: true, // run tests inside a file in parallel too
workers: process.env.CI ? 4 : undefined, // undefined = use about half your CPU cores
});This only works safely if tests are independent (practice 4). For very large suites, split them across several CI machines with sharding:
npx playwright test --shard=1/4 # machine 1 of 4
npx playwright test --shard=2/4 # machine 2 of 411. Debug with the right tools
Playwright gives you three debugging tools. Each one fits a different situation.
| Tool | What it is | When to use it | How to start it |
|---|---|---|---|
| UI mode | A window to run, watch and re-run tests step by step | Writing and fixing tests on your machine | npx playwright test --ui |
| Trace Viewer | A recording of a test run: every action, screenshot, network call and console log | A test failed in CI and you can't see why | npx playwright show-trace trace.zip |
| Debug mode | Pauses the test so you can step through it in the browser | Checking one line or one locator | npx playwright test --debug |
Turn on traces in CI, but only for retries. Recording every run is slow and fills up disk space. Recording only when a test is retried gives you a trace exactly when something went wrong:
// playwright.config.ts
use: {
trace: 'on-first-retry',
screenshot: 'only-on-failure',
video: 'retain-on-failure',
},Then open the HTML report with npx playwright show-report and click a failed test to see its trace.
12. Test across browsers and keep Playwright updated
Test on all three engines. Chromium (Chrome, Edge), Firefox and WebKit (Safari) each have their own bugs. Playwright runs the same test on all three with a few lines of config:
projects: [
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
{ name: 'firefox', use: { ...devices['Desktop Firefox'] } },
{ name: 'webkit', use: { ...devices['Desktop Safari'] } },
],Keep Playwright updated. Each release ships newer browser versions, so you catch problems before your users see them in their updated browsers. Check your version and update with:
npx playwright --version
npm install -D @playwright/test@latest
npx playwright install --with-depsRead the release notes before upgrading. New versions sometimes change defaults that affect existing tests.
Add type checking and linting to CI. Run npx tsc --noEmit and ESLint before tests, so typos and missing awaits are caught before any browser starts.
Common mistakes at a glance
| Mistake | Do this instead |
|---|---|
page.waitForTimeout(3000) | await expect(locator).toBeVisible() |
expect(await el.isVisible()).toBe(true) | await expect(el).toBeVisible() |
locator('div > div:nth-child(3) > span') | getByRole(), getByLabel() or getByTestId() |
.nth(2) to pick an item from a list | .filter({ hasText: 'Item name' }) |
| Test B relies on data from test A | Each test creates its own data through the API |
| Logging in through the UI in every test | Log in once and reuse storageState |
| Calling a real payment or email service | Mock it with page.route() |
expect(...) without await | Always await, and use the no-floating-promises lint rule |
trace: 'on' in CI | trace: 'on-first-retry' |
| High retries to hide flaky tests | Find and fix the root cause |
Final thoughts
You don't need to apply all twelve practices on day one. Start with the first three: better locators, no fixed waits, and web-first assertions. Those alone fix most flaky tests. Then add independent tests and saved login, and your suite will be faster and much easier to trust.
Need engineers who stay?
Dedicated developers at $3,500 per developer per month, working your backlog on 2-week sprints with a named technical lead.