We use cookies to enhance your experience on the site
CodeWorlds
Back to collections
Guide20 min read

Playwright, end to end testing and browser automation

Playwright runs end to end tests across three browser engines. Locators, auto waiting, flaky tests, and the new component testing model.

Playwright, end to end testing and browser automation

Playwright is a browser automation tool used mainly for end to end testing. Microsoft develops it under Apache 2.0, and the current version is 1.62.1, released on 30 July 2026. What separates it from the competition is support for three browser engines from one test file, plus a mechanism that waits for an element to be ready on its own.

Why this tool won

For years browser tests carried a reputation as something written reluctantly and maintained even more so. The reason was concrete: tests failed at random. An element appeared a fraction of a second late, an animation was still running, a network request had not returned. The response was sprinkling artificial delays, which made matters worse, since tests became slow and still flaky.

That problem was solved at the source here. Before clicking an element, the tool checks that the element exists, is visible, is not obscured, and accepts events. It waits until all those conditions hold and only then acts. The upshot is that artificial delays stop being necessary and tests stop failing for no reason.

The second thing is three browser engines from one test file. This is not about compatibility with old browsers but about catching differences that genuinely occur: form controls behaving differently, touch events handled differently, different default styles. In a Next.js application what surfaces here most often concerns hydration and components that behave differently on first render.

Know, though, that running every test on all three engines triples execution time, and the value of that is uneven. A sensible arrangement is the full suite on one engine for every change and a run across all three once a day or before a release. Differences between engines are real but rarely appear overnight, so paying for them daily in full execution time is waste.

The third is diagnostic tooling. A recorded execution trace captures screenshots before and after each operation, network state, log entries, and page structure. A test that failed on a build server stops being a mystery, because you open the trace file and see exactly what happened.

The fourth, mentioned less often, is recording tests by interaction. You start the tool, click through the application as a user would, and it writes the corresponding code. That is not a way to write production tests, since generated code tends to be literal and brittle, but it works well as a starting point and as a way to find out which locator to use for an awkward element.

Visual tests and when they earn their place

Comparing screenshots against a baseline is a feature people reach for eagerly and abandon quickly, so it is worth knowing where it works well and where it causes more harm than good.

The mechanism is simple: the first run creates a baseline, subsequent runs compare the current screenshot against it and report differences above a tolerance threshold. That sounds perfect for catching unintended visual changes.

The problem is that rendering differs between systems. The same code on macOS and on Linux produces different font antialiasing, and the difference is enough to fail a comparison. Baselines therefore have to be generated in the same environment the tests run in, meaning the container used on the build server rather than the author's laptop.

The second trap is scope. A full page screenshot breaks with every content change, including copy nobody was testing. Comparisons are worth narrowing to a specific component and to interface elements whose appearance is genuinely a contract with users.

The third is variable elements. A date, a counter, an avatar drawn at random, and an animation will make a test fail every time. They can be masked before comparison, and that is the right order of operations, not raising the tolerance threshold, since a raised threshold stops detecting anything.

If after the first month the team starts updating baselines reflexively without looking at the differences, that means there are too many visual tests or they are too broad. A few well aimed comparisons carry value; several dozen arbitrary ones become a ritual.

Locators, or how not to write brittle tests

This is where the worst decisions are most often made, with consequences paid over the following year.

Targeting elements by CSS classes or document tree structure is tempting. It works immediately and breaks with the first styling change, because the test stops finding a button that is still there and still works.

The approach that holds up targets elements the way a user does: by role and visible name. A button reading "Save" is found as a button with that name, not as the third element carrying a class beginning with a random hash.

Code
TypeScript
await page.getByRole('button', { name: 'Save' }).click()
await page.getByLabel('Email address').fill('anna@example.com')

There is a side effect worth knowing, since it is often the best argument for this approach in a conversation with a team. If an element cannot be targeted by role and name, that means it has no accessible name, so a screen reader will not describe it either. The test then forces a fix that repairs accessibility along the way.

