Skip to content
FLAVIO COPES
flaviocopes.com
2026

Playwright tutorial: end-to-end testing from scratch

By

Learn Playwright end-to-end testing from scratch: install it, write resilient tests, handle login, mock APIs, debug failures, and run everything in CI.

~~~

Playwright lets you test a web app using a real browser.

It can open a page, fill a form, click a button, and check what happened. Your test follows the same path a person would follow.

This is called end-to-end testing, or E2E testing.

A unit test checks one function in isolation. An E2E test checks whether a complete user flow works.

For example, an E2E test can verify that a person can:

That flow crosses your frontend, backend, database, and browser code. This is why E2E tests catch problems that unit tests cannot see.

Playwright gives us the browser automation library, a test runner, assertions, reports, debugging tools, and support for Chromium, Firefox, and WebKit.

Let’s set it up and write a real test suite.

Install Playwright

Run the official initializer inside your project:

npm init playwright@latest

The initializer asks a few questions:

I use TypeScript for tests, even when the app uses JavaScript. The editor can then catch misspelled methods and invalid options before I run the suite.

Playwright creates a structure similar to this:

playwright.config.ts
tests/
  example.spec.ts

It also adds @playwright/test to your development dependencies.

The playwright package is the browser automation library. The @playwright/test package adds the test runner, assertions, fixtures, retries, projects, and reports.

For most application testing, use @playwright/test.

Write your first test

A Playwright test has three parts:

  1. Open a page
  2. Perform an action
  3. Check the result

Create tests/homepage.spec.ts:

import { test, expect } from '@playwright/test'

test('homepage shows the site title', async ({ page }) => {
  await page.goto('https://flaviocopes.com/')

  await expect(
    page.getByRole('heading', { level: 1 })
  ).toBeVisible()

  await expect(page).toHaveTitle(/Flavio Copes/)
})

The test() function defines one test.

Playwright passes a page object to the callback. The page represents one browser tab.

page.goto() opens the URL. getByRole() finds the heading. expect() checks that the heading is visible and the page title contains the expected text.

Notice that every browser operation uses await. Browser actions are asynchronous.

Run the test:

npx playwright test

Playwright runs tests headless by default. The browser opens without a visible window.

Add --headed when you want to see it:

npx playwright test --headed

Seeing the browser is useful at first. Headless mode is faster and works well in CI.

Run one test

As your test suite grows, you rarely want to run everything while editing one file.

Run one file:

npx playwright test tests/homepage.spec.ts

Run tests whose title contains a word:

npx playwright test -g "homepage"

Run one browser project:

npx playwright test --project=chromium

You can also temporarily focus one test:

test.only('homepage shows the site title', async ({ page }) => {
  await page.goto('/')
})

Be careful with test.only(). A forgotten .only can make CI run one test and skip the rest.

Later we’ll configure CI to reject it.

Configure your app URL

Hardcoding the full URL in every test gets repetitive.

Set a baseURL in playwright.config.ts:

import { defineConfig } from '@playwright/test'

export default defineConfig({
  use: {
    baseURL: 'http://localhost:3000',
  },
})

Now your tests can use relative URLs:

await page.goto('/')
await page.goto('/login')
await page.goto('/products')

Playwright combines each path with the base URL.

You can also let Playwright start your app before the tests run:

import { defineConfig } from '@playwright/test'

export default defineConfig({
  use: {
    baseURL: 'http://localhost:3000',
  },
  webServer: {
    command: 'npm run dev',
    url: 'http://localhost:3000',
    reuseExistingServer: !process.env.CI,
  },
})

Playwright runs npm run dev and waits until the URL responds.

If your dev server is already running locally, reuseExistingServer uses it. In CI, Playwright starts a clean server.

This removes a manual step. You can run the tests with one command.

Find elements with locators

A locator describes how Playwright should find an element.

Good locators make tests readable and stable. Weak locators make tests break whenever the HTML changes.

My advice is to find elements the way a person or screen reader finds them.

For a button, use its role and name:

page.getByRole('button', { name: 'Create account' })

For a form field, use its label:

page.getByLabel('Email')

For visible content, use its text:

page.getByText('Your order is confirmed')

Playwright provides several useful locator methods:

LocatorGood for
getByRole()Buttons, links, headings, and other accessible elements
getByLabel()Form fields with labels
getByText()Visible text
getByPlaceholder()Inputs identified by placeholder text
getByAltText()Images with alternative text
getByTestId()Elements with a stable test ID

Suppose your page contains this form:

