Skip to content
HN
Back to blog
PlaywrightBest Practices

Playwright Best Practices I Actually Follow

A working list of Playwright conventions — locators, assertions, config — that keep a suite fast and readable as it grows.

July 30, 202616 min read

Playwright Best Practices I Actually Follow

Introduction

Playwright offers many powerful features for browser automation, but using Playwright effectively requires more than simply knowing its API.

A test suite can still become slow, flaky, and difficult to maintain even when it uses a modern tool.

Over time, I have learned that the most useful best practices are not always the most complicated ones. They are usually simple habits applied consistently across the entire automation project.

In this article, I will share the Playwright best practices I actually follow when building and maintaining end-to-end test automation.

These practices focus on:

  • Test stability
  • Readability
  • Maintainability
  • Debugging
  • Execution speed
  • Long-term scalability

1. I Use User-Facing Locators First

One of the first decisions in every Playwright test is how to locate an element.

I prefer locators that describe how users interact with the application.

For example:

await page.getByRole('button', {
  name: 'Login',
}).click();

Instead of:

await page.locator(
  '.header > div:nth-child(2) > button',
).click();

The first locator focuses on the purpose of the element.

The second locator depends on the page structure.

If the UI layout changes, the role-based locator is more likely to continue working.

My usual locator priority is:

  1. getByRole()
  2. getByLabel()
  3. getByPlaceholder()
  4. getByText()
  5. getByTestId()
  6. CSS selectors
  7. XPath

Example:

await page
  .getByLabel('Email address')
  .fill('tester@example.com');

await page
  .getByLabel('Password')
  .fill('Password123');

await page
  .getByRole('button', {
    name: 'Sign in',
  })
  .click();

These locators are readable and usually survive UI refactoring better than selectors based on CSS classes or element positions.


2. I Avoid Hardcoded Waits

One of the most common causes of slow and unreliable test suites is excessive use of waitForTimeout().

Example:

await page.waitForTimeout(5000);

This tells the test to wait for five seconds regardless of whether the application is ready after one second or ten seconds.

Hardcoded waits create two problems:

  • The test may wait longer than necessary.
  • The wait may still be too short in slower environments.

Instead, I wait for a meaningful condition.

await page
  .getByRole('button', {
    name: 'Submit',
  })
  .waitFor({
    state: 'visible',
  });

Or:

await expect(
  page.getByText('Order completed'),
).toBeVisible();

For loading indicators:

await page
  .getByTestId('loading-spinner')
  .waitFor({
    state: 'hidden',
  });

For API responses:

const responsePromise = page.waitForResponse(
  response =>
    response.url().includes('/api/orders') &&
    response.status() === 200,
);

await page
  .getByRole('button', {
    name: 'Place order',
  })
  .click();

await responsePromise;

I only use waitForTimeout() when time itself is part of the behaviour being tested or when debugging locally.


3. I Let Playwright Auto-Wait

Playwright automatically waits before performing actions.

Before clicking an element, Playwright checks whether it is:

  • Attached to the DOM
  • Visible
  • Stable
  • Enabled
  • Able to receive pointer events

Because of this, I avoid adding unnecessary manual waits.

Not recommended:

await button.waitFor({
  state: 'visible',
});

await button.click();

Often, this is enough:

await button.click();

The click() action already waits for the element to become actionable.

However, I still use explicit waits when I need to verify a specific application state, such as waiting for a modal to disappear or a result to load.

The goal is not to remove every wait.

The goal is to wait for the correct condition.


4. I Prefer Web-First Assertions

Playwright assertions automatically retry until the condition passes or the timeout is reached.

For example:

await expect(
  page.getByRole('heading', {
    name: 'Dashboard',
  }),
).toBeVisible();

This is better than checking the value immediately:

const visible = await page
  .getByRole('heading', {
    name: 'Dashboard',
  })
  .isVisible();

expect(visible).toBe(true);

The second example performs an immediate check and does not retry.

I regularly use assertions such as:

await expect(locator).toBeVisible();

await expect(locator).toBeHidden();

await expect(locator).toHaveText('Completed');

await expect(locator).toContainText('Success');

await expect(locator).toHaveValue('John');

await expect(locator).toHaveCount(3);

await expect(page).toHaveURL(/dashboard/);

await expect(page).toHaveTitle(/Dashboard/);

Web-first assertions make tests more stable because they account for asynchronous UI updates.


