Skip to main content
{/}codefunc
GuidesJuly 7, 2026 · 7 min read

Playwright for E2E Testing: A Practical Guide for Frontend Developers

Learn end-to-end testing with Playwright — writing reliable tests, handling authentication, testing across browsers, and integrating with CI/CD pipelines.

By codefunc

playwrighttestinge2eautomationfrontendci-cd

End-to-end tests verify your application works as users experience it. They catch integration issues that unit tests miss. Playwright has become the standard E2E testing tool — it is fast, reliable, and works across all browsers. Understanding Playwright patterns helps you write tests that catch real bugs without constant flakiness.

Bottom line: Playwright tests user flows, not implementation details. Use locators based on accessible roles and text, not CSS selectors. Handle async operations with auto-waiting, not arbitrary sleeps. Run tests in CI on every PR. Start with critical user paths (login, checkout, core features) before testing edge cases.

Quick setup

# Create new project with Playwright
npm init playwright@latest

# Or add to existing project
npm install -D @playwright/test
npx playwright install

This creates:

  • playwright.config.ts — configuration
  • tests/ — test directory
  • tests-examples/ — example tests

First test

// tests/homepage.spec.ts
import { test, expect } from '@playwright/test';

test('homepage has correct title', async ({ page }) => {
  await page.goto('https://example.com');
  
  await expect(page).toHaveTitle(/Example/);
});

test('user can navigate to about page', async ({ page }) => {
  await page.goto('https://example.com');
  
  await page.getByRole('link', { name: 'About' }).click();
  
  await expect(page).toHaveURL(/.*about/);
  await expect(page.getByRole('heading', { name: 'About Us' })).toBeVisible();
});

Run tests:

npx playwright test
npx playwright test --headed  # See browser
npx playwright test --ui      # Interactive UI mode

Locators

Locators find elements on the page. Prefer accessible locators over CSS selectors.

// 1. Role-based (best - matches accessibility)
page.getByRole('button', { name: 'Submit' })
page.getByRole('textbox', { name: 'Email' })
page.getByRole('link', { name: 'Learn more' })
page.getByRole('heading', { name: 'Welcome' })

// 2. Label text
page.getByLabel('Email address')
page.getByLabel('Password')

// 3. Placeholder
page.getByPlaceholder('Enter your email')

// 4. Text content
page.getByText('Sign up for free')
page.getByText(/welcome/i)  // Regex for flexibility

// 5. Test ID (when above don't work)
page.getByTestId('submit-button')

// 6. CSS selector (last resort)
page.locator('.submit-btn')
page.locator('#email-input')

Why role-based locators?

// Bad: Breaks if class changes
page.locator('.btn-primary.submit')

// Bad: Breaks if structure changes
page.locator('form > div:nth-child(3) > button')

// Good: Resilient, tests accessibility
page.getByRole('button', { name: 'Submit' })

Chaining locators

// Find button inside specific section
const nav = page.getByRole('navigation');
await nav.getByRole('link', { name: 'Home' }).click();

// Find within a list item
const todoItem = page.getByRole('listitem').filter({ hasText: 'Buy groceries' });
await todoItem.getByRole('button', { name: 'Delete' }).click();

Actions

Common interactions

// Click
await page.getByRole('button', { name: 'Submit' }).click();
await page.getByRole('button').dblclick();
await page.getByRole('button').click({ button: 'right' });

// Type
await page.getByLabel('Email').fill('user@example.com');
await page.getByLabel('Email').pressSequentially('user@example.com'); // Simulates typing

// Clear and type
await page.getByLabel('Email').clear();
await page.getByLabel('Email').fill('new@example.com');

// Select dropdown
await page.getByLabel('Country').selectOption('US');
await page.getByLabel('Country').selectOption({ label: 'United States' });

// Checkbox and radio
await page.getByLabel('Accept terms').check();
await page.getByLabel('Accept terms').uncheck();
await page.getByRole('radio', { name: 'Express shipping' }).check();

// Hover
await page.getByRole('button', { name: 'Menu' }).hover();

// File upload
await page.getByLabel('Upload file').setInputFiles('path/to/file.pdf');

Keyboard actions

await page.keyboard.press('Enter');
await page.keyboard.press('Tab');
await page.keyboard.press('Control+A');
await page.keyboard.type('Hello World');

Assertions

// Visibility
await expect(page.getByRole('alert')).toBeVisible();
await expect(page.getByRole('dialog')).toBeHidden();

