Browser Tests With Playwright
End-to-end tests that are not a liability: auto-waiting instead of sleeps, one user journey per test, tracing a failure, and running the same spec across browsers.
End-to-end tests that are not a liability: auto-waiting instead of sleeps, one user journey per test, tracing a failure, and running the same spec across browsers.
Browser tests have a bad reputation, and it is earned — but by a generation of tools rather than by the idea. Suites built on explicit sleeps and brittle selectors were slow, flaky and abandoned, and a lot of teams concluded that end-to-end testing does not work.
Modern tools removed most of the causes. By the end of this lesson you will
have a working Playwright test, you will know why sleep never appears in
one, and you will know how to diagnose a failure from a recorded trace
instead of by re-running it and hoping.
It is the only level that exercises the product as a user meets it: real browser, real front-end build, real requests, real back end.
That means it is the only place four categories of defect are visible: a front end that did not build, a misconfigured API URL, a button covered by a cookie banner, and a login flow that breaks on a redirect. Every one of those passes an API test comfortably.
It is also the most expensive level, so the rule from the foundations course applies with force: few, and about journeys. Five to fifteen tests covering the paths that must work, kept genuinely reliable, is worth more than two hundred that fail twice a week.
npm init playwright@latestThat installs Playwright, downloads the browser engines, and writes a
config and an example test. The Python version is
pip install playwright && playwright install.
import { test, expect } from '@playwright/test';
test('a signed-in user can create a contact', async ({ page }) => {
await page.goto('/contacts');
await page.getByRole('button', { name: 'New contact' }).click();
await page.getByLabel('Name').fill('Ada Lovelace');
await page.getByLabel('Email').fill('ada@example.com');
await page.getByRole('button', { name: 'Save' }).click();
await expect(page.getByRole('row', { name: /Ada Lovelace/ })).toBeVisible();
});npx playwright test # headless, all browsers
npx playwright test --headed # watch it happen
npx playwright test --ui # the interactive runner
npx playwright test --project=chromium # one engine
npx playwright show-report # the HTML report--ui is worth opening the first time: it shows each step, the DOM at that
moment, and the network activity, side by side. It is the fastest way to
understand what a test is doing.
The single largest source of flakiness in older suites was timing. The test clicked before the button existed, so people added sleeps — which are either too short, and flaky, or too long, and slow. Usually both, in different places.
Playwright removes the problem at the source. Before acting on an element it waits until that element is attached to the DOM, visible, stable, able to receive events, and enabled. Then it acts. If any condition is unmet it retries until the timeout.
Assertions retry too. expect(locator).toBeVisible() polls until it is
true or the timeout expires.
Bad — a fixed wait, which is a guess about someone else's machine:
await page.getByRole('button', { name: 'Save' }).click();
await page.waitForTimeout(2000);
expect(await page.locator('.row').count()).toBe(1);Good — wait for the condition, not for a duration:
await page.getByRole('button', { name: 'Save' }).click();
await expect(page.getByRole('row', { name: /Ada Lovelace/ })).toBeVisible();A guess about someone else's machine.
Slow when the app is fast — two seconds, every run — and flaky when the app is slow, which is exactly what CI is.
It also pairs with count(), which reads the DOM once with
no retry, so it fails if the row arrives a millisecond later.
Waits for the condition, not a duration.
Proceeds the instant the row appears, and waits as long as needed when the environment is loaded.
The other historical cause of flakiness was selectors —
div.container > ul:nth-child(3) > li.item breaking on every redesign.
Playwright's locators are built around how a person identifies an element, in a rough order of preference:
page.getByRole('button', { name: 'Save' }); // best: role + name
page.getByLabel('Email'); // form fields
page.getByPlaceholder('Search contacts');
page.getByText('No contacts yet');
page.getByTestId('contact-row'); // when nothing else fits
page.locator('.contact-row'); // last resort: CSSgetByRole first is not a style preference. Role and accessible name are
what assistive technology uses, so a test written this way fails when the
page becomes unusable with a screen reader — the button lost its label, the
heading is not a heading. You get a small accessibility check for free on
every journey.
Locators are lazy: creating one queries nothing. It is resolved when you act or assert, which is what makes auto-waiting possible. And they chain, which is how you scope to a region:
const row = page.getByRole('row', { name: /Ada Lovelace/ });
await row.getByRole('button', { name: 'Delete' }).click();That is much more robust than finding the third delete button on the page, and it says what it means.
If every test signs in through the form, most of your suite's runtime is the login page — and if login breaks, every test fails and none of them tells you why.
Do it once, save the session, reuse it:
// auth.setup.ts
import { test as setup, expect } from '@playwright/test';
const file = 'playwright/.auth/admin.json';
setup('authenticate as an admin', async ({ page }) => {
await page.goto('/login');
await page.getByLabel('Email').fill(process.env.TEST_EMAIL!);
await page.getByLabel('Password').fill(process.env.TEST_PASSWORD!);
await page.getByRole('button', { name: 'Sign in' }).click();
await expect(page.getByRole('heading', { name: 'Contacts' })).toBeVisible();
await page.context().storageState({ path: file });
});// playwright.config.ts
projects: [
{ name: 'setup', testMatch: /auth\.setup\.ts/ },
{
name: 'chromium',
use: {
...devices['Desktop Chrome'],
storageState: 'playwright/.auth/admin.json',
},
dependencies: ['setup'],
},
];Every test now starts signed in. Keep one test that goes through the real login form, because that journey matters and this arrangement would otherwise never exercise it.
The same reasoning applies to data: create what a test needs through the API rather than by clicking. Setting up a contact via twelve UI interactions tests the creation flow twelve times and makes the test about something else fragile.
test('a contact can be deleted', async ({ page, request }) => {
const created = await request.post('/api/contacts', {
data: { name: 'Ada Lovelace', email: 'ada@example.com' },
});
const { id } = await created.json();
await page.goto(`/contacts/${id}`);
await page.getByRole('button', { name: 'Delete' }).click();
await page.getByRole('button', { name: 'Confirm' }).click();
await expect(page.getByText('Contact deleted')).toBeVisible();
});Set up through the API, exercise through the UI, and assert through whichever is clearer. That is the pattern that keeps browser tests fast and focused.
The old complaint about browser tests was that a red result told you nothing. Traces changed that.
// playwright.config.ts
use: {
trace: 'on-first-retry',
screenshot: 'only-on-failure',
video: 'retain-on-failure',
}npx playwright show-trace test-results/.../trace.zipA trace is a recording: every step, a DOM snapshot before and after each action, the network requests, the console output, and a screencast. You can scrub to the failing step and inspect the page as it was — including hovering the elements the locator was looking at.
on-first-retry is the setting to use. Tracing costs a little runtime, so
recording only when a test has already failed once gives you the artefact
exactly when you need it.
For a failure you can reproduce locally:
npx playwright test --debug # step through in the inspector
npx playwright test --headed --slow-mo=500export default defineConfig({
testDir: './e2e',
fullyParallel: true,
forbidOnly: !!process.env.CI, // no test.only reaches CI
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 4 : undefined,
reporter: [['html'], ['list']],
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: 'webkit', use: { ...devices['Desktop Safari'] } },
{ name: 'mobile', use: { ...devices['iPhone 14'] } },
],
webServer: {
command: 'npm run start',
url: 'http://localhost:3000',
reuseExistingServer: !process.env.CI,
},
});Three of those deserve a note.
forbidOnly in CI stops a test.only left in a commit from silently
skipping the rest of the suite — a failure that reads as a passing build.
retries: 2 in CI is pragmatic and dangerous. It papers over flakiness,
and flakiness is a defect. Use it to keep the pipeline usable, and treat
any test that needed a retry as something to fix, not something that
passed. The next lesson is entirely about that.
webServer starts the application for you and waits for it, so
npx playwright test works from a clean checkout with no separate
instructions.
npm init playwright@latest # install + browsers + config
npx playwright test # headless, all projects
npx playwright test --ui # interactive runner
npx playwright test --headed --slow-mo=500
npx playwright test --debug # step through
npx playwright test --project=chromium
npx playwright show-report
npx playwright show-trace test-results/.../trace.zip
npx playwright codegen http://localhost:3000 # record a draft// Locators, in order of preference
page.getByRole('button', { name: 'Save' }); // role + accessible name
page.getByLabel('Email');
page.getByPlaceholder('Search');
page.getByText('No contacts yet');
page.getByTestId('contact-row'); // ask for these
page.locator('.row'); // last resort
// Scope to a region rather than indexing
const row = page.getByRole('row', { name: /Ada Lovelace/ });
await row.getByRole('button', { name: 'Delete' }).click();
// Wait for conditions, never for durations
await expect(locator).toBeVisible(); // retries until true
await expect(locator).toHaveText('Saved');
await expect(page).toHaveURL(/\/contacts\/\d+/);
// await page.waitForTimeout(2000) <- never
// expect(await locator.count()) <- reads once, no retry# Patterns that keep the suite fast and few
sign in ONCE, save storageState, reuse it
...and keep one test that uses the real login form
set up data through the API, exercise through the UI
few tests, about journeys: 5-15, kept genuinely reliable
# Diagnosing
trace: 'on-first-retry' a full recording when it matters
screenshot: 'only-on-failure'
video: 'retain-on-failure'
# Config worth setting
forbidOnly: !!process.env.CI a stray test.only cannot reach CI
retries: CI ? 2 : 0 keeps the pipeline usable — but a
test that needed a retry is a DEFECT
webServer starts the app and waits for it
fullyParallel: true requires independent tests
# Why getByRole first
# role + accessible name is what a screen reader uses, so the
# test fails when the page becomes unusable — a free
# accessibility check on every journeyThe next lesson goes deeper into the locator question — page objects, test
ids as a deliberate contract, and how to structure a browser suite so a
redesign does not invalidate all of it. Then flaky tests, which is where
the retries: 2 above gets paid off honestly.
Before that, write one Playwright test for the most important journey in something you work on: set the data up through the API, drive the UI with role-based locators, and assert on what the user would see. One reliable journey test is worth more than the twenty you were tempted to write.