Playwright from Scratch — Setup and First Test

Learn why Playwright is the modern choice for E2E testing. Set up your first project, write a reliable test using auto-waiting and accessible selectors, and understand the browser/context/page mental.

This post is part of the Playwright Essentials series. In this five-part series, you’ll learn how to build reliable end-to-end tests with Playwright — from installation to advanced patterns. Start here if you’re new to Playwright or want to strengthen your foundation.


Why Playwright?

If you’re choosing an E2E testing framework in 2025, Playwright should be on your shortlist. Here’s why.

Auto-waiting by default. Playwright waits for elements to be actionable before interacting with them. No more waitForTimeout(3000) scattered through your tests hoping the page has loaded. This single feature eliminates 80% of the flakiness that plagued Selenium and early Cypress suites.

Cross-browser support that actually works. Playwright ships with Chromium, Firefox, and WebKit (Safari’s engine). One test suite, three browsers. This isn’t theoretical — it works out of the box.

Developer experience. The trace viewer is a game-changer. When a test fails, you get a full timeline: screenshots, DOM snapshots, network traffic, console logs. It’s like having a debugger that travels back in time.

API testing in the same framework. Playwright’s APIRequestContext lets you seed data, check backend state, or run pure API tests without switching tools. This matters when you’re testing a full user journey that touches both UI and API.

Fast. Playwright runs tests in parallel by default and launches browsers in milliseconds. A well-structured Playwright suite runs faster than most Selenium suites despite covering the same scenarios.

:::tip[When NOT to Use Playwright] Playwright is overkill for pure API testing (use curl, httpie, or language-native HTTP clients). It’s also not the right choice for visual regression testing at scale (use Chromatic, Percy, or Applitools for that). Playwright excels at testing user interactions through the browser. :::


Installation — The Right Way

Let’s set up a new Playwright project from scratch.

Step 1: Initialise a Node.js project

mkdir my-playwright-tests
cd my-playwright-tests
npm init -y

This creates a package.json in an empty directory.

Step 2: Install Playwright

npm install --save-dev @playwright/test
npx playwright install

The first command installs the Playwright test runner. The second downloads the browser binaries (Chromium, Firefox, WebKit). These binaries are versioned with Playwright, so you get a known-good browser that won’t change when Chrome auto-updates on your machine.

Step 3: Create a basic configuration

Create playwright.config.ts in the project root:

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'] },
    },
  ],
});

Let’s break down what this configuration does:

  • testDir: Where Playwright looks for test files (default: files ending in .spec.ts or .test.ts)
  • fullyParallel: Run all tests in parallel (faster feedback, but requires test isolation)
  • forbidOnly: Fail CI if someone accidentally left test.only in the code
  • retries: Retry failing tests in CI (safety net for infrastructure flakiness), but not locally (so you see real failures immediately)
  • baseURL: The root URL for your application — all relative URLs in tests resolve against this
  • trace: 'on-first-retry': Capture a full trace (screenshots, network, DOM) only if the test fails and retries
  • projects: Defines which browsers to test. Start with one (Chromium), add Firefox and WebKit later

:::info[Why Start with One Browser?] Testing in three browsers from day one triples execution time and multiplies debugging surface area. Start with Chromium, get your tests stable, then add Firefox and WebKit. Cross-browser differences are real but rare — don’t pay the cost until you need the coverage. :::


Your First Test — A Login Flow

Create tests/login.spec.ts:

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

test.describe('Login', () => {
  test('should log in with valid credentials', async ({ page }) => {
    await page.goto('/login');

    await page.getByLabel('Email').fill('user@example.com');
    await page.getByLabel('Password').fill('password123');
    await page.getByRole('button', { name: 'Log in' }).click();

    await expect(page.getByText('Welcome back')).toBeVisible();
    await expect(page).toHaveURL(/\/dashboard/);
  });

  test('should show error with invalid credentials', async ({ page }) => {
    await page.goto('/login');

    await page.getByLabel('Email').fill('wrong@example.com');
    await page.getByLabel('Password').fill('wrongpassword');
    await page.getByRole('button', { name: 'Log in' }).click();

    await expect(page.getByText('Invalid credentials')).toBeVisible();
    await expect(page).toHaveURL('/login');
  });
});

What Makes This a Good Test?

Selectors mirror how users see the page. getByLabel('Email') finds the input by its label text — the same way a real user finds it. If the input’s id or class changes during a refactor, the test still works. We’ll cover locator strategy in depth in Part 2 — Locators That Don’t Break.

No hardcoded waits. Notice there’s no waitForTimeout(2000). Playwright’s expect(locator).toBeVisible() automatically waits until the element is visible or times out (default: 5 seconds). This is auto-waiting in action.

Assertions check outcomes, not steps. The test doesn’t verify that the HTTP request completed or that a loading spinner appeared. It checks the observable result: “Welcome back” text and the URL change. If the implementation changes but the outcome stays the same, the test still passes.


Running the Test

Run all tests

npx playwright test

This runs tests in headless mode across all configured projects (browsers). Output shows which tests passed, failed, or were skipped.

Run in headed mode (see the browser)

npx playwright test --headed

Useful when writing tests or debugging failures. You see exactly what Playwright sees.

Run a specific test file

npx playwright test tests/login.spec.ts

Run tests matching a pattern

npx playwright test --grep "should log in"

Open the HTML report

npx playwright show-report

After a test run, this opens an interactive HTML report showing pass/fail status, traces for failures, and screenshots.

:::tip[Debug with UI Mode] npx playwright test --ui opens Playwright’s UI mode — an interactive test runner with live preview, step-by-step execution, and DOM snapshots. It’s the fastest way to understand why a test is failing. :::


The Mental Model — Browser, Context, Page

Understanding Playwright’s architecture makes debugging easier and unlocks advanced patterns.

Browser

The browser represents a running browser instance. Playwright launches it once and reuses it across tests for speed.

const browser = await chromium.launch();

In most cases, you never interact with the browser directly — the test runner manages it.

BrowserContext

A context is an isolated browser session with its own cookies, localStorage, and session state. Each test gets a fresh context, ensuring tests don’t leak state between them.

const context = await browser.newContext();

This is why Playwright tests are isolated by default. Test A logs in, test B starts fresh — no manual cleanup required.

Page

A page is a single tab within a context. Most tests work with a single page, but you can open multiple pages to test scenarios like “user opens link in new tab.”

const page = await context.newPage();

In Playwright Test (the @playwright/test runner), you typically use the page fixture:

test('example', async ({ page }) => {
  // `page` is provided automatically — fresh context + page per test
  await page.goto('https://example.com');
});

The test runner creates a new context and page for each test, then discards them when the test finishes. This is why you don’t need afterEach cleanup in most cases.

:::warning[Shared State Is a Bug] If test B fails only when test A runs first, you have a shared state problem. Check: cookies, localStorage, server-side session state, database records. Playwright’s context isolation prevents client-side leakage, but it can’t fix server-side state pollution. Design tests to create their own data via API before the UI interaction (covered in Part 5 — API Testing with Playwright). :::


Common Gotchas for First-Time Users

1. Forgetting await

This is the most common bug:

// WRONG — doesn't wait for navigation
page.goto('/login');
page.getByLabel('Email').fill('user@example.com');

Every Playwright action returns a Promise. You must await it:

// CORRECT
await page.goto('/login');
await page.getByLabel('Email').fill('user@example.com');

Enable @typescript-eslint/no-floating-promises in your linter to catch this automatically.

2. Using page.locator() as a synchronous query

// WRONG — this doesn't find anything
const button = page.getByRole('button', { name: 'Submit' });
if (button) {
  await button.click();
}

getByRole() returns a Locator — a lazy reference that only resolves when you perform an action. It doesn’t query the DOM immediately. To check if an element exists, use an assertion:

// CORRECT — checks visibility asynchronously
const button = page.getByRole('button', { name: 'Submit' });
await expect(button).toBeVisible();
await button.click();

3. Hardcoded baseURL in every test

// AVOID — brittle if the URL changes
await page.goto('http://localhost:3000/login');
// BETTER — relative to baseURL in config
await page.goto('/login');

This makes tests portable across environments (dev, staging, prod).


What We’ve Covered

You now have:

  • A working Playwright installation
  • A test configuration optimised for speed and debuggability
  • A reliable login test using accessible selectors and auto-waiting
  • An understanding of the browser/context/page architecture

This foundation is enough to start writing your first test suite. In the next post, we’ll dive deep into locator strategy — the single most important skill for writing tests that don’t break on every UI change.

Action for this week: Install Playwright in a project, write one test for a real user flow (login, search, checkout — something your users actually do), and run it in both --headed and --ui mode. Get comfortable with the trace viewer before you need it in production.


Next in the series: Part 2 — Locators That Don’t Break