Skip to content
End-to-End Testing: Complete Guide with Playwright

End-to-End Testing: Complete Guide with Playwright

DodaTech Updated Jun 19, 2026 6 min read

End-to-end (E2E) testing automates real browser interactions to verify that your application works correctly from the user’s perspective — clicking buttons, filling forms, navigating pages, and checking that the right things happen at each step.

What You’ll Learn

  • What E2E testing is and when to use it over unit or integration tests
  • Setting up Playwright and writing your first browser test
  • Using assertions, the Page Object Model, and visual regression
  • Integrating E2E tests into your CI pipeline

Why E2E Testing Matters

Unit tests verify individual functions. Integration tests verify component interactions. But only E2E tests verify that the user’s journey works — from login to checkout to logout. E2E tests catch regressions that no other test type can: broken navigation, JavaScript errors in real browsers, CSS layout issues, and API integration failures.

Doda Browser uses Playwright E2E tests to verify that every browser feature works correctly across Chrome, Firefox, and WebKit before each release.

Learning Path

    flowchart LR
  A[Unit Testing] --> B[Integration Testing]
  B --> C[E2E Testing<br/>You are here]
  C --> D[CI Integration]
  D --> E[Visual Regression]
  style C fill:#f90,color:#fff
  

Playwright Setup

# Install Playwright
npm init playwright@latest

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

Expected output:

✔ Installed Playwright Test
✔ Created playwright.config.ts
✔ Created example tests in tests/
✔ Installed browsers: Chromium, Firefox, WebKit

Configuration

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

export default defineConfig({
  testDir: './e2e',
  fullyParallel: true,
  forbidOnly: !!process.env.CI,
  retries: process.env.CI ? 2 : 0,
  workers: process.env.CI ? 4 : undefined,
  reporter: [['html'], ['list']],

  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'] } },
    { name: 'Mobile Chrome', use: { ...devices['Pixel 5'] } },
    { name: 'Mobile Safari', use: { ...devices['iPhone 12'] } },
  ],
});

Writing Your First Test

// e2e/login.spec.ts
import { test, expect } from '@playwright/test';

test('user can log in successfully', async ({ page }) => {
  // Arrange: navigate to login page
  await page.goto('/login');

  // Act: fill form and submit
  await page.fill('[data-testid="email"]', 'user@example.com');
  await page.fill('[data-testid="password"]', 'correct-password');
  await page.click('[data-testid="login-button"]');

  // Assert: redirected to dashboard
  await expect(page).toHaveURL('/dashboard');
  await expect(page.locator('[data-testid="welcome"]')).toContainText('Welcome, User');
});

test('shows error on invalid credentials', async ({ page }) => {
  await page.goto('/login');
  await page.fill('[data-testid="email"]', 'wrong@example.com');
  await page.fill('[data-testid="password"]', 'wrong-password');
  await page.click('[data-testid="login-button"]');

  await expect(page.locator('[data-testid="error-message"]')).toBeVisible();
  await expect(page.locator('[data-testid="error-message"]')).toContainText(
    'Invalid email or password'
  );
});

Expected test output

Running 2 tests using 2 workers
  ✓ e2e/login.spec.ts:3:5 › user can log in successfully (4.2s)
  ✓ e2e/login.spec.ts:18:5 › shows error on invalid credentials (3.1s)
  2 passed (7.5s)

Assertions

Playwright provides auto-retrying assertions that wait for conditions to be met:

// Visibility and existence
await expect(page.locator('.loading-spinner')).toBeVisible();
await expect(page.locator('.error-message')).toBeHidden();
await expect(page.locator('#submit-btn')).toBeEnabled();
await expect(page.locator('#submit-btn')).toBeDisabled();

// Text content
await expect(page.locator('h1')).toHaveText('Dashboard');
await expect(page.locator('.price')).toContainText('$19.99');

// Form values
await expect(page.locator('#email')).toHaveValue('user@example.com');

// Count
await expect(page.locator('.todo-item')).toHaveCount(5);

// URL and attributes
await expect(page).toHaveURL(/\/dashboard/);
await expect(page.locator('img')).toHaveAttribute('alt', 'Profile photo');

Page Object Model

The Page Object Model (POM) encapsulates page-specific selectors and actions into reusable classes:

// e2e/pages/LoginPage.ts
import { Page } from '@playwright/test';

export class LoginPage {
  constructor(private page: Page) {}

  // Locators
  private emailInput = '[data-testid="email"]';
  private passwordInput = '[data-testid="password"]';
  private loginButton = '[data-testid="login-button"]';
  private errorMessage = '[data-testid="error-message"]';

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