5. I Keep Tests Independent

Each test should be able to run:

  • Individually
  • In any order
  • In parallel
  • Without depending on another test

This is fragile:

Test 1 creates a user
        ↓
Test 2 updates the user
        ↓
Test 3 deletes the user

If Test 1 fails, the remaining tests also fail.

Instead, each test should create and clean up its own data.

test('user can update profile', async ({
  request,
  page,
}) => {
  const user = await createUser(request);

  await loginAsUser(page, user);

  await profilePage.updateDisplayName(
    'Automation Tester',
  );

  await expect(
    profilePage.successMessage,
  ).toBeVisible();

  await deleteUser(request, user.id);
});

Independent tests are easier to debug and safer to run with multiple workers.


6. I Use APIs for Setup and Cleanup

Using the UI to prepare every test is usually slow.

For example, imagine a test that verifies order history.

A UI-only setup might require:

Open registration page

↓

Create account

↓

Verify email

↓

Login

↓

Search for product

↓

Add product to cart

↓

Complete checkout

↓

Open order history

Instead, I prefer:

Create user through API

↓

Create order through API

↓

Login

↓

Open order history

↓

Verify order in UI

Example:

const createUserResponse =
  await request.post('/api/users', {
    data: {
      name: 'Test User',
      email: uniqueEmail,
    },
  });

expect(createUserResponse.ok()).toBeTruthy();

const user =
  await createUserResponse.json();

API-based setup makes tests faster, more focused, and easier to maintain.

The UI test should verify the feature under test, not spend most of its time preparing data.


7. I Use Page Objects Without Overengineering

I use the Page Object Model to keep UI logic outside test files.

Example:

export class LoginPage {
  constructor(
    private readonly page: Page,
  ) {}

  private get emailInput() {
    return this.page.getByLabel(
      'Email address',
    );
  }

  private get passwordInput() {
    return this.page.getByLabel(
      'Password',
    );
  }

  private get loginButton() {
    return this.page.getByRole(
      'button',
      {
        name: 'Login',
      },
    );
  }

  async login(
    email: string,
    password: string,
  ): Promise<void> {
    await this.emailInput.fill(email);
    await this.passwordInput.fill(password);
    await this.loginButton.click();
  }
}

The test becomes:

await loginPage.login(
  user.email,
  user.password,
);

await expect(
  dashboardPage.heading,
).toBeVisible();

However, I avoid turning the Page Object Model into a collection of giant classes.

A page object should not contain:

  • Hundreds of unrelated methods
  • Test data
  • Complex business assertions
  • API logic
  • Reporting logic
  • Methods for every page in the application

For shared elements, I create component objects.

components/

├── HeaderComponent.ts
├── SidebarComponent.ts
├── ModalComponent.ts
├── PaginationComponent.ts
└── ToastComponent.ts

This keeps page objects focused.


8. I Keep Assertions Mostly in Tests

Page objects should usually perform actions and expose page state.

Tests should verify expected outcomes.

Page object:

async submitOrder(): Promise<void> {
  await this.submitButton.click();
}

Test:

await checkoutPage.submitOrder();

await expect(
  checkoutPage.successMessage,
).toHaveText(
  'Your order has been placed',
);

I avoid this pattern:

async submitOrder(): Promise<void> {
  await this.submitButton.click();

  await expect(
    this.successMessage,
  ).toBeVisible();
}

Why?

Because the page object is now deciding what every test should verify.

One test may expect success.

Another may intentionally test a failed submission.

Keeping assertions in tests makes scenarios more flexible and explicit.


9. I Do Not Hide Every Playwright Action

A common mistake is creating generic wrappers for everything.

Example:

async clickElement(
  locator: Locator,
): Promise<void> {
  await locator.click();
}

Then:

await basePage.clickElement(
  loginPage.loginButton,
);

This adds another layer without adding useful behaviour.

Playwright already has a good API.

I only create wrappers when they provide real value, such as:

  • Standard logging
  • Screenshot capture
  • Error handling
  • Reusable synchronisation
  • Cross-project behaviour

For example:

async clickAndWaitForLoading(
  button: Locator,
): Promise<void> {
  await button.click();

  await this.loadingIndicator.waitFor({
    state: 'hidden',
  });
}

The wrapper has a clear purpose.

A wrapper that only renames click() to clickElement() usually does not.


10. I Use Fixtures for Repeated Setup

