Your First Playwright Test
Install Playwright, write an end-to-end test against a real page, and read the runner's output — including what a failing assertion actually tells you.
A Playwright test drives a real browser and asserts on what the page shows. The runner starts the browser, runs your file, and tears everything down — you write the middle part.
Setting up
npm init playwright@latest
Getting started with writing end-to-end tests with Playwright:
Initializing project in '.'
✔ Do you want to use TypeScript or JavaScript? · TypeScript
✔ Where to put your end-to-end tests? · tests
✔ Add a GitHub Actions workflow? (y/N) · false
✔ Install Playwright browsers (can be done manually via 'npx playwright install')? (Y/n) · true
Installing Playwright Test (npm install --save-dev @playwright/test)…
Downloading Chromium 133.0.6943.16 (playwright build v1155) - 128.5 MiB
Downloading Firefox 134.0 (playwright build v1466) - 84.2 MiB
Downloading Webkit 18.2 (playwright build v2140) - 71.9 MiB
✔ Success! Created a Playwright Test project at /home/you/demo
That wrote playwright.config.ts and a tests/ directory, and downloaded three browsers.
The browsers live outside node_modules in a shared cache, which is why the first install
is slow and the next project is instant.
The first test
We will test demo.playwright.dev/todomvc, a public page that stays up, so every example here is one you can actually run.
// tests/todo.spec.ts
import { test, expect } from '@playwright/test';
test('page loads with an empty list', async ({ page }) => {
await page.goto('https://demo.playwright.dev/todomvc');
await expect(page).toHaveTitle(/TodoMVC/);
await expect(page.getByTestId('todo-title')).toHaveCount(0);
});
npx playwright test tests/todo.spec.ts --project=chromium
Running 1 test using 1 worker
✓ 1 [chromium] › tests/todo.spec.ts:4:1 › page loads with an empty list (964ms)
1 passed (1.7s)
Three things happened that you did not write. Playwright launched Chromium, created a
fresh browser context with its own cookies and storage, and closed both afterwards. The
{ page } in the test signature is a fixture — ask for it and the runner provides it.
Every page method returns a promise, so every line needs await. Forgetting one is the
most common beginner bug, and it usually shows up as a test that passes when it should not.
Adding a todo
test('adds a todo item', async ({ page }) => {
await page.goto('https://demo.playwright.dev/todomvc');
const input = page.getByPlaceholder('What needs to be done?');
await input.fill('buy milk');
await input.press('Enter');
await expect(page.getByTestId('todo-title')).toHaveText(['buy milk']);
});
Running 1 test using 1 worker
✓ 1 [chromium] › tests/todo.spec.ts:11:1 › adds a todo item (1.2s)
1 passed (2.0s)
No sleeps, no polling. expect(...).toHaveText() retries until the list matches or the
timeout expires — that retry loop is the single most important thing Playwright does for
you, and lesson 3 pulls it apart.
Reading a failure
Change the expected text to something the app never renders:
await expect(page.getByTestId('todo-title')).toHaveText(['buy bread']);
Running 1 test using 1 worker
✘ 1 [chromium] › tests/todo.spec.ts:11:1 › adds a todo item (5.5s)
1) [chromium] › tests/todo.spec.ts:11:1 › adds a todo item ──────────────────────
Error: Timed out 5000ms waiting for expect(locator).toHaveText(expected)
Locator: getByTestId('todo-title')
Expected string: "buy bread"
Received string: "buy milk"
Call log:
- expect.toHaveText with timeout 5000ms
- waiting for getByTestId('todo-title')
- locator resolved to <span data-testid="todo-title">buy milk</span>
- unexpected value "buy milk"
16 |
> 17 | await expect(page.getByTestId('todo-title')).toHaveText(['buy bread']);
| ^
18 | });
at /home/you/demo/tests/todo.spec.ts:17:48
attachment #1: screenshot (image/png) ─────────────────────────────────────────
test-results/todo-adds-a-todo-item-chromium/test-failed-1.png
───────────────────────────────────────────────────────────────────────────────
1 failed
[chromium] › tests/todo.spec.ts:11:1 › adds a todo item ──────────────────────
The call log is the part worth learning to read. It shows the assertion retrying, the element it resolved to, and the value it rejected. A failure that says “locator resolved to …, unexpected value” is a real mismatch; one that says “waiting for locator” and nothing more means the element never appeared at all — a different bug entirely.
Note the 5.5s. A failing assertion spends its full timeout retrying before giving up: passing tests are fast, failing ones are slow.
Watching it happen
npx playwright test --headed --project=chromium
The browser window opens and you see the typing. Add --debug and it pauses on the first
line with the Playwright Inspector attached, so you can step through:
npx playwright test tests/todo.spec.ts --debug
For day-to-day work, UI mode beats both:
npx playwright test --ui
It gives a watch-mode runner with a timeline, a DOM snapshot per step, and time travel back through the run. Lesson 8 covers it alongside traces.
The HTML report
Every run writes one:
npx playwright show-report
Serving HTML report at http://localhost:9323. Press Ctrl+C to quit.
On a failure the report carries the screenshot, the error, and — once you enable it — the trace. On CI this is the artifact to upload; it turns “the pipeline is red” into a click-through of what the browser saw.
Running across browsers
The generated config declares three projects:
// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './tests',
projects: [
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
{ name: 'firefox', use: { ...devices['Desktop Firefox'] } },
{ name: 'webkit', use: { ...devices['Desktop Safari'] } },
],
});
Drop --project and every test runs three times, once per browser:
Running 6 tests using 5 workers
✓ 1 [chromium] › tests/todo.spec.ts:4:1 › page loads with an empty list (1.0s)
✓ 2 [firefox] › tests/todo.spec.ts:4:1 › page loads with an empty list (1.4s)
✓ 3 [webkit] › tests/todo.spec.ts:4:1 › page loads with an empty list (1.3s)
✓ 4 [chromium] › tests/todo.spec.ts:11:1 › adds a todo item (1.2s)
✓ 5 [firefox] › tests/todo.spec.ts:11:1 › adds a todo item (1.6s)
✓ 6 [webkit] › tests/todo.spec.ts:11:1 › adds a todo item (1.5s)
6 passed (4.4s)
Six tests in 4.4 seconds because five workers ran in parallel. Lesson 9 covers what that parallelism costs you and how to control it.
Practice
1. Write a test that adds two todos and asserts the counter reads "2 items left".
test('counter tracks the number of items', async ({ page }) => {
await page.goto('https://demo.playwright.dev/todomvc');
const input = page.getByPlaceholder('What needs to be done?');
for (const item of ['buy milk', 'walk the dog']) {
await input.fill(item);
await input.press('Enter');
}
await expect(page.getByTestId('todo-count')).toHaveText('2 items left');
});
✓ 1 [chromium] › tests/todo.spec.ts:20:1 › counter tracks the number of items (1.3s)
1 passed (2.1s)
fill replaces the input’s value rather than appending to it, so the loop needs no
clearing step.
2. Remove an await from one of the actions and run the test.
input.fill('buy milk'); // no await
await input.press('Enter');
✘ 1 [chromium] › tests/todo.spec.ts:11:1 › adds a todo item (5.4s)
Error: Timed out 5000ms waiting for expect(locator).toHaveText(expected)
Expected string: "buy milk"
Received string: ""
press fired before fill finished, so an empty todo was submitted. TypeScript will not
catch this — a floating promise is legal. Turn on the no-floating-promises ESLint rule
for your test directory; it is the only reliable defence.
3. Run only the tests whose title contains "adds".
npx playwright test -g "adds"
Running 3 tests using 3 workers
✓ 1 [chromium] › tests/todo.spec.ts:11:1 › adds a todo item (1.2s)
✓ 2 [firefox] › tests/todo.spec.ts:11:1 › adds a todo item (1.6s)
✓ 3 [webkit] › tests/todo.spec.ts:11:1 › adds a todo item (1.4s)
3 passed (2.8s)
-g matches the test title; combine it with --project to narrow to one browser. To run a
single test by position, pass file:line — npx playwright test tests/todo.spec.ts:11.
4. Make a test fail, then find the screenshot on disk.
ls test-results/todo-adds-a-todo-item-chromium/
test-failed-1.png
Screenshots are written on failure by default (screenshot: 'only-on-failure'). The
directory is cleared at the start of every run, so copy anything you want to keep — or
upload playwright-report/ as a CI artifact instead.
Next: locators — how to name an element so the test survives a redesign.