  async login(email: string, password: string) {
    await this.page.fill(this.emailInput, email);
    await this.page.fill(this.passwordInput, password);
    await this.page.click(this.loginButton);
  }

  // Assertions
  async expectError(message: string) {
    await expect(this.page.locator(this.errorMessage)).toContainText(message);
  }
}
// e2e/login-pom.spec.ts
import { test } from '@playwright/test';
import { LoginPage } from './pages/LoginPage';

test('user can log in with POM', async ({ page }) => {
  const loginPage = new LoginPage(page);
  await loginPage.goto();
  await loginPage.login('user@example.com', 'correct-password');
  await expect(page).toHaveURL('/dashboard');
});

CI Integration

# .github/workflows/e2e.yml
name: E2E Tests
on: [deployment_status]
jobs:
  e2e:
    if: github.event.deployment_status.state == 'success'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: '20' }
      - run: npm ci
      - run: npx playwright install --with-deps
      - run: npx playwright test
        env:
          BASE_URL: ${{ github.event.deployment_status.environment_url }}
      - uses: actions/upload-artifact@v4
        if: always()
        with:
          name: playwright-report
          path: playwright-report/

Visual Regression Testing

Playwright can compare screenshots to detect visual changes:

test('homepage matches snapshot', async ({ page }) => {
  await page.goto('/');
  await expect(page).toHaveScreenshot('homepage.png', {
    maxDiffPixels: 100,
  });
});

test('checkout form renders correctly', async ({ page }) => {
  await page.goto('/checkout');
  await page.fill('#card-number', '4242424242424242');
  await expect(page.locator('.payment-form')).toHaveScreenshot();
});

Run npx playwright test --update-snapshots to update reference screenshots when intentional changes occur.

Common Errors

1. Flaky Tests from Timing Issues

Elements not loaded when Playwright tries to interact. Use auto-retrying assertions and waitFor instead of fixed delays.

// BAD: fixed wait
await page.waitForTimeout(2000);

// GOOD: wait for element
await page.waitForSelector('.table-loaded');

2. Using CSS Classes for Locators

CSS classes change frequently. Use data-testid attributes for test selectors.

3. Tests Pass Locally but Fail in CI

Environment differences (screen size, timeouts, base URL). Use the same config in both environments. Set CI-specific timeouts.

4. Not Handling Authentication

Tests that require login should handle auth once. Use page.context().storageState() to save and restore sessions.

5. Running Too Many Tests in Parallel

Headless browsers consume memory. Limit workers based on CI runner specs.

6. Ignoring Test Failures

Failed E2E tests almost always indicate real bugs. Investigate every failure using traces and screenshots.

Practice Questions

  1. What does E2E testing verify that other test types don’t? Real user journeys across pages, including navigation, JavaScript execution, CSS rendering, and API integration.

  2. What is the Page Object Model? A pattern that encapsulates page-specific selectors and actions into reusable classes.

  3. How does Playwright handle dynamic content? Auto-retrying assertions wait for conditions to be met within a configurable timeout.

  4. What is visual regression testing? Comparing screenshots of pages/components to detect unintended visual changes.

  5. How do you avoid flaky tests? Use auto-retrying assertions, data-testid selectors, and proper waits instead of fixed timeouts.

Challenge: Write a complete E2E test suite for a login page plus a dashboard page. Include: successful login, failed login with error message, logout, and a visual regression snapshot. Use the Page Object Model pattern.

FAQ

How many E2E tests should I write?
Enough to cover critical user journeys (login, checkout, search, settings). 20-50 E2E tests per application is typical. Too many become slow and flaky.
How is Playwright different from Cypress?
Playwright supports multiple browsers (Chromium, Firefox, WebKit) and runs tests outside the browser (faster, better network control). Cypress runs in-browser with a simpler API but only supports Chromium.
Should E2E tests replace unit tests?
No. E2E tests verify user journeys. Unit tests verify individual functions. You need both.
How long should an E2E test suite take?
Aim for under 10 minutes. Longer suites discourage developers from running them. Run critical tests on every PR, full suite before releases.
Can E2E tests test mobile devices?
Yes. Playwright emulates mobile devices with devices['Pixel 5'] or devices['iPhone 12'].

What’s Next

TutorialWhat You’ll Learn
Playwright Testing GuideDeep dive into Playwright features
Integration Testing GuideTesting service and API interactions
CI/CD Pipeline GuideAutomating tests in your pipeline

Built by the developers of Doda Browser, DodaZIP, and Durga Antivirus Pro. Updated 2026-06-19.

Built by the developers of DodaTech

Doda Browser, DodaZIP & Durga Antivirus Pro