Skip to main content
Playwright beginner Lesson 1 of 10

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:linenpx 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.

Frequently Asked Questions

Do I need to install browsers separately from Playwright?
Yes. The npm package is the test runner and API; the browser binaries are downloaded by `npx playwright install`. The init script does it for you on first setup, and CI needs the same step, or the `--with-deps` variant to pull system libraries too.
Does Playwright run tests in a real browser?
It drives real Chromium, Firefox and WebKit builds, not a simulation. They run headless by default so nothing appears on screen; pass `--headed` to watch, which is useful while writing a test and wasteful in CI.
What is the difference between Playwright and Selenium?
Playwright talks to browsers over their own debugging protocols rather than through WebDriver, which is what lets auto-waiting, network interception and tracing live in the core API. Selenium has broader language support, a grid ecosystem, and a much longer history.
Why does my test pass locally but fail in CI?
Almost always timing. CI machines are slower, so a step that happened to finish before the next line ran locally does not finish in time there. The fix is to assert on state with `expect` rather than sleeping, which is what the rest of this track is about.