Fixtures are one of the most useful Playwright features for reducing repeated setup.

Example fixture:

import {
  test as base,
} from '@playwright/test';

import {
  LoginPage,
} from '../pages/LoginPage';

import {
  DashboardPage,
} from '../pages/DashboardPage';

type AppFixtures = {
  loginPage: LoginPage;
  dashboardPage: DashboardPage;
};

export const test =
  base.extend<AppFixtures>({
    loginPage: async (
      { page },
      use,
    ) => {
      await use(
        new LoginPage(page),
      );
    },

    dashboardPage: async (
      { page },
      use,
    ) => {
      await use(
        new DashboardPage(page),
      );
    },
  });

export {
  expect,
} from '@playwright/test';

The test becomes:

test(
  'user can open the dashboard',
  async ({
    loginPage,
    dashboardPage,
  }) => {
    await loginPage.login(
      'tester@example.com',
      'Password123',
    );

    await expect(
      dashboardPage.heading,
    ).toBeVisible();
  },
);

Fixtures are useful for:

  • Page objects
  • API clients
  • Authenticated sessions
  • Test users
  • Database setup
  • Environment configuration

11. I Reuse Authentication State

Logging in through the UI before every test is expensive.

For suitable scenarios, I authenticate once and save the browser state.

Example setup:

import {
  chromium,
} from '@playwright/test';

async function globalSetup(): Promise<void> {
  const browser =
    await chromium.launch();

  const page =
    await browser.newPage();

  await page.goto(
    'https://example.com/login',
  );

  await page
    .getByLabel('Email')
    .fill(process.env.TEST_EMAIL!);

  await page
    .getByLabel('Password')
    .fill(process.env.TEST_PASSWORD!);

  await page
    .getByRole('button', {
      name: 'Login',
    })
    .click();

  await page.context().storageState({
    path: 'playwright/.auth/user.json',
  });

  await browser.close();
}

export default globalSetup;

Then:

use: {
  storageState:
    'playwright/.auth/user.json',
}

This saves time and reduces repeated login failures.

However, I still keep dedicated login tests to verify the actual login flow.


12. I Avoid Shared Mutable Test Data

Parallel tests can interfere with each other when they share:

  • The same user account
  • The same database record
  • The same file
  • The same shopping cart
  • The same balance
  • The same report folder

For example, two workers updating the same profile may cause random failures.

I prefer creating unique data.

const uniqueEmail =
  `tester-${Date.now()}-${test.info().workerIndex}@example.com`;

For screenshots or output files:

const screenshotName =
  `${test.info().workerIndex}-${test.info().testId}.png`;

A better approach is often to use Playwright's output path:

const screenshotPath =
  test.info().outputPath(
    'failure.png',
  );

await page.screenshot({
  path: screenshotPath,
});

This prevents file collisions between tests and workers.


13. I Use Retries Carefully

Retries are helpful for identifying intermittent failures, but they should not hide real problems.

Example configuration:

retries:
  process.env.CI ? 2 : 0,

I use retries mainly in CI because remote environments can occasionally experience:

  • Temporary network issues
  • Slow infrastructure
  • Browser startup delays
  • External service instability

However, if a test passes only after retrying regularly, I treat it as a flaky test that needs investigation.

A retry is a safety net.

It is not a fix.


14. I Keep Trace, Screenshot, and Video Evidence

When a test fails in CI, I need enough information to understand the failure without rerunning it immediately.

My typical configuration is:

use: {
  screenshot: 'only-on-failure',
  video: 'retain-on-failure',
  trace: 'retain-on-failure',
}

In some projects, I use:

trace: 'on-first-retry',

This reduces artifact size while still providing useful debugging information.

The Playwright Trace Viewer can show:

  • Every action
  • DOM snapshots
  • Network requests
  • Console messages
  • Screenshots
  • Timing information
  • Locator details

This is often more useful than a screenshot alone.


15. I Add Meaningful Test Steps

For long workflows, I use test.step().

test(
  'customer can complete checkout',
  async ({ page }) => {
    await test.step(
      'Login as customer',
      async () => {
        await loginPage.login(
          customer.email,
          customer.password,
        );
      },
    );

    await test.step(
      'Add product to cart',
      async () => {
        await productPage.addToCart(
          'Laptop',
        );
      },
    );

    await test.step(
      'Complete checkout',
      async () => {
        await checkoutPage.placeOrder();
      },
    );

    await test.step(
      'Verify order success',
      async () => {
        await expect(
          checkoutPage.successMessage,
        ).toBeVisible();
      },
    );
  },
);

