Rorix Technologies
Engineering13 min read

Playwright Best Practices: A Simple Guide for Testers and Developers

PlaywrightPlaywright Best PracticesTest AutomationE2E TestingQA AutomationSoftware TestingFlaky TestsPage Object Model
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 practiceIn one line
1Use user-facing locatorsPrefer getByRole(), getByLabel(), getByTestId() over CSS and XPath
2Never use fixed waitsNo waitForTimeout(); Playwright waits for you
3Use web-first assertionsawait expect(locator).toBeVisible() retries until it passes
4Keep tests independentEach test sets up its own data and can run alone
5Log in once, reuse itSave login state with storageState
6Organise with page objects and fixturesKeep page details out of your test steps
7Set up data through the APIFaster and more stable than clicking through the UI
8Mock third-party servicesDon't let other people's servers break your tests
9Fix flaky tests, don't hide themRetries in CI only; treat "flaky" as a bug
10Run tests in parallelUse workers and sharding for speed
11Debug with the right toolsUI mode locally, traces in CI
12Test all browsers, stay updatedChromium, 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:

LocatorUse it forExample
getByRole()Buttons, links, headings, checkboxesgetByRole('button', { name: 'Save' })
getByLabel()Form fields with a labelgetByLabel('Password')
getByPlaceholder()Fields with only placeholder textgetByPlaceholder('Search')
getByText()Plain text on the pagegetByText('Order placed')
getByTestId()When nothing above worksgetByTestId('cart-total')
locator('css')Last resort onlylocator('.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 loaded

Good:

await page.getByRole('button', { name: 'Submit' }).click();
await expect(page.getByText('Form submitted')).toBeVisible();   // waits only as long as needed

Also 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 assertionWeb-first assertion
Looks likeexpect(await el.isVisible()).toBe(true)await expect(el).toBeVisible()
ChecksOnceAgain and again until it passes
Result on a slow pageFlaky failurePasses

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 objectFixture
What it isA class for one pageSomething Playwright sets up for a test
Main jobGroup locators and actionsSet up and clean up automatically
ExampleLoginPage, CartPageA 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 retries in 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 4

11. Debug with the right tools

Playwright gives you three debugging tools. Each one fits a different situation.

ToolWhat it isWhen to use itHow to start it
UI modeA window to run, watch and re-run tests step by stepWriting and fixing tests on your machinenpx playwright test --ui
Trace ViewerA recording of a test run: every action, screenshot, network call and console logA test failed in CI and you can't see whynpx playwright show-trace trace.zip
Debug modePauses the test so you can step through it in the browserChecking one line or one locatornpx 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-deps

Read 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

MistakeDo 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 AEach test creates its own data through the API
Logging in through the UI in every testLog in once and reuse storageState
Calling a real payment or email serviceMock it with page.route()
expect(...) without awaitAlways await, and use the no-floating-promises lint rule
trace: 'on' in CItrace: 'on-first-retry'
High retries to hide flaky testsFind 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.

Work with Rorix

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.

Playwright Best Practices: A Simple Guide for Testers and Developers