When there genuinely is no other option, a test attribute remains. That is a last resort rather than a default, because it creates an element existing solely for the test, which somebody will eventually delete as unused. If you reach for it regularly, ask whether the problem sits in the interface itself: a button without text, an icon without a description, and a field without a label are hard to target in a test for exactly the reason they are hard to use without sight.

Choosing between waiting for an element and waiting for a state is a separate matter. The automatic mechanism covers a single operation, but it does not know that after clicking save the list still has to reload. Checking the consequence, meaning waiting for a new row to appear, works better here than any countdown, since the test ends exactly when the condition holds rather than after a fixed time.

The new component testing model

Version 1.62 changed how components are tested, and it is worth knowing, since older tutorials describe the previous approach.

The new model rests on stories and galleries. A story wraps a component in one specific scenario, with fixed props, mock data, and providers, while a gallery is a page rendering those stories on demand. A test mounts a chosen story and checks what appeared.

The change has a practical rationale. The previous approach required building the component inside the test context, which grew brittle with complex dependencies. Now the component renders where it normally lives, in your application with your configuration, and the test merely reaches for it.

It is worth considering, though, whether browser based component tests are what you need. Launching a real browser costs time, and most component logic is checked faster by a tool that runs without one, Vitest for instance. A browser is needed where actual rendering matters: layout, conditional styling, behaviour at different viewport sizes.

What is Playwright?

Playwright is a modern browser automation and end-to-end testing framework created by Microsoft. It supports all major browsers (Chromium, Firefox, WebKit) with a single consistent API, offering fast, reliable, and cross-browser testing of web applications.

Unlike older tools such as Selenium, Playwright was designed from the ground up with modern applications in mind - it handles auto-waiting, web components, shadow DOM, iframes, and many other challenging scenarios without additional configuration.

Why Playwright?

Key advantages

  1. Cross-browser - Chromium, Firefox, WebKit with a single API
  2. Auto-waiting - Automatic waiting for elements
  3. Speed - Parallel test execution
  4. Reliability - No flaky tests thanks to smart retries
  5. Codegen - Recording tests through interaction
  6. Trace Viewer - Debugging with full context
  7. API Testing - Built-in REST API testing

Playwright vs Cypress vs Selenium

FeaturePlaywrightCypressSelenium
BrowsersChromium, Firefox, WebKitChromium and Edge, Firefox, experimental WebKitAll
SpeedVery fastFastSlow
Auto-waitingBuilt-inBuilt-inManual
ParallelNativeVia Cypress Cloud, capped free planRequires Grid
iframesFull supportLimitedFull
Mobile emulationYesNoVia Appium
API testingBuilt-inPluginExternal
LanguagesJS/TS, Python, .NET, JavaJS/TS onlyAll

Installation and configuration

New project

Code
Bash
# Initialize a Playwright project
npm init playwright@latest

# Answer the prompts:
# - TypeScript or JavaScript
# - Test folder (tests/)
# - Add GitHub Actions workflow?
# - Install browsers?

Project structure

Code
TEXT
my-project/
├── tests/
│   ├── example.spec.ts
│   └── auth.spec.ts
├── playwright.config.ts
├── package.json
└── .github/
    └── workflows/
        └── playwright.yml

Configuring playwright.config.ts

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

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

  // Run tests in parallel
  fullyParallel: true,

  // Fail build on CI if you accidentally left test.only
  forbidOnly: !!process.env.CI,

  // Retry failed tests on CI
  retries: process.env.CI ? 2 : 0,

  // Workers - parallel processes
  workers: process.env.CI ? 1 : undefined,

  // Reporter
  reporter: [
    ['html'],
    ['list'],
    process.env.CI ? ['github'] : ['line']
  ],

  // Global test settings
  use: {
    // Base URL
    baseURL: 'http://localhost:3000',

    // Trace on failure
    trace: 'on-first-retry',

    // Screenshot on failure
    screenshot: 'only-on-failure',

    // Video on failure
    video: 'on-first-retry',
  },

  // Projects = browser configurations
  projects: [
    {
      name: 'chromium',
      use: { ...devices['Desktop Chrome'] },
    },
    {
      name: 'firefox',
      use: { ...devices['Desktop Firefox'] },
    },
    {
      name: 'webkit',
      use: { ...devices['Desktop Safari'] },
    },
    // Mobile
    {
      name: 'Mobile Chrome',
      use: { ...devices['Pixel 5'] },
    },
    {
      name: 'Mobile Safari',
      use: { ...devices['iPhone 12'] },
    },
  ],

  // Start dev server before tests
  webServer: {
    command: 'npm run dev',
    url: 'http://localhost:3000',
    reuseExistingServer: !process.env.CI,
  },
})