Meaningful steps improve:

  • HTML reports
  • Trace readability
  • Failure analysis
  • Team communication

I avoid creating a step for every individual click.

Steps should represent useful business actions.


16. I Configure Timeouts by Purpose

I avoid setting one huge timeout for everything.

Example:

await button.click({
  timeout: 100000,
});

A one-hundred-second timeout may hide application problems and make failures painfully slow.

Instead, I use appropriate timeouts for different situations.

expect: {
  timeout: 10_000,
},

use: {
  actionTimeout: 15_000,
  navigationTimeout: 30_000,
},

For a known slow operation:

await expect(
  reportPage.downloadCompleteMessage,
).toBeVisible({
  timeout: 60_000,
});

Specific timeouts are easier to understand than globally increasing every timeout.


17. I Avoid force: true Unless Necessary

Playwright allows forced actions:

await button.click({
  force: true,
});

This bypasses some actionability checks.

It can be useful in rare situations, but it may also hide real UI problems.

For example, if another element is covering the button, a real user may not be able to click it either.

Before using force: true, I check:

  • Is a loading overlay still visible?
  • Is a dropdown covering the element?
  • Is the target outside the viewport?
  • Is the element disabled?
  • Is the animation still running?
  • Is the locator matching the wrong element?

I only use forced actions when the application behaviour genuinely requires them.


18. I Avoid Using .first() as a Quick Fix

This locator may match multiple elements:

page.getByRole('button', {
  name: 'Edit',
});

A quick workaround is:

page
  .getByRole('button', {
    name: 'Edit',
  })
  .first();

But .first() may hide an unclear locator.

A better approach is to identify the intended container.

const userRow =
  page.getByRole('row', {
    name: /Alice Johnson/,
  });

await userRow
  .getByRole('button', {
    name: 'Edit',
  })
  .click();

I use .first(), .last(), or .nth() only when position is genuinely part of the requirement.


19. I Use Tags to Control Test Execution

Not every pipeline needs to run every test.

I classify tests by purpose.

test(
  'user can login @smoke',
  async ({ page }) => {
    // Test logic
  },
);

Then:

npx playwright test --grep @smoke

Or exclude tests:

npx playwright test --grep-invert @slow

Common categories include:

  • @smoke
  • @regression
  • @critical
  • @api
  • @mobile
  • @slow

Tags make CI/CD pipelines more flexible.


20. I Separate Test Data from Test Logic

I avoid filling test files with large hardcoded datasets.

Instead:

const user = {
  email: 'tester@example.com',
  password: 'Password123',
};

I move reusable data into dedicated modules.

export const validUser = {
  email:
    process.env.TEST_EMAIL!,
  password:
    process.env.TEST_PASSWORD!,
};

For larger datasets:

data/

├── users.json
├── products.json
├── payment-methods.json
└── environments.json

However, I do not move every small value into a separate file.

Test data should be externalised when it improves reuse, security, or readability.


21. I Keep Secrets Outside the Repository

Credentials should never be committed to source control.

I use environment variables.

const username =
  process.env.TEST_USERNAME;

const password =
  process.env.TEST_PASSWORD;

And validate them early:

if (!username || !password) {
  throw new Error(
    'TEST_USERNAME and TEST_PASSWORD are required',
  );
}

In CI, secrets can be stored in:

  • GitHub Actions Secrets
  • Jenkins Credentials
  • GitLab CI/CD Variables
  • Azure DevOps Variable Groups
  • Secret management platforms

A .env file used locally should be included in .gitignore.


22. I Keep the Playwright Configuration Readable

A configuration file should clearly show how tests run.

Example:

import {
  defineConfig,
  devices,
} from '@playwright/test';

export default defineConfig({
  testDir: './tests',

  fullyParallel: true,

  forbidOnly: Boolean(
    process.env.CI,
  ),

  retries:
    process.env.CI ? 2 : 0,

  workers:
    process.env.CI ? 2 : undefined,

  reporter: [
    ['list'],
    [
      'html',
      {
        outputFolder:
          'playwright-report',
        open: 'never',
      },
    ],
  ],

  use: {
    baseURL:
      process.env.BASE_URL,

    trace:
      'retain-on-failure',

    screenshot:
      'only-on-failure',

    video:
      'retain-on-failure',
  },

  projects: [
    {
      name: 'chromium',
      use: {
        ...devices[
          'Desktop Chrome'
        ],
      },
    },
  ],
});