// Text content
await expect(page.getByRole('heading')).toHaveText('Welcome');
await expect(page.getByRole('heading')).toContainText('Welcome');

// Attributes
await expect(page.getByRole('link')).toHaveAttribute('href', '/about');
await expect(page.getByRole('button')).toBeEnabled();
await expect(page.getByRole('button')).toBeDisabled();

// Form state
await expect(page.getByLabel('Email')).toHaveValue('user@example.com');
await expect(page.getByLabel('Accept')).toBeChecked();

// Count
await expect(page.getByRole('listitem')).toHaveCount(5);

// Page state
await expect(page).toHaveURL(/.*dashboard/);
await expect(page).toHaveTitle('Dashboard | MyApp');

// Screenshot comparison
await expect(page).toHaveScreenshot('homepage.png');

Soft assertions

Continue test after failure:

await expect.soft(page.getByText('Welcome')).toBeVisible();
await expect.soft(page.getByText('Dashboard')).toBeVisible();
// Test continues even if first assertion fails

Handling async operations

Playwright auto-waits for elements, but sometimes you need explicit waits.

// Wait for element
await page.getByRole('button', { name: 'Submit' }).waitFor();
await page.getByRole('alert').waitFor({ state: 'visible' });

// Wait for navigation
await page.waitForURL('**/dashboard');

// Wait for network request
await page.waitForResponse('**/api/users');

// Wait for load state
await page.waitForLoadState('networkidle');

// Custom wait
await page.waitForFunction(() => {
  return document.querySelector('.loaded') !== null;
});

Avoid arbitrary sleeps

// Bad: Flaky, slow
await page.waitForTimeout(3000);

// Good: Wait for specific condition
await expect(page.getByRole('alert')).toBeVisible();

Authentication

Login before each test

test.beforeEach(async ({ page }) => {
  await page.goto('/login');
  await page.getByLabel('Email').fill('test@example.com');
  await page.getByLabel('Password').fill('password123');
  await page.getByRole('button', { name: 'Sign in' }).click();
  await expect(page).toHaveURL('/dashboard');
});

test('user can view profile', async ({ page }) => {
  await page.getByRole('link', { name: 'Profile' }).click();
  await expect(page.getByRole('heading', { name: 'My Profile' })).toBeVisible();
});

Reuse authentication state

Save login state and reuse across tests:

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

export default defineConfig({
  projects: [
    { name: 'setup', testMatch: /.*\.setup\.ts/ },
    {
      name: 'chromium',
      dependencies: ['setup'],
      use: {
        storageState: 'playwright/.auth/user.json',
      },
    },
  ],
});
// 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('test@example.com');
  await page.getByLabel('Password').fill('password123');
  await page.getByRole('button', { name: 'Sign in' }).click();
  await expect(page).toHaveURL('/dashboard');
  
  await page.context().storageState({ path: authFile });
});

Page Object Model

Organize tests with page objects:

// pages/login.page.ts
import { Page, Locator } from '@playwright/test';

export class LoginPage {
  readonly page: Page;
  readonly emailInput: Locator;
  readonly passwordInput: Locator;
  readonly submitButton: Locator;
  readonly errorMessage: Locator;

  constructor(page: Page) {
    this.page = page;
    this.emailInput = page.getByLabel('Email');
    this.passwordInput = page.getByLabel('Password');
    this.submitButton = page.getByRole('button', { name: 'Sign in' });
    this.errorMessage = page.getByRole('alert');
  }

  async goto() {
    await this.page.goto('/login');
  }

  async login(email: string, password: string) {
    await this.emailInput.fill(email);
    await this.passwordInput.fill(password);
    await this.submitButton.click();
  }
}
// tests/login.spec.ts
import { test, expect } from '@playwright/test';
import { LoginPage } from '../pages/login.page';

test('successful login redirects to dashboard', async ({ page }) => {
  const loginPage = new LoginPage(page);
  
  await loginPage.goto();
  await loginPage.login('user@example.com', 'password123');
  
  await expect(page).toHaveURL('/dashboard');
});

test('invalid credentials show error', async ({ page }) => {
  const loginPage = new LoginPage(page);
  
  await loginPage.goto();
  await loginPage.login('user@example.com', 'wrong');
  
  await expect(loginPage.errorMessage).toHaveText('Invalid credentials');
});

API mocking

Mock API responses for faster, reliable tests:

test('displays user data from API', async ({ page }) => {
  // Mock API response
  await page.route('**/api/user', async (route) => {
    await route.fulfill({
      status: 200,
      contentType: 'application/json',
      body: JSON.stringify({
        name: 'John Doe',
        email: 'john@example.com',
      }),
    });
  });

  await page.goto('/profile');
  
  await expect(page.getByText('John Doe')).toBeVisible();
  await expect(page.getByText('john@example.com')).toBeVisible();
});

test('handles API error gracefully', async ({ page }) => {
  await page.route('**/api/user', async (route) => {
    await route.fulfill({
      status: 500,
      body: 'Internal Server Error',
    });
  });

  await page.goto('/profile');
  
  await expect(page.getByText('Failed to load profile')).toBeVisible();
});

For generating test data, use the Fake Data Generator.

Configuration

// playwright.config.ts
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',
    video: 'retain-on-failure',
  },

  projects: [
    {
      name: 'chromium',
      use: { ...devices['Desktop Chrome'] },
    },
    {
      name: 'firefox',
      use: { ...devices['Desktop Firefox'] },
    },
    {
      name: 'webkit',
      use: { ...devices['Desktop Safari'] },
    },
    {
      name: 'Mobile Chrome',
      use: { ...devices['Pixel 5'] },
    },
  ],

  webServer: {
    command: 'npm run dev',
    url: 'http://localhost:3000',
    reuseExistingServer: !process.env.CI,
  },
});

CI/CD integration

GitHub Actions

# .github/workflows/playwright.yml
name: Playwright Tests
on:
  push:
    branches: [main]
  pull_request:
    branches: [main]
    
jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          
      - name: Install dependencies
        run: npm ci
        
      - name: Install Playwright Browsers
        run: npx playwright install --with-deps
        
      - name: Run Playwright tests
        run: npx playwright test
        
      - uses: actions/upload-artifact@v4
        if: always()
        with:
          name: playwright-report
          path: playwright-report/
          retention-days: 30

Debugging

Interactive mode

npx playwright test --ui

Debug mode

npx playwright test --debug

Trace viewer

npx playwright show-trace trace.zip

Code generation

Record tests by interacting with the browser:

npx playwright codegen https://example.com

Common patterns

Testing forms

test('form validation', async ({ page }) => {
  await page.goto('/register');
  
  // Submit empty form
  await page.getByRole('button', { name: 'Register' }).click();
  
  // Check validation messages
  await expect(page.getByText('Email is required')).toBeVisible();
  await expect(page.getByText('Password is required')).toBeVisible();
  
  // Fill with invalid data
  await page.getByLabel('Email').fill('invalid-email');
  await page.getByRole('button', { name: 'Register' }).click();
  
  await expect(page.getByText('Invalid email format')).toBeVisible();
  
  // Fill with valid data
  await page.getByLabel('Email').fill('user@example.com');
  await page.getByLabel('Password').fill('SecurePass123!');
  await page.getByRole('button', { name: 'Register' }).click();
  
  await expect(page).toHaveURL('/welcome');
});

Testing modals

test('confirm dialog', async ({ page }) => {
  await page.goto('/items');
  
  await page.getByRole('button', { name: 'Delete' }).click();
  
  // Modal appears
  const modal = page.getByRole('dialog');
  await expect(modal).toBeVisible();
  await expect(modal.getByText('Are you sure?')).toBeVisible();
  
  // Confirm deletion
  await modal.getByRole('button', { name: 'Confirm' }).click();
  
  // Modal closes, item removed
  await expect(modal).toBeHidden();
  await expect(page.getByText('Item deleted')).toBeVisible();
});

Testing tables

test('table sorting', async ({ page }) => {
  await page.goto('/users');
  
  // Click header to sort
  await page.getByRole('columnheader', { name: 'Name' }).click();
  
  // Verify order
  const rows = page.getByRole('row');
  await expect(rows.nth(1)).toContainText('Alice');
  await expect(rows.nth(2)).toContainText('Bob');
  
  // Click again for reverse
  await page.getByRole('columnheader', { name: 'Name' }).click();
  await expect(rows.nth(1)).toContainText('Zoe');
});

Use these codefunc tools alongside this guide:

Practical takeaway

Start with critical user flows — login, main features, checkout. Use role-based locators for resilience. Mock APIs for speed and reliability. Run tests in CI on every PR. Use the trace viewer to debug failures. Page objects keep tests maintainable as they grow. Don't test implementation details; test user behavior.

Sources

Try it on codefunc

Related articles

3

Search tools

Find a developer tool