Reliable end-to-end browser testing. Playwright drives Chromium, Firefox, and WebKit with one API, auto-waits for elements so your tests stop flaking, and ships a trace viewer that turns "it failed in CI" into a time-travel debugging session. You'll install it, write resilient tests with web-first locators and assertions, handle auth and network, debug with the best tooling in the space, and run it all in CI. Hands-on throughout.
The journey:
| You need | Why |
|---|---|
| Node.js 18+ | Playwright is an npm package |
| A web app to test | Local dev server or a deployed URL |
| Basic JavaScript/TypeScript | Tests are .ts/.js |
End-to-end (E2E) tests drive a real browser the way a user does — click, type, navigate — and assert the app behaves. The historical problem with E2E is flakiness: tests that pass locally and fail randomly in CI because they raced the app.
Playwright is built to kill flake:
sleep.expect(locator).toBeVisible() retries until it's true or times out.npm init playwright@latest
The initializer sets up playwright.config.ts, an e2e/ (or tests/) folder with an example, a GitHub Actions workflow, and downloads the browser binaries. Then:
npx playwright test # run all tests, headless
npx playwright test --ui # open the interactive UI mode
npx playwright show-report # open the HTML report
import { test, expect } from '@playwright/test'
test('homepage has the right title', async ({ page }) => {
await page.goto('https://playwright.dev/')
await expect(page).toHaveTitle(/Playwright/)
})
test('get started link works', async ({ page }) => {
await page.goto('https://playwright.dev/')
await page.getByRole('link', { name: 'Get started' }).click()
await expect(page.getByRole('heading', { name: 'Installation' })).toBeVisible()
})
page is a fixture Playwright provides fresh to every test. Everything is async/await.
A locator is a lazy, re-findable description of an element. Prefer locators that mirror how a user (or assistive tech) perceives the page — these survive refactors and CSS changes:
page.getByRole('button', { name: 'Submit' }) // best — accessible role + name
page.getByLabel('Email address') // form fields by their label
page.getByPlaceholder('Search…')
page.getByText('Welcome back')
page.getByTestId('checkout-total') // data-testid — an explicit contract
Avoid brittle CSS/XPath like .btn-primary > span:nth-child(2) — it breaks the moment markup shifts. Chain and filter to narrow down:
page.getByRole('listitem').filter({ hasText: 'Pro plan' }).getByRole('button', { name: 'Buy' })
💡 Priority order: role → label → placeholder → text → test id → CSS. The higher you stay, the more your test resembles a user and the less it flakes on cosmetic changes.
This is the heart of why Playwright tests are stable. Actions auto-wait for actionability, and assertions retry until they pass or time out:
// No sleeps needed — click waits for the button to be visible + enabled + stable
await page.getByRole('button', { name: 'Save' }).click()
// Web-first assertions retry automatically (default ~5s)
await expect(page.getByText('Saved!')).toBeVisible()
await expect(page.getByRole('row')).toHaveCount(10)
await expect(page.getByTestId('total')).toHaveText('$42.00')
The distinction that trips people up:
expect(locator).toBeVisible() — retries (web-first). Use for anything the page does asynchronously.expect(value).toBe(x) — does not retry. Use for plain values you already have.Never write await page.waitForTimeout(3000) to "fix" a flaky test — it's slow and still races. Assert on the condition instead.
await page.getByLabel('Email').fill('[email protected]') // fill clears + types
await page.getByLabel('Password').fill('s3cret')
await page.getByRole('checkbox', { name: 'Remember me' }).check()
await page.getByRole('combobox').selectOption('Pro')
await page.getByRole('button', { name: 'Log in' }).click()
// File upload, keyboard, hover
await page.getByLabel('Avatar').setInputFiles('avatar.png')
await page.getByRole('textbox').press('Enter')
await page.getByText('Menu').hover()
Fixtures set up and tear down what a test needs, in isolation. Beyond the built-in page, group tests and share setup with hooks:
import { test, expect } from '@playwright/test'
test.describe('Checkout', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/cart')
})
test('shows the cart total', async ({ page }) => {
await expect(page.getByTestId('total')).toHaveText('$42.00')
})
})
Write your own fixtures to share logic (e.g. a loggedInPage) across tests without copy-paste — that's how Playwright suites stay DRY as they grow.
Logging in through the UI in every test is slow. Instead, authenticate once in a setup project, save the browser storage state, and reuse it:
// auth.setup.ts
import { test as setup } from '@playwright/test'
setup('authenticate', async ({ page }) => {
await page.goto('/login')
await page.getByLabel('Email').fill(process.env.USER!)
await page.getByLabel('Password').fill(process.env.PASS!)
await page.getByRole('button', { name: 'Log in' }).click()
await page.waitForURL('/dashboard')
await page.context().storageState({ path: 'playwright/.auth/user.json' })
})
// playwright.config.ts — reuse the saved session
projects: [
{ name: 'setup', testMatch: /auth\.setup\.ts/ },
{
name: 'chromium',
use: { ...devices['Desktop Chrome'], storageState: 'playwright/.auth/user.json' },
dependencies: ['setup'],
},
]
Now every test starts already logged in — fast and reliable.
Playwright can intercept requests to stub third-party APIs, force error states, or make tests deterministic:
// Mock an API response
await page.route('**/api/products', async (route) => {
await route.fulfill({ json: [{ id: 1, name: 'Test product' }] })
})
// Assert a request was made / wait for a response
const resp = await page.waitForResponse('**/api/checkout')
expect(resp.status()).toBe(200)
Playwright also has a full API testing client (request fixture) for hitting endpoints directly — great for seeding data or testing your backend without a browser.
Three tools you'll reach for constantly:
# 1. Codegen — click through your app; it writes the locators + actions for you
npx playwright codegen https://your-app.com
# 2. UI mode — watch tests run step by step, time-travel, pick locators
npx playwright test --ui
# 3. Debug a single test with the inspector
npx playwright test example.spec.ts --debug
Trace viewer is the killer feature for CI failures. Enable traces on retry, then open the trace from a failed run to get a full timeline: DOM snapshots at every step, network, console, and the exact action that failed.
// playwright.config.ts
use: { trace: 'on-first-retry', screenshot: 'only-on-failure' }
npx playwright show-trace trace.zip
The generated workflow runs headless across browsers and uploads the report:
# .github/workflows/playwright.yml
name: Playwright Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 20 }
- run: npm ci
- run: npx playwright install --with-deps
- run: npx playwright test
- uses: actions/upload-artifact@v4
if: ${{ !cancelled() }}
with: { name: playwright-report, path: playwright-report/, retention-days: 7 }
Enable retries in CI to absorb genuine flake while you fix root causes, and shard across machines for speed:
// playwright.config.ts
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
npx playwright test --shard=1/3 # run a third of the tests per machine
waitForTimeout. Assert on a condition; let auto-waiting do its job.storageState. Log in once, not per test.| Mistake | Fix |
|---|---|
waitForTimeout(3000) to fix flake |
Use web-first assertions that retry |
| Brittle CSS/XPath selectors | getByRole / getByLabel / getByTestId |
| Logging in through UI every test | Save and reuse storageState |
| Tests depend on each other's state | Isolate — fresh context per test |
| No trace on failure | trace: 'on-first-retry' |
| Asserting on values, not locators | expect(locator).toBeVisible() retries; expect(value) doesn't |
waitForTimeout and replace it with a web-first assertion.auth.setup.ts that saves storageState and make two tests reuse it.page.route to render a deterministic list, then assert on it.trace: 'on-first-retry', force a failure, and open the trace with show-trace.You can now write E2E tests that don't flake, locate elements the way users do, handle auth and network, and debug CI failures with the trace viewer. Pair this with the GitHub Actions tutorial to gate every pull request on a green browser suite.