I avoid placing complex business logic inside playwright.config.ts.

Configuration should configure.

It should not become another utility module.


23. I Treat Flaky Tests as Defects

A flaky test is not simply an annoying test.

It damages confidence in the entire test suite.

Common causes include:

  • Weak locators
  • Shared test data
  • Missing synchronisation
  • Environment instability
  • External dependencies
  • Animation timing
  • Incorrect assumptions about application state

When investigating a flaky test, I review:

Test failure

↓

Trace

↓

Screenshot

↓

Video

↓

Network requests

↓

Console logs

↓

Locator behaviour

↓

Test data conflicts

I do not immediately add a longer timeout.

A longer timeout may only make the same failure slower.


24. I Review Failed Tests by Root Cause

Not every failed test indicates a product defect.

A failure may come from:

  • Application bug
  • Automation bug
  • Environment issue
  • Test data issue
  • Network issue
  • Outdated expectation
  • Incorrect locator
  • Dependency failure

I classify the failure before fixing it.

For example:

Expected checkout success message

↓

API returned 500

↓

Backend defect

Or:

Button visible in screenshot

↓

Locator matched hidden duplicate

↓

Automation defect

Root-cause classification improves both the product and the automation framework.


25. I Optimise for Clarity Before Cleverness

A compact test is not automatically a good test.

This may be technically valid:

await page
  .locator('.x')
  .nth(2)
  .click();

But it tells the reader very little.

This is clearer:

await checkoutPage
  .placeOrderButton
  .click();

I prefer code that another tester can understand quickly.

Automation code is maintained far more often than it is originally written.

Clarity is a long-term optimisation.


A Practical Example

Here is a simplified example combining several of these practices.

import {
  test,
  expect,
} from '../fixtures/app.fixture';

test(
  'customer can place an order @smoke',
  async ({
    request,
    loginPage,
    productPage,
    checkoutPage,
  }) => {
    const customer =
      await createCustomer(
        request,
      );

    await test.step(
      'Login as customer',
      async () => {
        await loginPage.login(
          customer.email,
          customer.password,
        );
      },
    );

    await test.step(
      'Add product to cart',
      async () => {
        await productPage
          .addProductToCart(
            'Wireless Mouse',
          );
      },
    );

    await test.step(
      'Submit order',
      async () => {
        await checkoutPage
          .placeOrder();
      },
    );

    await test.step(
      'Verify order confirmation',
      async () => {
        await expect(
          checkoutPage
            .confirmationMessage,
        ).toContainText(
          'Order confirmed',
        );
      },
    );

    await deleteCustomer(
      request,
      customer.id,
    );
  },
);

This test:

  • Uses reusable fixtures
  • Creates data through an API
  • Uses business-oriented Page Object methods
  • Includes meaningful test steps
  • Keeps assertions in the test
  • Cleans up created data
  • Avoids hardcoded waits
  • Can run independently

My Playwright Best Practice Checklist

Before considering a test complete, I usually check:

  • Does the test use stable, user-facing locators?
  • Is the test independent?
  • Can it run in parallel safely?
  • Does it avoid unnecessary hardcoded waits?
  • Does it use web-first assertions?
  • Is setup performed through APIs where appropriate?
  • Are secrets stored outside the codebase?
  • Will failure artifacts provide enough debugging information?
  • Does the test clearly describe the business scenario?
  • Is the test validating the intended behaviour rather than implementation details?

If several answers are no, the test probably needs improvement.


Conclusion

Playwright provides excellent tools for building fast and reliable browser automation, but the quality of a test suite still depends on the decisions made by the people writing it.

The best practices I follow are not based on making the framework as complex as possible. They are based on keeping tests stable, independent, readable, and easy to debug.

I prioritise semantic locators, web-first assertions, API-based setup, independent test data, meaningful fixtures, and useful failure artifacts. I avoid hardcoded waits, unnecessary wrappers, shared mutable state, and retries that hide flaky behaviour.

The most important lesson is simple:

A good Playwright test should not only pass today. It should remain understandable, reliable, and maintainable months from now.

Playwright Best Practices I Actually Follow | Hoa Nguyen