Writing tests

Basic structure

Code
TypeScript
import { test, expect } from '@playwright/test'

test.describe('Homepage', () => {
  test.beforeEach(async ({ page }) => {
    await page.goto('/')
  })

  test('has correct title', async ({ page }) => {
    await expect(page).toHaveTitle(/My App/)
  })

  test('navigation works', async ({ page }) => {
    await page.getByRole('link', { name: 'About' }).click()
    await expect(page).toHaveURL('/about')
  })
})

Locators - finding elements

Code
TypeScript
import { test, expect } from '@playwright/test'

test('locators examples', async ({ page }) => {
  await page.goto('/form')

  // Recommended - semantic locators
  // Role-based (accessibility)
  await page.getByRole('button', { name: 'Submit' }).click()
  await page.getByRole('link', { name: 'Home' }).click()
  await page.getByRole('heading', { name: 'Welcome' })
  await page.getByRole('textbox', { name: 'Email' }).fill('test@example.com')
  await page.getByRole('checkbox', { name: 'Accept terms' }).check()

  // Label-based
  await page.getByLabel('Email').fill('test@example.com')
  await page.getByLabel('Password').fill('secret123')

  // Placeholder
  await page.getByPlaceholder('Enter your email').fill('test@example.com')

  // Text content
  await page.getByText('Welcome back')
  await page.getByText(/welcome/i) // Regex, case-insensitive

  // Test ID (for complex cases)
  await page.getByTestId('submit-button').click()

  // Last resort - CSS/XPath, less stable
  await page.locator('.submit-btn').click()
  await page.locator('#email-input').fill('test@example.com')
  await page.locator('button[type="submit"]').click()
  await page.locator('//button[text()="Submit"]').click() // XPath
})

Chaining locators

Code
TypeScript
test('chained locators', async ({ page }) => {
  // Find an element within the context of another
  const form = page.locator('form#login')
  await form.getByLabel('Email').fill('test@example.com')
  await form.getByLabel('Password').fill('secret')
  await form.getByRole('button', { name: 'Login' }).click()

  // Filtering
  const rows = page.getByRole('row')
  const activeRow = rows.filter({ hasText: 'Active' })
  await activeRow.getByRole('button', { name: 'Edit' }).click()

  // Nth element
  await page.getByRole('listitem').nth(2).click() // Third element
  await page.getByRole('listitem').first().click()
  await page.getByRole('listitem').last().click()
})

Assertions

Code
TypeScript
import { test, expect } from '@playwright/test'

test('assertions examples', async ({ page }) => {
  await page.goto('/dashboard')

  // Page assertions
  await expect(page).toHaveTitle('Dashboard')
  await expect(page).toHaveURL(/dashboard/)
  await expect(page).toHaveURL('http://localhost:3000/dashboard')

  // Element visibility
  await expect(page.getByText('Welcome')).toBeVisible()
  await expect(page.getByText('Error')).toBeHidden()
  await expect(page.getByTestId('loading')).not.toBeVisible()

  // Element state
  await expect(page.getByRole('button')).toBeEnabled()
  await expect(page.getByRole('button')).toBeDisabled()
  await expect(page.getByRole('checkbox')).toBeChecked()
  await expect(page.getByRole('textbox')).toBeEditable()
  await expect(page.getByRole('textbox')).toBeFocused()

  // Element content
  await expect(page.getByRole('heading')).toHaveText('Dashboard')
  await expect(page.getByRole('heading')).toContainText('Dash')
  await expect(page.getByRole('textbox')).toHaveValue('initial value')
  await expect(page.getByRole('textbox')).toBeEmpty()

  // Element attributes
  await expect(page.getByRole('link')).toHaveAttribute('href', '/about')
  await expect(page.getByRole('img')).toHaveAttribute('alt', /logo/i)

  // CSS
  await expect(page.getByTestId('box')).toHaveCSS('color', 'rgb(0, 0, 0)')
  await expect(page.getByRole('button')).toHaveClass(/primary/)

  // Count
  await expect(page.getByRole('listitem')).toHaveCount(5)

  // Soft assertions (do not stop the test)
  await expect.soft(page.getByText('Optional')).toBeVisible()
})