<label for="email">Email</label>
<input id="email" type="email">

<button>Create account</button>

You can interact with it like this:

await page.getByLabel('Email').fill('flavio@playwright.test')
await page.getByRole('button', { name: 'Create account' }).click()

The test reads like a description of what the user does.

Avoid fragile CSS selectors

You can use CSS selectors:

page.locator('.signup-form > div:nth-child(2) > button')

But this selector knows too much about the HTML structure.

Add a wrapper or move the button and the test breaks. The user flow still works, but the test fails.

This is better:

page.getByRole('button', { name: 'Create account' })

Sometimes an element has no useful role, label, or text. Add a test ID:

<div data-testid="cart-total">$42.00</div>

Then locate it:

page.getByTestId('cart-total')

I use test IDs as an explicit contract when user-facing locators are not enough.

Do not use them for every element. Accessible locators also test whether your interface exposes useful names and roles.

Understand strict locators

Playwright expects a locator used for an action to match one element.

This locator is ambiguous if the page has two “Delete” buttons:

page.getByRole('button', { name: 'Delete' })

Playwright fails instead of guessing.

Narrow the locator to the product you want:

const product = page
  .getByRole('listitem')
  .filter({ hasText: 'JavaScript Handbook' })

await product.getByRole('button', { name: 'Delete' }).click()

This tells Playwright to find the list item containing the book title, then click its Delete button.

You could use .first() or .nth(1), but that depends on position. Prefer a locator that explains which item you want.

Fill forms and click buttons

Most tests use a small set of actions.

Fill a text field:

await page.getByLabel('Name').fill('Flavio Copes')

Select an option:

await page.getByLabel('Country').selectOption('IT')

Check a checkbox:

await page.getByLabel('Accept the terms').check()

Upload a file:

await page
  .getByLabel('Profile photo')
  .setInputFiles('tests/files/avatar.png')

Press a key:

await page.getByLabel('Search').press('Enter')

Hover over an element:

await page.getByRole('button', { name: 'Account' }).hover()

Playwright performs checks before each action.

Before clicking, it waits for the element to exist, become visible, stop moving, receive pointer events, and become enabled.

This behavior is called auto-waiting.

Let Playwright wait

Web pages change asynchronously.

A click might start a request. A message might appear 300 milliseconds later. A button might become enabled after validation finishes.

The tempting fix is a manual sleep:

await page.waitForTimeout(1000)

Avoid this in tests.

If the page responds in 100 milliseconds, the test wastes 900. If CI responds in 1200 milliseconds, the test still fails.

Wait for the result you care about:

await page.getByRole('button', { name: 'Save' }).click()

await expect(page.getByText('Changes saved')).toBeVisible()

Playwright retries the assertion until the message appears or the assertion times out.

This is faster and more reliable than guessing a delay.

My rule is simple: wait for a state, not for a number of milliseconds.

Use web-first assertions

Playwright includes assertions made for web pages.

Check that an element is visible:

await expect(page.getByText('Welcome back')).toBeVisible()

Check its exact text:

await expect(page.getByTestId('cart-total')).toHaveText('$42.00')

Check a list length:

await expect(page.getByRole('listitem')).toHaveCount(3)

Check an input value:

await expect(page.getByLabel('Email')).toHaveValue('flavio@playwright.test')

Check the current URL:

await expect(page).toHaveURL('/dashboard')

Check the page title:

await expect(page).toHaveTitle(/Dashboard/)

These assertions retry. Always await them.

Compare this:

expect(await page.getByTestId('status').textContent()).toBe('Ready')

with this:

await expect(page.getByTestId('status')).toHaveText('Ready')

The first version reads the value once. It can run too early.

The second version keeps checking until the status becomes Ready.

Use Playwright’s web-first assertions whenever one exists.

Write a complete user-flow test

Let’s put locators, actions, and assertions together.

Imagine a small online bookstore. A person can add a book to the cart and start checkout.

The test looks like this:

import { test, expect } from '@playwright/test'

test('add a book to the cart', async ({ page }) => {
  await page.goto('/books')

  const book = page
    .getByRole('article')
    .filter({ hasText: 'The JavaScript Handbook' })

  await book.getByRole('button', { name: 'Add to cart' }).click()

  await expect(page.getByTestId('cart-count')).toHaveText('1')

  await page.getByRole('link', { name: 'Cart' }).click()

  await expect(page).toHaveURL('/cart')
  await expect(
    page.getByRole('heading', { name: 'Your cart' })
  ).toBeVisible()
  await expect(page.getByText('The JavaScript Handbook')).toBeVisible()
})

