Nathen Watters
testing4 min read

Getting Started with Playwright for End-to-End Testing

A practical guide to setting up Playwright, writing your first tests, and integrating into CI/CD — covering everything from installation to parallel execution.

·
playwrighte2etestingautomationtypescript

Playwright has become one of the most capable tools in the end-to-end testing ecosystem. Built by Microsoft, it gives you reliable cross-browser automation, a powerful selector engine, and first-class TypeScript support — all with a test runner that handles parallelism, retries, and reporting out of the box.

This guide walks through setting up Playwright from scratch, writing tests that hold up in CI, and avoiding the common pitfalls that trip up teams new to the framework.

Why Playwright Over Cypress or Selenium?

All three tools can get the job done, but they make different trade-offs.

Selenium is the most flexible — it works with any language and any browser via WebDriver. The cost is verbosity: you write a lot of boilerplate, browser setup is manual, and built-in waiting mechanisms are limited.

Cypress is excellent for developers who want a smooth getting-started experience. It runs in-browser, which makes debugging great. The trade-offs: no native multi-tab support, limited cross-origin testing, and the architecture makes it harder to test non-browser layers.

Playwright hits the middle ground. It auto-waits on elements before interacting with them (which eliminates most sleep() hacks), supports Chrome, Firefox, and WebKit from a single API, and its test runner is production-ready with parallel workers, retry logic, and HTML reports.

Installation

npm init playwright@latest

This scaffolds a project with a sample test, playwright.config.ts, and installs the browser binaries. If you're adding Playwright to an existing project:

npm install --save-dev @playwright/test
npx playwright install

Your First Test

import { test, expect } from '@playwright/test'
 
test('homepage has correct title', async ({ page }) => {
  await page.goto('/')
  await expect(page).toHaveTitle(/My App/)
})
 
test('user can navigate to about page', async ({ page }) => {
  await page.goto('/')
  await page.getByRole('link', { name: 'About' }).click()
  await expect(page).toHaveURL('/about')
})

A few things worth noting:

  • page.goto('/') works because baseURL is set in playwright.config.ts
  • page.getByRole() uses semantic selectors — these are more resilient than CSS selectors or data-testids because they reflect what a real user sees
  • expect(page).toHaveTitle() auto-waits; you don't need waitForSelector or arbitrary sleeps

The Page Object Model

As your test suite grows, you'll want to avoid duplicating selectors and actions across tests. The Page Object Model (POM) pattern addresses this:

// pages/LoginPage.ts
import { type Page, type Locator } from '@playwright/test'
 
export class LoginPage {
  readonly emailInput: Locator
  readonly passwordInput: Locator
  readonly submitButton: Locator
 
  constructor(page: Page) {
    this.emailInput = page.getByLabel('Email')
    this.passwordInput = page.getByLabel('Password')
    this.submitButton = page.getByRole('button', { name: 'Log in' })
  }
 
  async login(email: string, password: string) {
    await this.emailInput.fill(email)
    await this.passwordInput.fill(password)
    await this.submitButton.click()
  }
}
// tests/auth.spec.ts
import { test, expect } from '@playwright/test'
import { LoginPage } from '../pages/LoginPage'
 
test('successful login redirects to dashboard', async ({ page }) => {
  const loginPage = new LoginPage(page)
  await page.goto('/login')
  await loginPage.login('user@example.com', 'password')
  await expect(page).toHaveURL('/dashboard')
})

Configuring for CI

A lean playwright.config.ts for CI:

import { defineConfig, devices } from '@playwright/test'
 
export default defineConfig({
  testDir: './tests',
  fullyParallel: true,
  forbidOnly: !!process.env.CI,
  retries: process.env.CI ? 2 : 0,
  workers: process.env.CI ? 1 : undefined,
  reporter: [['html', { open: 'never' }], ['line']],
  use: {
    baseURL: process.env.BASE_URL ?? 'http://localhost:3000',
    trace: 'on-first-retry',
    screenshot: 'only-on-failure',
  },
  projects: [
    { name: 'chromium', use: { ...devices['Desktop Chrome'] } },
    { name: 'firefox', use: { ...devices['Desktop Firefox'] } },
  ],
})

Key settings:

  • forbidOnly — prevents accidentally committing test.only() to CI
  • retries: 2 — Playwright retries flaky tests automatically in CI
  • trace: 'on-first-retry' — captures a trace on the first retry, which gives you a full timeline view of what failed

FAQ

Should I use data-testid attributes or role-based selectors?

Role-based selectors (getByRole, getByLabel, getByText) should be your default — they match what users actually see and are resilient to HTML structure changes. Reserve data-testid for elements with no semantic role, like custom UI components.

How do I test authenticated flows without repeating login in every test?

Use Playwright's storage state feature. Log in once in a globalSetup file, save the authentication state to disk, and load it in your tests. This avoids re-authenticating on every test run.

Why are my tests flaky?

The most common cause is implicit timing assumptions — waiting for a URL change but not for the page to finish rendering. Always assert on the state you need before acting: await expect(page.getByRole('button')).toBeEnabled() before .click().