Interactions

Code
TypeScript
test('interactions', async ({ page }) => {
  await page.goto('/form')

  // Clicking
  await page.getByRole('button').click()
  await page.getByRole('button').click({ button: 'right' }) // Right click
  await page.getByRole('button').dblclick() // Double click
  await page.getByRole('button').click({ modifiers: ['Control'] }) // Ctrl+click

  // Hover
  await page.getByText('Menu').hover()

  // Filling forms
  await page.getByLabel('Email').fill('test@example.com')
  await page.getByLabel('Email').clear()
  await page.getByLabel('Email').type('test@example.com') // Keystroke by keystroke

  // Press keys
  await page.getByLabel('Search').press('Enter')
  await page.keyboard.press('Escape')
  await page.keyboard.press('Control+A')

  // Select dropdown
  await page.getByLabel('Country').selectOption('poland')
  await page.getByLabel('Country').selectOption({ label: 'Poland' })
  await page.getByLabel('Multi').selectOption(['opt1', 'opt2'])

  // Checkbox & radio
  await page.getByLabel('Accept').check()
  await page.getByLabel('Accept').uncheck()
  await page.getByLabel('Accept').setChecked(true)

  // File upload
  await page.getByLabel('Upload').setInputFiles('path/to/file.pdf')
  await page.getByLabel('Upload').setInputFiles(['file1.pdf', 'file2.pdf'])

  // Drag and drop
  await page.getByTestId('source').dragTo(page.getByTestId('target'))

  // Focus
  await page.getByLabel('Email').focus()
  await page.getByLabel('Email').blur()
})

Waiting and timeouts

Code
TypeScript
test('waiting', async ({ page }) => {
  await page.goto('/dashboard')

  // Auto-waiting - Playwright automatically waits for elements
  // The following by itself waits until the element is visible and clickable
  await page.getByRole('button').click()

  // Explicit waits
  await page.waitForSelector('.loaded')
  await page.waitForLoadState('networkidle')
  await page.waitForLoadState('domcontentloaded')
  await page.waitForURL('/dashboard')

  // Wait for response
  const responsePromise = page.waitForResponse('**/api/data')
  await page.getByRole('button', { name: 'Load' }).click()
  const response = await responsePromise

  // Wait for request
  const requestPromise = page.waitForRequest('**/api/submit')
  await page.getByRole('button', { name: 'Submit' }).click()
  await requestPromise

  // Custom wait
  await page.waitForFunction(() => {
    return document.querySelector('.counter')?.textContent === '10'
  })

  // Timeout override
  await page.getByRole('button').click({ timeout: 10000 })

  // Poll until condition
  await expect(async () => {
    const count = await page.getByTestId('count').textContent()
    expect(parseInt(count!)).toBeGreaterThan(5)
  }).toPass({ timeout: 10000 })
})

Advanced scenarios

Authentication

TStests/auth.setup.ts
TypeScript
// tests/auth.setup.ts
import { test as setup, expect } from '@playwright/test'

const authFile = 'playwright/.auth/user.json'

setup('authenticate', async ({ page }) => {
  await page.goto('/login')
  await page.getByLabel('Email').fill('user@example.com')
  await page.getByLabel('Password').fill('password123')
  await page.getByRole('button', { name: 'Sign in' }).click()

  // Wait for redirect after login
  await page.waitForURL('/dashboard')

  // Save authentication state
  await page.context().storageState({ path: authFile })
})
TSplaywright.config.ts
TypeScript
// playwright.config.ts
export default defineConfig({
  projects: [
    // Setup project
    { name: 'setup', testMatch: /.*\.setup\.ts/ },

    // Tests that need auth
    {
      name: 'chromium',
      use: {
        ...devices['Desktop Chrome'],
        storageState: 'playwright/.auth/user.json',
      },
      dependencies: ['setup'],
    },
  ],
})
TStests/dashboard.spec.ts
TypeScript
// tests/dashboard.spec.ts
import { test, expect } from '@playwright/test'