This test does not know which JavaScript framework powers the page.

It only knows what the user sees and does. You can rewrite the frontend and keep the same test if the behavior stays the same.

That is one of the main benefits of E2E testing.

Group tests and share setup

Tests for the same area often start on the same page.

Use test.describe() to group them and test.beforeEach() for repeated setup:

import { test, expect } from '@playwright/test'

test.describe('cart', () => {
  test.beforeEach(async ({ page }) => {
    await page.goto('/cart')
  })

  test('starts empty', async ({ page }) => {
    await expect(page.getByText('Your cart is empty')).toBeVisible()
  })

  test('has a checkout button', async ({ page }) => {
    await expect(
      page.getByRole('button', { name: 'Checkout' })
    ).toBeVisible()
  })
})

The hook runs before every test.

Do not use one test to prepare data for the next test. Each test should work alone.

Every test gets a clean browser context

Playwright creates a new browser context for every test.

A browser context is like a fresh browser profile. It has its own cookies, local storage, session, and pages.

One test cannot leak login cookies into another test.

This isolation matters when tests run in parallel. It also means you can run one test without first running five other tests.

The browser process can stay open because contexts are cheap. You get isolation without launching a complete browser for every test.

If two tests affect the same server-side data, browser isolation cannot help. Give each test unique data, or create separate test accounts.

For example:

test('create an account', async ({ page }) => {
  const email = `flavio+${Date.now()}@playwright.test`

  await page.goto('/signup')
  await page.getByLabel('Email').fill(email)
  await page.getByLabel('Password').fill('test-password-123')
  await page.getByRole('button', { name: 'Create account' }).click()

  await expect(page).toHaveURL('/dashboard')
})

The unique email stops parallel test runs from fighting over one account.

Test several browsers

Playwright supports Chromium, Firefox, and WebKit.

Configure them as projects:

import { defineConfig, devices } from '@playwright/test'

export default defineConfig({
  projects: [
    {
      name: 'chromium',
      use: { ...devices['Desktop Chrome'] },
    },
    {
      name: 'firefox',
      use: { ...devices['Desktop Firefox'] },
    },
    {
      name: 'webkit',
      use: { ...devices['Desktop Safari'] },
    },
  ],
})

Now npx playwright test runs the suite in all three projects.

During development, run Chromium for quick feedback:

npx playwright test --project=chromium

Let CI run the broader browser matrix.

WebKit is not Safari, but it uses the same browser engine. It catches many Safari-specific rendering and behavior problems without requiring a Mac runner.

Test a mobile viewport

Projects can also emulate mobile devices.

Add a mobile project:

{
  name: 'mobile-chrome',
  use: {
    ...devices['Pixel 7'],
  },
}

Playwright applies the device’s viewport, user agent, touch support, and pixel ratio.

This is useful for responsive navigation, mobile forms, and layouts.

Device emulation is not a replacement for testing on a real phone. It is a fast way to catch common mobile problems in every test run.

Build a practical configuration

Here is a useful starting configuration for a real project:

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',
  use: {
    baseURL: 'http://localhost:3000',
    trace: 'on-first-retry',
    screenshot: 'only-on-failure',
  },
  projects: [
    {
      name: 'chromium',
      use: { ...devices['Desktop Chrome'] },
    },
    {
      name: 'firefox',
      use: { ...devices['Desktop Firefox'] },
    },
    {
      name: 'webkit',
      use: { ...devices['Desktop Safari'] },
    },
  ],
  webServer: {
    command: 'npm run dev',
    url: 'http://localhost:3000',
    reuseExistingServer: !process.env.CI,
  },
})

Let’s look at the less obvious options.

forbidOnly fails CI when a test contains test.only().

retries retries failed tests twice in CI. Local failures do not retry, so you see them immediately.

workers uses one worker in CI for stability. You can increase this later when the suite becomes slow.

trace records a trace on the first retry. A trace contains the steps, DOM snapshots, network activity, console messages, and screenshots.

screenshot keeps a screenshot when a test fails.

This configuration is a starting point. Add options when you have a reason, not because they exist.

Reuse an authenticated session

Logging in through the UI before every test is slow.

Playwright can log in once, save the browser state, and reuse it. The saved state contains cookies and local storage.

First, ignore the authentication file:

playwright/.auth

Add that path to .gitignore.

The file may contain sensitive cookies. Never commit it.

Create tests/auth.setup.ts:

import { test as setup, expect } from '@playwright/test'

