Skip to main content

How to Set Up E2E Tests with Playwright from Scratch

·PkgPulse Team

TL;DR

Playwright is the E2E testing standard in 2026. It supports Chromium, Firefox, and WebKit from one API, has auto-waits that eliminate flaky tests, and ships a trace viewer for debugging. Setup is npx playwright install and you're writing tests. The patterns that make tests maintainable: Page Object Model for reusable selectors, API request interception for consistent test data, and test.beforeEach for auth state.

Key Takeaways

  • npx playwright install — downloads browsers, sets up config automatically
  • Auto-wait — Playwright waits for elements before clicking (no sleep needed)
  • Locators — prefer getByRole, getByLabel over CSS selectors
  • Page Object Model — encapsulate page interactions for maintainable tests
  • storageState — save auth state once, reuse across tests (fast auth bypass)

Installation

# Install Playwright
npm init playwright@latest
# Interactive setup:
# - TypeScript (yes)
# - Where to put tests? (tests/)
# - GitHub Actions? (yes)
# - Install browsers? (yes)

# Or manually:
npm install -D @playwright/test
npx playwright install  # Downloads Chromium, Firefox, WebKit

playwright.config.ts

// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';

export default defineConfig({
  testDir: './tests',
  fullyParallel: true,           // Run tests in parallel
  forbidOnly: !!process.env.CI,  // Fail CI on .only
  retries: process.env.CI ? 2 : 0,
  workers: process.env.CI ? 1 : undefined,

  reporter: [
    ['html', { open: 'never' }],  // HTML report
    ['list'],                       // Console output
  ],

  use: {
    baseURL: process.env.BASE_URL || 'http://localhost:3000',
    trace: 'on-first-retry',       // Capture trace on failure
    screenshot: 'only-on-failure',
    video: 'retain-on-failure',
  },

  projects: [
    // Auth setup — runs once before all tests
    {
      name: 'setup',
      testMatch: /.*\.setup\.ts/,
    },

    // Test projects — run against different browsers
    {
      name: 'chromium',
      use: {
        ...devices['Desktop Chrome'],
        storageState: 'playwright/.auth/user.json',
      },
      dependencies: ['setup'],
    },
    {
      name: 'firefox',
      use: {
        ...devices['Desktop Firefox'],
        storageState: 'playwright/.auth/user.json',
      },
      dependencies: ['setup'],
    },
    {
      name: 'mobile',
      use: {
        ...devices['iPhone 14'],
        storageState: 'playwright/.auth/user.json',
      },
      dependencies: ['setup'],
    },
  ],

  // Start dev server before tests
  webServer: {
    command: 'npm run dev',
    url: 'http://localhost:3000',
    reuseExistingServer: !process.env.CI,
    timeout: 120_000,
  },
});

Auth Setup (Once, Reuse Everywhere)

// tests/auth.setup.ts — runs once before all tests
import { test as setup, expect } from '@playwright/test';
import path from 'path';

const authFile = path.join(__dirname, '../playwright/.auth/user.json');

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

  // Fill login form
  await page.getByLabel('Email').fill('test@example.com');
  await page.getByLabel('Password').fill('testpassword123');
  await page.getByRole('button', { name: 'Sign in' }).click();

  // Wait for successful login
  await expect(page).toHaveURL('/dashboard');
  await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();

  // Save auth state — reused by all tests
  await page.context().storageState({ path: authFile });
});

Page Object Model

// tests/pages/dashboard.page.ts
import { type Page, type Locator, expect } from '@playwright/test';

export class DashboardPage {
  readonly page: Page;

  // Define locators as properties
  readonly heading: Locator;
  readonly createButton: Locator;
  readonly searchInput: Locator;
  readonly projectList: Locator;

  constructor(page: Page) {
    this.page = page;
    this.heading = page.getByRole('heading', { name: 'Dashboard' });
    this.createButton = page.getByRole('button', { name: 'Create project' });
    this.searchInput = page.getByPlaceholder('Search projects...');
    this.projectList = page.getByRole('list', { name: 'Projects' });
  }

  async goto() {
    await this.page.goto('/dashboard');
    await expect(this.heading).toBeVisible();
  }

  async createProject(name: string) {
    await this.createButton.click();
    await this.page.getByLabel('Project name').fill(name);
    await this.page.getByRole('button', { name: 'Create' }).click();
    // Wait for the new project to appear
    await expect(
      this.projectList.getByRole('listitem').filter({ hasText: name })
    ).toBeVisible();
  }

  async searchProjects(query: string) {
    await this.searchInput.fill(query);
    await this.page.keyboard.press('Enter');
  }
}
// tests/dashboard.spec.ts — using Page Object Model
import { test, expect } from '@playwright/test';
import { DashboardPage } from './pages/dashboard.page';

test.describe('Dashboard', () => {
  let dashboard: DashboardPage;

  test.beforeEach(async ({ page }) => {
    dashboard = new DashboardPage(page);
    await dashboard.goto();
  });

  test('shows dashboard heading', async () => {
    await expect(dashboard.heading).toBeVisible();
  });

  test('creates a new project', async () => {
    await dashboard.createProject('My Test Project');
    await expect(
      dashboard.projectList.getByRole('listitem').filter({ hasText: 'My Test Project' })
    ).toBeVisible();
  });

  test('searches for projects', async ({ page }) => {
    await dashboard.searchProjects('specific project');
    // Verify search results
    await expect(page.getByText('No projects found')).toBeVisible();
  });
});

API Mocking for Consistent Test Data

// Intercept API calls to return consistent data
test('shows user data from API', async ({ page }) => {
  // Mock the API response
  await page.route('/api/users', (route) => {
    route.fulfill({
      status: 200,
      contentType: 'application/json',
      body: JSON.stringify([
        { id: '1', name: 'Alice', email: 'alice@test.com' },
        { id: '2', name: 'Bob', email: 'bob@test.com' },
      ]),
    });
  });

  await page.goto('/users');
  await expect(page.getByText('Alice')).toBeVisible();
  await expect(page.getByText('Bob')).toBeVisible();
});

// Mock error state
test('shows error when API fails', async ({ page }) => {
  await page.route('/api/users', (route) => {
    route.fulfill({ status: 500, body: 'Server Error' });
  });

  await page.goto('/users');
  await expect(page.getByRole('alert')).toContainText('Failed to load');
});

CI Configuration (GitHub Actions)

# .github/workflows/e2e.yml
name: E2E Tests

on: [push, pull_request]

jobs:
  playwright:
    runs-on: ubuntu-latest
    timeout-minutes: 30

    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'

      - name: Install dependencies
        run: npm ci

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

      - name: Run Playwright tests
        run: npx playwright test
        env:
          BASE_URL: ${{ vars.BASE_URL }}

      - name: Upload test report
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: playwright-report
          path: playwright-report/
          retention-days: 30

Compare Playwright and Cypress package health on PkgPulse.

Comments

Stay Updated

Get the latest package insights, npm trends, and tooling tips delivered to your inbox.