// This test uses the logged-in state
test('dashboard shows user data', async ({ page }) => {
  await page.goto('/dashboard')
  await expect(page.getByText('Welcome, User')).toBeVisible()
})

Multiple contexts (multi-user)

Code
TypeScript
test('admin and user interaction', async ({ browser }) => {
  // Admin context
  const adminContext = await browser.newContext({
    storageState: 'playwright/.auth/admin.json'
  })
  const adminPage = await adminContext.newPage()

  // User context
  const userContext = await browser.newContext({
    storageState: 'playwright/.auth/user.json'
  })
  const userPage = await userContext.newPage()

  // Admin creates something
  await adminPage.goto('/admin/posts')
  await adminPage.getByRole('button', { name: 'New Post' }).click()
  await adminPage.getByLabel('Title').fill('Test Post')
  await adminPage.getByRole('button', { name: 'Publish' }).click()

  // User sees it
  await userPage.goto('/posts')
  await expect(userPage.getByText('Test Post')).toBeVisible()

  // Cleanup
  await adminContext.close()
  await userContext.close()
})

API testing

Code
TypeScript
import { test, expect } from '@playwright/test'

test.describe('API Tests', () => {
  test('GET /api/users', async ({ request }) => {
    const response = await request.get('/api/users')

    expect(response.ok()).toBeTruthy()
    expect(response.status()).toBe(200)

    const users = await response.json()
    expect(users).toHaveLength(10)
    expect(users[0]).toHaveProperty('email')
  })

  test('POST /api/users', async ({ request }) => {
    const response = await request.post('/api/users', {
      data: {
        name: 'John Doe',
        email: 'john@example.com'
      }
    })

    expect(response.status()).toBe(201)

    const user = await response.json()
    expect(user.name).toBe('John Doe')
  })

  test('with authentication', async ({ request }) => {
    const response = await request.get('/api/me', {
      headers: {
        'Authorization': `Bearer ${process.env.API_TOKEN}`
      }
    })

    expect(response.ok()).toBeTruthy()
  })
})

Visual regression testing

Code
TypeScript
import { test, expect } from '@playwright/test'

test('visual regression', async ({ page }) => {
  await page.goto('/dashboard')

  // Full page screenshot
  await expect(page).toHaveScreenshot('dashboard.png')

  // Element screenshot
  const chart = page.getByTestId('revenue-chart')
  await expect(chart).toHaveScreenshot('revenue-chart.png')

  // With options
  await expect(page).toHaveScreenshot('dashboard-full.png', {
    fullPage: true,
    maxDiffPixels: 100,
    threshold: 0.2,
  })
})
Code
Bash
# Update baseline screenshots
npx playwright test --update-snapshots

Mocking and intercepting

Code
TypeScript
test('mock API responses', async ({ page }) => {
  // Mock a specific endpoint
  await page.route('**/api/users', async route => {
    await route.fulfill({
      status: 200,
      contentType: 'application/json',
      body: JSON.stringify([
        { id: 1, name: 'Mock User' }
      ])
    })
  })

  await page.goto('/users')
  await expect(page.getByText('Mock User')).toBeVisible()
})

test('modify response', async ({ page }) => {
  await page.route('**/api/products', async route => {
    const response = await route.fetch()
    const json = await response.json()

    // Modify response
    json.products = json.products.slice(0, 3)

    await route.fulfill({ response, json })
  })

  await page.goto('/products')
})

test('abort requests', async ({ page }) => {
  // Block images
  await page.route('**/*.{png,jpg,jpeg}', route => route.abort())

  // Block analytics
  await page.route('**/analytics/**', route => route.abort())

  await page.goto('/page')
})

test('delay response', async ({ page }) => {
  await page.route('**/api/slow', async route => {
    await new Promise(resolve => setTimeout(resolve, 3000))
    await route.continue()
  })

  await page.goto('/page')
  // Test loading state
  await expect(page.getByText('Loading...')).toBeVisible()
})

Fixtures (custom setup)

TSfixtures.ts
TypeScript
// fixtures.ts
import { test as base, expect } from '@playwright/test'

// Extend base test with custom fixtures
export const test = base.extend<{
  adminPage: Page
  userPage: Page
  todoApp: TodoApp
}>({
  // Admin page fixture
  adminPage: async ({ browser }, use) => {
    const context = await browser.newContext({
      storageState: 'playwright/.auth/admin.json'
    })
    const page = await context.newPage()
    await use(page)
    await context.close()
  },

  // User page fixture
  userPage: async ({ browser }, use) => {
    const context = await browser.newContext({
      storageState: 'playwright/.auth/user.json'
    })
    const page = await context.newPage()
    await use(page)
    await context.close()
  },

  // Page Object fixture
  todoApp: async ({ page }, use) => {
    const todoApp = new TodoApp(page)
    await todoApp.goto()
    await use(todoApp)
  },
})

export { expect }

// Page Object Model
class TodoApp {
  constructor(private page: Page) {}

  async goto() {
    await this.page.goto('/todos')
  }

  async addTodo(text: string) {
    await this.page.getByPlaceholder('What needs to be done?').fill(text)
    await this.page.keyboard.press('Enter')
  }

  async toggleTodo(text: string) {
    await this.page.getByRole('listitem')
      .filter({ hasText: text })
      .getByRole('checkbox')
      .click()
  }

  async deleteTodo(text: string) {
    await this.page.getByRole('listitem')
      .filter({ hasText: text })
      .hover()
    await this.page.getByRole('button', { name: 'Delete' }).click()
  }

  async getTodoCount() {
    return await this.page.getByRole('listitem').count()
  }
}
TStests/todo.spec.ts
TypeScript
// tests/todo.spec.ts
import { test, expect } from '../fixtures'

test('can add and complete todos', async ({ todoApp }) => {
  await todoApp.addTodo('Buy groceries')
  await todoApp.addTodo('Do laundry')

  expect(await todoApp.getTodoCount()).toBe(2)

  await todoApp.toggleTodo('Buy groceries')
  await todoApp.deleteTodo('Do laundry')

  expect(await todoApp.getTodoCount()).toBe(1)
})

Running tests

Basic commands

Code
Bash
# Run all tests
npx playwright test

# Specific file
npx playwright test tests/login.spec.ts

# Specific test (grep)
npx playwright test -g "login works"

# Specific browser
npx playwright test --project=chromium
npx playwright test --project=firefox

# Headed mode (visible browser)
npx playwright test --headed

# Debug mode
npx playwright test --debug

# UI mode (interactive)
npx playwright test --ui

# Only failed tests
npx playwright test --last-failed

# Repeat test X times
npx playwright test --repeat-each=3

Reports

Code
Bash
# HTML report
npx playwright show-report

# Trace viewer
npx playwright show-trace trace.zip

Codegen - recording tests

Code
Bash
# Record interactions and generate code
npx playwright codegen https://example.com

# With device emulation
npx playwright codegen --device="iPhone 13"

# With viewport
npx playwright codegen --viewport-size=800,600

# Save to file
npx playwright codegen -o tests/recorded.spec.ts

CI/CD integration

GitHub Actions

.github/workflows/playwright.yml
YAML
# .github/workflows/playwright.yml
name: Playwright Tests

on:
  push:
    branches: [main, master]
  pull_request:
    branches: [main, master]

jobs:
  test:
    timeout-minutes: 60
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: 18

      - name: Install dependencies
        run: npm ci

      - name: Install Playwright Browsers
        run: npx playwright install --with-deps

      - name: Run Playwright tests
        run: npx playwright test

      - uses: actions/upload-artifact@v4
        if: always()
        with:
          name: playwright-report
          path: playwright-report/
          retention-days: 30

Sharding (parallel CI)

Code
YAML
jobs:
  test:
    strategy:
      matrix:
        shard: [1/4, 2/4, 3/4, 4/4]

    steps:
      - name: Run tests
        run: npx playwright test --shard=${{ matrix.shard }}

Best practices

Stable selectors

Code
TypeScript
// Good - semantic, stable
page.getByRole('button', { name: 'Submit' })
page.getByLabel('Email')
page.getByTestId('login-form')

// Bad - brittle, implementation dependent
page.locator('.btn-primary')
page.locator('#submit-btn')
page.locator('div > button:nth-child(2)')

Test isolation

Code
TypeScript
// Good - each test independent
test.beforeEach(async ({ page }) => {
  // Reset state before each test
  await page.goto('/')
})

// Bad - dependency between tests
// Test A should not rely on state from Test B

Test ID for difficult elements

Code
HTML
<!-- In the application -->
<button data-testid="checkout-button">Complete Purchase</button>
Code
TypeScript
// In the test
await page.getByTestId('checkout-button').click()

FAQ - Frequently asked questions

How to debug failed tests?

  1. Run with --debug: npx playwright test --debug
  2. Use Trace Viewer to analyze trace.zip
  3. Add await page.pause() in the test code
  4. Use UI mode: npx playwright test --ui

How to test responsiveness?

Code
TypeScript
test('mobile layout', async ({ page }) => {
  await page.setViewportSize({ width: 375, height: 667 })
  await page.goto('/')
  await expect(page.getByRole('button', { name: 'Menu' })).toBeVisible()
})

How to handle a popup/dialog?

Code
TypeScript
page.on('dialog', dialog => dialog.accept())
await page.getByRole('button', { name: 'Delete' }).click()

How to test a new window/tab?

Code
TypeScript
const [newPage] = await Promise.all([
  page.waitForEvent('popup'),
  page.getByRole('link', { name: 'Open in new tab' }).click()
])
await expect(newPage).toHaveURL('/new-page')

Flaky tests, the biggest enemy

Auto waiting removes the most common causes of random failures, but not all of them. The remaining ones are worth knowing, because they destroy a team's trust in the suite, and without trust nobody reads those tests.

The first is shared state. Tests run in parallel, so two of them operating on the same database record will break each other in an order dependent way. The fix is for each test to create its own data and clean up after itself rather than relying on what a previous one left behind.

The second is dependence on wall clock time. A test checking that a notification disappears after five seconds will pass on a fast machine and fail on a loaded build server. Rather than waiting for time to elapse, control the browser's clock or assert on the outcome rather than the moment.

The third is external services. A test calling a real payment API will fail on the day that service has an outage, rightly so, but that was not what the test was about. Intercepting requests and returning a fixed response settles it.

The fourth is animations. An element mid transition can be technically visible and not yet visually in place, which particularly breaks screenshot comparisons. Disabling animations during tests is standard practice here.

Once a test starts flaking, do not disable it and do not add retries hoping it passes on the third attempt. Retries make sense as a safety net rather than as a way of hiding a problem, since a test passing one time in three guarantees nothing.

Cost, and when not to write browser tests

End to end tests are the most expensive rung of the testing pyramid, and that is worth accounting for when planning rather than writing them reflexively for everything.

They cost execution time. Launching a real browser, loading the application, and walking through the interface takes seconds, while a unit test finishes in milliseconds. A suite of five hundred such tests can stretch a pipeline to a quarter of an hour, and that is time the team spends waiting.

They also cost maintenance. Every interface change potentially breaks a test even when the logic works correctly. The more tests assert on presentation details, the more often you fix tests instead of writing code.

A practical rule that holds up: use browser tests for the paths you earn on or whose breakage would hurt most. Signup, login, placing an order, payment. Everything else, form validation, data formatting, conditional logic, gets checked more cheaply and faster at a lower level, in TypeScript without launching a browser.

Twenty well chosen end to end tests give more confidence than two hundred checking everything in sequence, because somebody will fix those twenty when they start failing, and nobody will maintain two hundred.

Documentation and the full API are on the project site, and the source lives in the GitHub repository.