const authFile = 'playwright/.auth/user.json'

setup('authenticate', async ({ page }) => {
  await page.goto('/login')

  await page.getByLabel('Email').fill(process.env.TEST_USER_EMAIL!)
  await page.getByLabel('Password').fill(process.env.TEST_USER_PASSWORD!)
  await page.getByRole('button', { name: 'Log in' }).click()

  await expect(page).toHaveURL('/dashboard')

  await page.context().storageState({ path: authFile })
})

Then add a setup project and make the browser project depend on it:

import { defineConfig, devices } from '@playwright/test'

export default defineConfig({
  projects: [
    {
      name: 'setup',
      testMatch: /.*\.setup\.ts/,
    },
    {
      name: 'chromium',
      use: {
        ...devices['Desktop Chrome'],
        storageState: 'playwright/.auth/user.json',
      },
      dependencies: ['setup'],
    },
  ],
})

The setup test logs in and saves the state. The Chromium tests start already authenticated.

Keep a small separate test for the login flow itself. Reusing authentication should speed up unrelated tests, not remove login coverage.

If tests change shared account data, use separate accounts. Parallel tests that edit the same profile will interfere with each other.

Mock an API response

An E2E test can use your real backend. It can also intercept a request and return controlled data.

This is useful for error states that are hard to reproduce.

For example, mock the products endpoint:

test('shows an empty products message', async ({ page }) => {
  await page.route('**/api/products', async route => {
    await route.fulfill({
      json: [],
    })
  })

  await page.goto('/products')

  await expect(page.getByText('No products found')).toBeVisible()
})

Register the route before opening the page. The page might request the data as soon as it loads.

You can also simulate a server error:

await page.route('**/api/products', async route => {
  await route.fulfill({
    status: 500,
    json: { error: 'Server unavailable' },
  })
})

Then assert that the page shows a useful error message.

Mocking makes a test deterministic, but it removes part of the real system. Keep a few tests against the real API for critical flows.

Test visual changes

Playwright can compare a page or element with a saved screenshot.

Create a visual assertion:

test('homepage layout', async ({ page }) => {
  await page.goto('/')

  await expect(page).toHaveScreenshot('homepage.png')
})

The first run reports that the reference is missing and writes it. Review that image, then add it to the repository. Later runs compare against it.

Update snapshots intentionally:

npx playwright test --update-snapshots

Visual tests are sensitive to operating systems, fonts, animations, and dynamic content.

Run them in a consistent environment. Hide timestamps, disable animations, and wait for fonts before taking the screenshot.

Use visual tests for layouts where appearance matters. Do not turn every page into a full-page screenshot test.

Generate a test with codegen

Playwright can watch your browser actions and generate test code.

Run:

npx playwright codegen http://localhost:3000

Two windows open. One contains your app. The other contains the generated code.

Click through a flow and Playwright records locators and actions.

Codegen is great for discovering locators and creating a first draft. I do not keep the output unchanged.

After recording:

A recording tells Playwright what you clicked. A good test explains what the user should be able to do.

Debug with UI Mode

UI Mode is the easiest way to develop and debug Playwright tests.

Start it:

npx playwright test --ui

You can run one test, inspect every step, see the page before and after an action, view network requests, and filter the suite.

The timeline lets you move through the test without repeating it manually.

You can also open the Playwright Inspector:

npx playwright test --debug

The inspector runs the test step by step. You can pause, resume, and inspect locators.

To debug one test:

npx playwright test tests/cart.spec.ts --debug

My workflow is:

  1. Run the failing test in UI Mode
  2. Inspect the first unexpected step
  3. Check the locator and page state
  4. Fix the cause
  5. Run the test several times

Watching the final error is often not enough. Find the first moment when the page stopped matching your expectation.

Inspect traces

A trace is a recording made for debugging.

It contains more than a video. You can inspect:

With trace: 'on-first-retry', Playwright records a trace when a test retries.

Open a local trace:

npx playwright show-trace test-results/path-to-trace.zip

Traces are especially useful for CI failures. You cannot watch the CI browser, but you can inspect what it saw.

Do not record traces for every successful test forever. They take time and storage. Recording on the first retry gives you evidence when something goes wrong.

Open the HTML report

Playwright creates an HTML report with test results.

Open it:

npx playwright show-report

The report groups results by browser and test file. It also shows retries, attachments, errors, screenshots, and traces.

A test that passes after a retry is marked as flaky.

Do not treat a flaky result as success. The retry helped you collect evidence. The test still has a problem.

