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.
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@latestThis 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 installYour 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 becausebaseURLis set inplaywright.config.tspage.getByRole()uses semantic selectors — these are more resilient than CSS selectors or data-testids because they reflect what a real user seesexpect(page).toHaveTitle()auto-waits; you don't needwaitForSelectoror 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 committingtest.only()to CIretries: 2— Playwright retries flaky tests automatically in CItrace: '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().