Playwright Mistakes to Avoid: Fix Common Testing Errors

This guide explains the most common Playwright mistakes teams make in real projects and shows exactly how to fix them. You’ll see wrong-vs-right code examples, recommended configuration settings, and practical strategies used by teams running Playwright at scale.

Thumbnail 1

Playwright now drives over 33 million weekly npm downloads. Teams pick it up because it handles Chromium, Firefox, and WebKit from a single API, and the auto-waiting system means fewer timing headaches out of the box.

But writing a test that works once on your laptop is very different from writing one that passes 500 times across three browsers in a CI pipeline. Most teams hit the same walls: tests flickering between pass and fail, selectors breaking after a design tweak, and CI runs failing for reasons nobody can reproduce locally.

This guide covers eight specific Playwright mistakes, from hardcoded waits and fragile selectors to CI/CD misconfigurations, with wrong-vs-right code examples, production-ready config snippets, and a GitHub Actions workflow you can copy directly. Every fix is based on patterns from teams running Playwright at scale across hundreds of tests.

All examples in this guide target Playwright v1.40+ and follow the official Playwright best practices.

What are the most common Playwright mistakes developers make?

Before diving into each mistake individually, here is a quick overview of the patterns that trip up most teams. These are not edge cases. According to research-backed data on Playwright test failure root causes, the majority of broken test suites trace back to a handful of repeated anti-patterns.

The most frequent Playwright mistakes include:

  • Hardcoded waits (page.waitForTimeout()) instead of letting Playwright's auto-waiting handle timing

  • Fragile CSS or XPath selectors that snap the moment a developer changes a class name

  • Shared state between tests where one test's leftover data corrupts the next run

  • Spinning up a brand-new browser for every single test, burning through memory and time

  • Logging in through the UI before each test instead of reusing saved authentication

  • Skipping the trace viewer and debugging failures by guessing

  • No page object model, leading to duplicated selectors scattered across dozens of files

  • Broken CI/CD configurations that work locally but collapse in headless environments

A Playwright mistake is any recurring pattern in test code that causes unreliable results, slow execution, or difficult debugging, even when the application being tested is functioning correctly.

A Playwright mistake is any recurring pattern in test code that causes unreliable results, slow execution, or difficult debugging, even when the application being tested is functioning correctly.

Each of these is covered in detail below, with the wrong approach, why it breaks, and the exact fix.

Mistake 1: hardcoded waits instead of auto-waiting

This is the single most common Playwright mistake, and it is almost always the first one beginners make. Teams migrating from Selenium or Cypress bring their old sleep and wait habits along, and page.waitForTimeout() feels like a familiar safety net.

The temptation is clear. A test clicks a button, and the next element takes a moment to render. So you drop in a page.waitForTimeout(3000) and move on. The test passes on your machine.

Why it breaks:

  • If the element loads in 500ms, you wasted 2.5 seconds for nothing

  • If the server is slow (especially in CI), 3 seconds is not enough and the test fails

  • Multiply this across 200 tests and your suite takes 10 extra minutes of pure waiting

The fix: use Playwright's built-in auto-waiting

Playwright already waits for elements to be visible, stable, and actionable before interacting with them. You don't need to manage timing yourself.

example.spec.ts
// Wrong: hardcoded timeout
await page.waitForTimeout(3000);
await page.click('#submit-button');

// Right: let Playwright auto-wait
await page.getByRole('button', { name'Submit' }).click();

For situations where you need to confirm an element's state, use Playwright assertions with built-in retry logic:

example.spec.ts
// Web-first assertion with auto-retry
await expect(page.getByText('Order confirmed')).toBeVisible();

This same principle applies to async/await patterns. A common Playwright async await mistake is wrapping locator actions in manual try/catch blocks with arbitrary retries. Playwright's auto-waiting already retries internally, so layering your own retry logic on top just hides the real failure.

Tip: Resist the urge to carry over old wait patterns from Selenium or Cypress. Playwright's auto-waiting architecture handles timing internally. Let it do its job.

Mistake 2: fragile selectors that break on every UI change

After hardcoded waits, bad selectors are the second biggest source of Playwright testing errors. In large codebases, we have seen a single class-name refactor break 40+ tests overnight because every test file was pinned to the same fragile CSS chain.

A selector like div.container > ul > li:nth-child(3) > span.price is pinned to the exact DOM structure. A designer moves one wrapper div, and the selector returns nothing.

XPath has the same problem. Long XPath chains are brittle, hard to read, and painful to maintain.

What to use instead:

Playwright provides user-facing locators that care about what the element does, not where it sits in the DOM tree.

example.spec.ts
// Fragile: CSS chain tied to DOM structure
await page.locator('div.header > nav > ul > li:nth-child(2) > a').click();

// Fragile: XPath that breaks on DOM changes
await page.locator('//div[@class="header"]/nav/ul/li[2]/a').click();

// Stable: role-based locator
await page.getByRole('link', { name'Pricing' }).click();

// Stable: test ID locator
await page.getByTestId('pricing-link').click();

The priority order for choosing selectors (from Playwright's official docs) is:

  • getByRole() - matches ARIA roles (button, link, heading)

  • getByLabel() - form fields by their label text

  • getByText() - elements by visible text content

  • getByTestId() - explicit data-testid attributes

Note: Playwright's strict mode throws an error if a locator matches more than one element. This catches ambiguous selectors early, before they cause random failures in production CI runs.

Mistake 3: skipping test isolation and sharing state

When tests share cookies, local storage, or database state, they create invisible dependencies. Test A creates a user. Test B assumes that user exists. Run them in a different order or in parallel, and everything breaks. This is one of the hardest Playwright testing errors to debug because the failure only appears under specific execution orders.

This is one of the top reasons Playwright tests fail in CI/CD but pass locally. On your machine, tests run sequentially. In CI, they often run in parallel across workers.

The fix: isolate every test completely

cart.spec.ts
// Each test gets a fresh browser context
test('add item to cart'async ({ page }) => {
  // This page comes from a brand-new context
  // No cookies, no storage, no session leaks
  await page.goto('/shop');
  await page.getByRole('button', { name'Add to cart' }).click();
  await expect(page.getByTestId('cart-count')).toHaveText('1');
});

If tests need specific data, create that data in the test setup. Do not rely on side effects from other tests.

cart.spec.ts
test.beforeEach(async ({ page }) => {
  // Reset state explicitly before each test
  await page.request.post('/api/reset-test-data');
  await page.goto('/dashboard');
});

See your test failures clearly
TestDino shows flaky tests, root causes, and trends at a glance.
Start free CTA Graphic

Mistake 4: creating a new browser instance for every test

Launching a full browser process (Chromium, Firefox, or WebKit) is expensive. It eats CPU, allocates memory, and adds 1 to 3 seconds of startup overhead per test.

If your suite has 200 tests, that is up to 10 minutes of just waiting for browsers to boot.

The fix: reuse browser instances, reset contexts

Playwright already handles this well by default. Each test gets a new BrowserContext (which is lightweight), while the underlying browser instance stays alive.

playwright.config.ts
// playwright.config.ts - default behavior is correct
export default defineConfig({
  use: {
    // Playwright reuses the browser process across tests
    // Each test gets an isolated BrowserContext automatically
  },
  workers4// parallel workers share browser processes
});

The key insight: you do not need a new browser for isolation. A new BrowserContext gives you clean cookies, storage, and session state without the overhead of a full browser launch.

Tip: When running in parallel, Playwright distributes tests across workers. Each worker launches one browser and creates a new context per test. Tuning the workers count in your config to match your machine's CPU cores gives the best throughput.

Mistake 5: repeating login through the UI in every single test

If your app requires authentication, the naive approach is to fill in the username and password at the start of each test. That works, but it is painfully slow.

A login flow with a network round trip, session creation, and page redirect can take 2 to 5 seconds. Do that across 200 tests and you have added 7 to 16 minutes of pure login overhead.

The fix: use storageState to save and reuse authentication

Playwright lets you perform login once, export the session (cookies and local storage), and load it into every subsequent test.

global-setup.ts
// global-setup.ts - run login once before all tests
import { chromium } from '@playwright/test';

async function globalSetup() {
  const browser = await chromium.launch();
  const context = await browser.newContext();
  const page = await context.newPage();

  await page.goto('/login');
  await page.getByLabel('Email').fill('[email protected]');
  await page.getByLabel('Password').fill('securepassword');
  await page.getByRole('button', { name'Sign in' }).click();

  // Save signed-in state
  await context.storageState({ path'./auth/user.json' });
  await browser.close();
}
export default globalSetup;

playwright.config.ts
// playwright.config.ts - reuse the saved session
export default defineConfig({
  globalSetup'./global-setup.ts',
  use: {
    storageState'./auth/user.json',
  },
});

Now every test starts authenticated instantly. No login form, no network delay.

Note: Add auth/ to your .gitignore file. The storageState JSON contains session cookies and tokens that should never be committed to version control.

How do you fix flaky tests and selector failures in Playwright?

Flaky tests are tests that sometimes pass and sometimes fail without any code change. According to the Flaky Test Benchmark Report 2026, the average flaky test rate across engineering teams is around 2 to 5%, but even a small percentage can erode trust in the entire test suite.

The most effective strategies to fix Playwright flaky tests are:

  • Replace every waitForTimeout with auto-waiting locators and web-first assertions

  • Switch to role-based or test-id selectors to survive DOM changes

  • Isolate test state so parallel execution does not cause cross-contamination

  • Mock external API calls using Playwright's network interception to remove third-party variability

  • Enable tracing on first retry so failed tests produce actionable diagnostics

playwright.config.ts
// recommended flaky test settings
export default defineConfig({
  retriesprocess.env.CI ? 2 : 0,
  use: {
    trace'on-first-retry',
    screenshot'only-on-failure',
    video'on-first-retry',
  },
});

A flaky test is a test that produces different results (pass/fail) across multiple runs on the same codebase and environment, without any intentional changes. Flakiness is usually caused by timing issues, shared state, or environmental differences.

A flaky test is a test that produces different results (pass/fail) across multiple runs on the same codebase and environment, without any intentional changes. Flakiness is usually caused by timing issues, shared state, or environmental differences.

Mistake 6: ignoring Playwright's debugging and tracing tools

When a test fails, many developers do this: stare at the error message, add a console.log, re-run, and repeat. That loop can eat hours.

Playwright ships with a powerful set of debugging tools that most teams underuse. The trace viewer alone can save you significant time by showing exactly what happened during a failed test: DOM snapshots, network calls, console logs, and a visual timeline of every action.

Tools you should be using:

  • Trace viewer → Full replay of the test with screenshots at every step

  • UI mode → Interactive test runner with live browser preview

  • Playwright Inspector → Step through tests and inspect selectors in real time

terminal
# Open the trace viewer for a failed test
npx playwright show-trace test-results/my-test/trace.zip

# Run tests in UI mode for interactive debugging
npx playwright test --ui

# Debug a specific test with the inspector
npx playwright test mytest.spec.ts --debug

Note: Set trace: 'on-first-retry' in your config. This avoids recording traces for passing tests (which saves storage) while still capturing diagnostics for any test that needed a retry.

Mistake 7: not structuring tests with the page object model

When a project starts small, it is tempting to write all selectors and actions directly inside test files. Five tests later, you have the same page.getByRole('button', { name: 'Add to cart' }) duplicated in three places.

Then someone renames the button text from "Add to cart" to "Add item." Now you are hunting through every test file to update the selector.

The fix: use the page object model (POM)

The POM pattern wraps each page's elements and actions into a reusable class. Tests call descriptive methods instead of raw selectors.

pages/CartPage.ts
// pages/CartPage.ts
export class CartPage {
  constructor(private pagePage) {}

  async addItem(itemNamestring) {
    await this.page.getByRole('button', { nameitemName }).click();
  }

  async getCartCount() {
    return this.page.getByTestId('cart-count');
  }
}

tests/cart.spec.ts
// tests/cart.spec.ts
import { CartPage } from '../pages/CartPage';

test('user can add item to cart'async ({ page }) => {
  const cart = new CartPage(page);
  await page.goto('/shop');
  await cart.addItem('Wireless Mouse');
  await expect(await cart.getCartCount()).toHaveText('1');
});

If the button text ever changes, you update one line in CartPage.ts instead of every test. Combine POMs with Playwright annotations to tag tests by feature area, making it even easier to trace which page objects are covered by which tests.

Tip: Use Playwright fixtures to inject page objects automatically. This removes boilerplate new CartPage(page) calls and keeps test files clean. The Playwright E2E testing guide walks through this pattern step by step.

Debug failures in one click
TestDino's dashboard flags flaky tests and surfaces root causes.
Try free CTA Graphic

Mistake 8: misconfiguring Playwright for CI/CD pipelines

The classic symptom: tests pass on every developer's laptop but fail the moment they hit CI. This is one of the most frustrating Playwright CI/CD errors because the failure message rarely tells you the real cause.

Common CI/CD misconfigurations:

  • Missing headless flag - CI environments usually do not have a display. If your config defaults to headless: false, tests will crash.

  • Insufficient timeouts - CI servers are often slower than local machines. Default timeouts may be too tight.

  • Missing system dependencies - Playwright browsers need specific OS libraries. In Docker or fresh CI images, these are often missing.

  • No artifact collection - When a test fails in CI, you need traces, screenshots, and videos to debug remotely. Without them, you are flying blind.

playwright.config.ts
// playwright.config.ts
export default defineConfig({
  retriesprocess.env.CI ? 2 : 0,
  workersprocess.env.CI ? 2 : undefined,
  use: {
    headlesstrue,
    trace'on-first-retry',
    screenshot'only-on-failure',
    video'on-first-retry',
  },
});

.github/workflows/playwright.yml
# GitHub Actions workflow example
nameInstall Playwright Browsers
  runnpx playwright install --with-deps

nameRun Playwright Tests
  runnpx playwright test

nameUpload test artifacts
  usesactions/upload-artifact@v4
  ifalways()
  with:
    nameplaywright-report
    pathplaywright-report/

Note: Always run npx playwright install --with-deps in your CI pipeline. The --with-deps flag installs both the browser binaries and the underlying OS libraries they need. Without it, you will see cryptic "browser failed to launch" errors.

TestDino integrates with CI pipelines to surface test failure trends across runs. When combined with Playwright's trace artifacts, it gives your team a single dashboard to track which tests are failing, how often, and why, without digging through raw CI logs.

Quick-reference: Playwright mistakes vs. fixes

#

Mistake

Impact

Fix

1

Hardcoded waits

Slow, flaky

Use auto-waiting + web-first assertions

2

Fragile selectors

Tests break after UI changes

Use getByRole(), getByTestId()

3

Shared test state

Parallel failures

Isolate with fresh BrowserContext per test

4

New browser per test

Slow, resource-heavy

Reuse browser, new context per test

5

UI login every test

Adds minutes to suite

Use storageState for auth reuse

6

No tracing/debugging

Hours of guesswork

Enable trace viewer, UI mode

7

No page object model

Duplicated, unmaintainable selectors

Wrap pages in POM classes

8

Broken CI/CD config

Tests pass locally, fail in CI

Headless mode, --with-deps, artifact upload

Track every Playwright test run
TestDino captures results, traces, and failure patterns automatically.
Get started CTA Graphic

Conclusion

Most Playwright mistakes come down to the same root cause: fighting the framework instead of working with it. Playwright already handles timing, isolation, and browser management. The job is to stop overriding those defaults with manual workarounds.

The eight Playwright mistakes covered here mainly responsible for the majority of flaky and broken test suites. Fix them, and your tests become faster, more stable, and significantly easier to maintain.

If you want to keep your test suite healthy over time, pair these Playwright best practices with a reporting tool like TestDino that surfaces failure trends, flags flaky tests automatically, and gives your team visibility into what is actually breaking across every run, every branch, and every browser.

FAQs

What is the most common mistake in Playwright test automation?
The most common Playwright mistake is using hardcoded waits like page.waitForTimeout() instead of relying on Playwright's built-in auto-waiting. Playwright waits for elements to be stable, visible, and actionable before every interaction. Fixed timeouts make tests slower (wasting time when the element loads fast) and more fragile (failing when the server is slow.
Why do Playwright tests pass locally but fail in CI/CD?
This usually comes down to environment differences: slower rendering, network variability, and concurrency. The most common causes are fragile selectors tied to specific DOM structures, missing headless mode configuration, skipping --with-deps during browser installation, and no proper state resets between tests.
How do I fix flaky tests in Playwright?
Start by replacing every waitForTimeout() call with Playwright's smart locators and web-first assertions. Enable trace: 'on-first-retry' for diagnostics. Use storageState for authentication reuse and isolate test state so parallel runs do not cross-contaminate. Finally, mock external APIs using Playwright network mocking to remove third-party variability.
Is using XPath selectors a mistake in Playwright?
Yes. Relying on XPath or deep CSS class-based selectors is a common Playwright mistake because they are tightly coupled to the DOM structure. When a developer moves a div or renames a CSS class, these selectors break silently. Playwright recommends using role-based locators (getByRole), label-based locators (getByLabel), or explicit data-testid attributes for selectors that survive UI refactors.
Should I create a new browser instance for every Playwright test?
No. Creating a new browser instance per test is a costly Playwright mistake that adds 1 to 3 seconds of launch overhead per test. Instead, let Playwright reuse the underlying browser process and create a lightweight BrowserContext for each test. This gives you full test isolation (clean cookies, storage, sessions) without the performance penalty of launching a new browser.
Dhruv Rai

Product & Growth Engineer

Dhruv Rai is a Product and Growth Engineer at TestDino, focusing on developer automation and product workflows. His work involves building solutions around Playwright, CI/CD, and developer tooling to improve release reliability.

He contributes through technical content and product initiatives that help engineering teams adopt modern testing practices and make informed tooling decisions.

Get started fast

Step-by-step guides, real-world examples, and proven strategies to maximize your test reporting success