Run Playwright in GitHub Actions

Playwright needs your npm packages, browser binaries, and Linux browser dependencies in CI.

Create .github/workflows/playwright.yml:

name: Playwright tests

on:
  push:
    branches: [main, master]
  pull_request:
    branches: [main, master]

jobs:
  test:
    timeout-minutes: 60
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v5

      - uses: actions/setup-node@v6
        with:
          node-version: lts/*

      - name: Install dependencies
        run: npm ci

      - name: Install Playwright browsers
        run: npx playwright install --with-deps

      - name: Run Playwright tests
        run: npx playwright test

      - name: Upload Playwright report
        if: ${{ !cancelled() }}
        uses: actions/upload-artifact@v5
        with:
          name: playwright-report
          path: playwright-report/
          retention-days: 30

The artifact keeps the HTML report when a test fails. Download it from the workflow run and open it locally.

Store login credentials as repository secrets. Pass them to the test step:

- name: Run Playwright tests
  run: npx playwright test
  env:
    TEST_USER_EMAIL: ${{ secrets.TEST_USER_EMAIL }}
    TEST_USER_PASSWORD: ${{ secrets.TEST_USER_PASSWORD }}

Never put real credentials in a test file.

Keep E2E tests reliable

E2E tests have a reputation for being flaky. Most flaky tests have a concrete cause.

Here are the rules I follow.

Test behavior you control

Your test should focus on your app.

Avoid testing third-party pages, analytics scripts, payment provider dashboards, or search engines. They can change or block automated traffic.

Mock the boundary, use a sandbox, or stop when your app redirects correctly.

Use resilient locators

Prefer roles, labels, and visible names.

Avoid long CSS paths, XPath, and selectors tied to the current DOM structure.

Wait for outcomes

Use web-first assertions.

Avoid waitForTimeout() and arbitrary sleeps.

Keep tests independent

Each test should create the data it needs.

Do not rely on test order. Do not share mutable accounts across parallel tests.

Keep each test focused

A test that checks signup, profile editing, checkout, cancellation, and logout is hard to debug.

Split it into user behaviors. Keep one complete happy path, then add focused tests for important branches.

Control dynamic data

Dates, random recommendations, ads, and live API data make assertions unstable.

Freeze, seed, or mock them when they are not the subject of the test.

Do not hide failures with retries

Retries are useful in CI because they collect traces and reveal flaky tests.

They are not a fix. If a test fails once and passes the second time, investigate it.

Decide what to test

Do not reproduce every unit test in the browser.

E2E tests are slower and more expensive. Use them for flows that would hurt if they broke.

Good E2E candidates include:

Use fast unit tests for small functions and edge cases. Vitest is a good option for JavaScript and TypeScript projects.

Use Playwright for confidence that the pieces work together.

A small suite covering the critical paths is more valuable than hundreds of tests nobody trusts.

A useful test folder structure

Start simple:

tests/
  auth.setup.ts
  homepage.spec.ts
  login.spec.ts
  cart.spec.ts
  checkout.spec.ts
  files/
    avatar.png

Do not build a large abstraction layer on day one.

When repeated actions become noisy, extract a small helper:

import type { Page } from '@playwright/test'

async function addBookToCart(page: Page, title: string) {
  const book = page
    .getByRole('article')
    .filter({ hasText: title })

  await book.getByRole('button', { name: 'Add to cart' }).click()
}

If a page has many repeated interactions, you can create a page object. But keep the assertions in the tests so the expected behavior stays visible.

Abstractions should remove repetition. They should not hide the user flow.

Update Playwright

The Playwright package and its browser binaries are versioned together.

Update the package:

npm install -D @playwright/test@latest

Then install the matching browsers:

npx playwright install --with-deps

Check the installed version:

npx playwright --version

If CI suddenly cannot launch a browser after a package update, make sure the CI browser files were updated too.

Where to go from here

Start with one critical flow.

Install Playwright, configure the local server, and write a test using role and label locators. Run it in UI Mode until it feels solid. Then add it to CI.

Do not begin by automating your entire app.

Add tests when a flow matters, when a bug escapes, or when you are afraid to change something.

Playwright is also useful outside test suites. You can automate screenshots, data collection, and even record repeatable product demos.

But testing is where its design really shines. Browser isolation, auto-waiting, web-first assertions, traces, and cross-browser projects remove much of the work that used to make E2E testing painful.

Your first test can be ten lines. That is enough to protect one important path today.

Tagged: Node.js · All topics
~~~

Related posts about node: