E2E Testing: A Comprehensive Hands-On Guide to Playwright
E2E testing is an "end-to-end simulation" that mimics real user interactions—it tells you exactly how well your app works in a browser.
1. What You'll Learn
- Configure Playwright and set the Next.js development server to start automatically (webServer configuration)
- Designing Maintainable Test Code Using the Page Object Model (POM)
- Develop critical path tests: page navigation, form submission, and authentication process
- Using
toHaveScreenshot()for visual regression testing - Implement isolated testing by intercepting API requests via
page.route() - Integrating Playwright into a GitHub Actions CI pipeline
2. A True Story of a Test Engineer
(1) Pain Point: Incomplete manual test coverage and frequent production incidents
Diana is a test engineer on the TaskFlow team. The team maintains a SaaS platform serving more than 10,000 users, and before each release, three QA engineers must manually test more than 200 test cases—a process that takes two full days.
In last week's release, the "Edit Project Name" feature worked fine in Chrome, but the input field couldn't be focused in Safari. The QA team only tested it in Chrome, and after the release, they received more than 50 complaints.
The problem with manual testing is obvious:
| Issue | Impact |
|---|---|
| Incomplete browser coverage | Tested only on Chrome; Safari and Firefox were omitted |
| Long development cycle | Each release requires 2 days of manual testing |
| Inconsistent results | Different testers use different methods |
| Unable to reproduce | Need to retest after the bug is fixed |
(2) Playwright E2E Solution
Diana introduced Playwright to replace manual operations with code:
test('user can create a project', async ({ page }) => {
await page.goto('/projects')
await page.getByRole('button', { name: 'New Project' }).click()
await page.getByPlaceholder('Project name').fill('E-commerce App')
await page.getByRole('button', { name: 'Create' }).click()
await expect(page.getByText('E-commerce App')).toBeVisible()
})
(3) Revenue
| Dimension | Manual Testing | Playwright E2E |
|---|---|---|
| Test Duration | 2 days | 15 minutes |
| Browser Coverage | Chrome Only | Chrome + Firefox + Safari + Edge |
| Restore Cycle | Full Restore Each Time | Incremental Restore Each Time + CI Automatic |
| Bug Escape Rate | 15% | < 2% |
| Test Repeatability | Low (human variation) | High (100% consistency) |
3. Setting Up the Playwright Environment
Playwright is a cross-browser automated testing framework maintained by Microsoft that supports Chromium, Firefox, and WebKit.
graph TB
A[playwright.config.ts] --> B[webServer Layout]
A --> C[Browser Settings]
A --> D[Test Directory]
B --> E[Automatic Startup npm run dev]
B --> F[Listening on a port 3000 Available]
C --> G[Chromium / Firefox / WebKit]
C --> H[Viewport 1280x720]
style A fill:#cce5ff
style B fill:#d4edda
| Configuration File | Purpose | Key Options |
|---|---|---|
playwright.config.ts |
E2E Main Configuration | webServer Automated Lifecycle Management |
playwright/index.html |
Component Testing Portal | ct.target Configuration |
.github/workflows/playwright.yml |
CI Integration | npx playwright install --with-deps |
(1) Installation and Initialization
npm init playwright@latest
# Select:
# ✓ TypeScript
# ✓ E2E tests
# ✓ Add GitHub Actions workflow
# ✓ Install browsers (Chromium, Firefox, WebKit)
(2) playwright.config.ts Core Configuration
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 ? 1 : undefined,
reporter: [
['html'],
['json', { outputFile: 'playwright-report/results.json' }]
],
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'] }
}
],
webServer: {
command: 'npm run dev',
url: 'http://localhost:3000',
reuseExistingServer: !process.env.CI,
timeout: 30000
}
})
(3) Directory Structure
├── e2e/
│ ├── auth.setup.ts # Authentication Preprocessing
│ ├── models/
│ │ ├── LoginPage.ts # Login Page POM
│ │ ├── ProjectPage.ts # Project Page POM
│ │ └── TaskPage.ts # Tasks Page POM
│ ├── navigation.spec.ts # Navigation Test
│ ├── auth.spec.ts # Certification Testing
│ ├── projects.spec.ts # Project CRUD
│ └── visual.spec.ts # Visual Regression
├── playwright.config.ts
├── playwright-report/
└── test-results/
▶ Example: Verifying that Playwright is running
// e2e/example.spec.ts
import { test, expect } from '@playwright/test'
test('homepage has correct title', async ({ page }) => {
await page.goto('/')
await expect(page).toHaveTitle(/TaskFlow/)
await expect(page.locator('h1')).toContainText('Welcome')
})
npx playwright test --project=chromium --headed
Running 1 test using 1 worker
✓ e2e/example.spec.ts:3:1 › homepage has correct title (2.3s)
4. Page Object Model (POM) Design Pattern
POM encapsulates page interaction logic into separate classes, so the test code focuses only on "what to do" rather than "how to do it."
graph TB
A[Test Cases] --> B[Page Object Layer]
B --> C[LoginPage]
B --> D[ProjectsPage]
B --> E[TaskPage]
C --> F[Browser API]
D --> F
E --> F
style B fill:#cce5ff
style C fill:#d4edda
style D fill:#d4edda
style E fill:#d4edda
(1) LoginPage POM Implementation
// e2e/models/LoginPage.ts
import { type Page, type 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.getByPlaceholder('Enter your email address')
this.passwordInput = page.getByPlaceholder('Enter your password')
this.submitButton = page.getByRole('button', { name: 'Log In' })
this.errorMessage = page.getByTestId('login-error')
}
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()
}
async expectError(message: string) {
await expect(this.errorMessage).toContainText(message)
}
async expectLoggedIn() {
await expect(this.page).toHaveURL(/dashboard/)
}
}
(2) ProjectsPage POM Implementation
// e2e/models/ProjectsPage.ts
import { type Page, type Locator } from '@playwright/test'
export class ProjectsPage {
readonly page: Page
readonly newProjectButton: Locator
readonly projectNameInput: Locator
readonly createButton: Locator
readonly projectList: Locator
readonly searchInput: Locator
constructor(page: Page) {
this.page = page
this.newProjectButton = page.getByRole('button', { name: 'New Project' })
this.projectNameInput = page.getByPlaceholder('Project name')
this.createButton = page.getByRole('button', { name: 'Create' })
this.projectList = page.getByTestId('project-list')
this.searchInput = page.getByPlaceholder('Search Items')
}
async goto() {
await this.page.goto('/projects')
}
async createProject(name: string) {
await this.newProjectButton.click()
await this.projectNameInput.fill(name)
await this.createButton.click()
}
async searchProject(name: string) {
await this.searchInput.fill(name)
}
async expectProjectVisible(name: string) {
await expect(this.projectList).toContainText(name)
}
async openProject(name: string) {
await this.page.getByRole('link', { name }).first().click()
}
}
▶ Example: POM-Driven Testing
// e2e/projects.spec.ts
import { test, expect } from '@playwright/test'
import { LoginPage } from './models/LoginPage'
import { ProjectsPage } from './models/ProjectsPage'
test.describe('Project Management', () => {
let loginPage: LoginPage
let projectsPage: ProjectsPage
test.beforeEach(async ({ page }) => {
loginPage = new LoginPage(page)
projectsPage = new ProjectsPage(page)
await loginPage.goto()
await loginPage.login('alice@example.com', 'password123')
await loginPage.expectLoggedIn()
})
test('create a new project', async () => {
await projectsPage.goto()
await projectsPage.createProject('E-commerce Dashboard')
await projectsPage.expectProjectVisible('E-commerce Dashboard')
})
test('search for an existing project', async () => {
await projectsPage.goto()
await projectsPage.searchProject('E-commerce')
await projectsPage.expectProjectVisible('E-commerce Dashboard')
})
})
5. Critical Path Testing
The critical path is the business workflow most frequently used by users and must be prioritized.
(1) Authentication Process Testing
// e2e/auth.spec.ts
import { test, expect } from '@playwright/test'
test.describe('Authentication Flow', () => {
test('redirects unauthenticated user to login', async ({ page }) => {
await page.goto('/dashboard')
await expect(page).toHaveURL(/login/)
})
test('shows validation errors on empty form', async ({ page }) => {
await page.goto('/login')
await page.getByRole('button', { name: 'Log In' }).click()
await expect(page.getByText(/Please enter your email address/i)).toBeVisible()
await expect(page.getByText(/Please enter your password/i)).toBeVisible()
})
test('successful login redirects to dashboard', async ({ page }) => {
await page.goto('/login')
await page.getByPlaceholder('Enter your email address').fill('alice@example.com')
await page.getByPlaceholder('Enter your password').fill('correct-password')
await page.getByRole('button', { name: 'Log In' }).click()
await expect(page).toHaveURL(/dashboard/)
await expect(page.getByText(/Welcome back, Alice/i)).toBeVisible()
})
test('logout clears session', async ({ page }) => {
await page.goto('/login')
await page.getByPlaceholder('Enter your email address').fill('alice@example.com')
await page.getByPlaceholder('Enter your password').fill('correct-password')
await page.getByRole('button', { name: 'Log In' }).click()
await page.getByRole('button', { name: /Exit/i }).click()
await expect(page).toHaveURL(/login/)
})
})
(2) Form Submission Test
// e2e/task-creation.spec.ts
import { test, expect } from '@playwright/test'
test.describe('Task Creation Flow', () => {
test.beforeEach(async ({ page }) => {
// Log in and go to the project
await page.goto('/login')
await page.getByPlaceholder('Enter your email address').fill('alice@example.com')
await page.getByPlaceholder('Enter your password').fill('password123')
await page.getByRole('button', { name: 'Log In' }).click()
await page.goto('/projects/p1')
})
test('creates a task with all fields', async ({ page }) => {
await page.getByRole('button', { name: 'Add Task' }).click()
await page.getByPlaceholder('Task title').fill('Implement user auth')
await page.getByLabel('Priority').selectOption('High')
await page.getByLabel('Assignee').selectOption('Bob')
await page.getByRole('button', { name: 'Save' }).click()
await expect(page.getByText('Implement user auth')).toBeVisible()
})
test('shows error for empty title', async ({ page }) => {
await page.getByRole('button', { name: 'Add Task' }).click()
await page.getByRole('button', { name: 'Save' }).click()
await expect(page.getByText(/The title cannot be left blank./i)).toBeVisible()
})
})
(3) Navigation and Routing Testing
// e2e/navigation.spec.ts
import { test, expect } from '@playwright/test'
test.describe('Navigation Flow', () => {
test('sidebar links navigate correctly', async ({ page }) => {
await page.goto('/login')
await page.getByPlaceholder('Enter your email address').fill('alice@example.com')
await page.getByPlaceholder('Enter your password').fill('password123')
await page.getByRole('button', { name: 'Log In' }).click()
await page.getByRole('link', { name: 'Projects' }).click()
await expect(page).toHaveURL(/\/projects/)
await page.getByRole('link', { name: 'Dashboard' }).click()
await expect(page).toHaveURL(/\/dashboard/)
await page.getByRole('link', { name: 'Settings' }).click()
await expect(page).toHaveURL(/\/settings/)
})
test('breadcrumb shows current location', async ({ page }) => {
await page.goto('/login')
await page.getByPlaceholder('Enter your email address').fill('alice@example.com')
await page.getByPlaceholder('Enter your password').fill('password123')
await page.getByRole('button', { name: 'Log In' }).click()
await page.goto('/projects/p1/tasks/t1')
await expect(page.getByTestId('breadcrumb')).toContainText([
/Projects/, /Project 1/, /Task 1/
])
})
})
6. Visual Regression Testing
Visual regression testing captures subtle changes in UI styling. Playwright enables pixel-level comparison through toHaveScreenshot().
graph LR
A[Test Run] --> B[Take a screenshot of the current status]
B --> C{Comparison with the reference screenshot}
C -->|Match| D[Test Passed]
C -->|Differences > Threshold| E[Test Failed]
E --> F[Generate a Variance Report]
style A fill:#cce5ff
style C fill:#fff3cd
style D fill:#d4edda
style E fill:#f8d7da
| Option | Description | Recommended Value |
|---|---|---|
maxDiffPixels |
Maximum number of differing pixels | 100 |
maxDiffPixelRatio |
Maximum Difference Ratio | 0.01 |
threshold |
Pixel Comparison Threshold | 0.2 |
animations |
Disable animations | 'disabled' |
stylePath |
Additional CSS Overrides | Hide Random Elements |
(1) Configure Visual Testing
// playwright.config.ts Excerpt
export default defineConfig({
use: {
screenshot: 'only-on-failure',
viewport: { width: 1280, height: 720 }
},
expect: {
toHaveScreenshot: {
maxDiffPixels: 100,
animations: 'disabled'
}
}
})
(2) Generate a reference screenshot
# Generate a baseline screenshot on first run
npx playwright test --update-snapshots
# Comparison of Subsequent Operations Against the Baseline
npx playwright test
▶ Example: Visual Regression Testing
// e2e/visual.spec.ts
import { test, expect } from '@playwright/test'
test.describe('Visual Regression', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/login')
await page.getByPlaceholder('Enter your email address').fill('alice@example.com')
await page.getByPlaceholder('Enter your password').fill('password123')
await page.getByRole('button', { name: 'Log In' }).click()
})
test('dashboard page matches snapshot', async ({ page }) => {
await page.goto('/dashboard')
await page.waitForLoadState('networkidle')
await expect(page).toHaveScreenshot('dashboard.png', {
fullPage: true
})
})
test('project list matches snapshot', async ({ page }) => {
await page.goto('/projects')
await page.waitForSelector('[data-testid="project-list"]')
await expect(page).toHaveScreenshot('projects.png')
})
test('task detail matches snapshot', async ({ page }) => {
await page.goto('/projects/p1/tasks/t1')
await page.waitForLoadState('networkidle')
await expect(page).toHaveScreenshot('task-detail.png', {
mask: [page.locator('[data-testid="timestamp"]')]
})
})
})
7. API Mocking and Request Interception
Playwright's page.route() can intercept network requests at the browser level, allowing you to test front-end/back-end interactions without a real backend.
(1) Basics of Route Interception
// Global Interception
await page.route('/api/', async route => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify([{ id: 1, title: 'Mocked Task' }])
})
})
// Specification URL Intercept
await page.route('https://api.example.com/products*', async route => {
const response = await route.fetch() // Let It Go to Reality API
const body = await response.json()
body.push({ id: 999, name: 'Injected Product' })
await route.fulfill({ response, body: JSON.stringify(body) })
})
(2) Comparison of API Mock Patterns
| Pattern | Method | Applicable Scenarios |
|---|---|---|
| Full Mock | route.fulfill() |
Testing the front end when the back end is not ready |
| Agent Modification | route.fetch() + Modification |
Inject Test Data/Error |
| Proxy Pass-Through | route.fetch() + route.fulfill({ response }) |
Listen for requests but do not modify the data |
| Request Interrupted | route.abort() |
Testing Offline Status |
▶ Example: A Complete Test with API Mock
// e2e/api-mock.spec.ts
import { test, expect } from '@playwright/test'
test.describe('API Mock Scenarios', () => {
test('shows empty state when no data', async ({ page }) => {
await page.route('**/api/projects', async route => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify([])
})
})
await page.goto('/projects')
await expect(page.getByText(/No projects yet/i)).toBeVisible()
})
test('handles 500 error gracefully', async ({ page }) => {
await page.route('**/api/projects', async route => {
await route.fulfill({
status: 500,
contentType: 'application/json',
body: JSON.stringify({ error: 'Server error' })
})
})
await page.goto('/projects')
await expect(page.getByText(/Failed to load/i)).toBeVisible()
})
test('shows loading state then data', async ({ page }) => {
// Delay responses to test under load conditions
await page.route('**/api/projects', async route => {
await new Promise(resolve => setTimeout(resolve, 1000))
await route.fulfill({
status: 200,
body: JSON.stringify([
{ id: 'p1', name: 'Mock Project', taskCount: 5 }
])
})
})
await page.goto('/projects')
await expect(page.getByTestId('loading-skeleton')).toBeVisible()
await expect(page.getByText('Mock Project')).toBeVisible({ timeout: 5000 })
})
test('network offline scenario', async ({ page }) => {
await page.route('/api/', async route => {
await route.abort('internetdisconnected')
})
await page.goto('/projects')
await expect(page.getByText(/Network connection failed/i)).toBeVisible()
})
})
8. CI Integration: GitHub Actions
Playwright integrates seamlessly with GitHub Actions, automatically running E2E tests with every push.
# .github/workflows/playwright.yml
name: Playwright Tests
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
jobs:
test:
timeout-minutes: 60
runs-on: ubuntu-latest
strategy:
matrix:
browser: [chromium, firefox, webkit]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
- name: Install dependencies
run: npm ci
- name: Cache Playwright browsers
uses: actions/cache@v4
with:
path: ~/.cache/ms-playwright
key: ${{ runner.os }}-playwright-${{ hashFiles('package-lock.json') }}
- name: Install Playwright browsers
run: npx playwright install --with-deps ${{ matrix.browser }}
- name: Run Playwright tests
run: npx playwright test --project=${{ matrix.browser }}
- uses: actions/upload-artifact@v4
if: failure()
with:
name: playwright-report-${{ matrix.browser }}
path: playwright-report/
retention-days: 7
9. Complete Example: TaskFlow Application E2E Suite
// e2e/taskflow-e2e.spec.ts
// ============================================
// General E2E Test:TaskFlow The Complete User Flow of the Application
// ============================================
import { test, expect, type Page } from '@playwright/test'
// --- Utility Functions:Set Authentication Status ---
async function setupAuth(page: Page) {
await page.goto('/login')
await page.getByPlaceholder('Enter your email address').fill('alice@taskflow.io')
await page.getByPlaceholder('Enter your password').fill('Password123!')
await page.getByRole('button', { name: 'Log In' }).click()
await expect(page).toHaveURL(/dashboard/)
}
test.describe('TaskFlow E2E Suite', () => {
test.beforeEach(async ({ page }) => {
await setupAuth(page)
})
// --- 1. Dashboard Loading ---
test('dashboard displays key metrics', async ({ page }) => {
await page.goto('/dashboard')
await expect(page.getByTestId('total-projects')).toBeVisible()
await expect(page.getByTestId('total-tasks')).toBeVisible()
await expect(page.getByTestId('team-members')).toBeVisible()
const projectCount = await page.getByTestId('total-projects').textContent()
expect(Number(projectCount)).toBeGreaterThan(0)
})
// --- 2. Project CRUD Process ---
test('complete project lifecycle', async ({ page }) => {
// Create
await page.goto('/projects')
await page.getByRole('button', { name: 'New Project' }).click()
await page.getByPlaceholder('Project name').fill('E2E Test Project')
await page.getByPlaceholder('Description').fill('Created by Playwright')
await page.getByRole('button', { name: 'Create' }).click()
await expect(page.getByText('E2E Test Project')).toBeVisible()
// Edit
await page.getByRole('button', { name: /Edit/i }).click()
await page.getByPlaceholder('Project name').fill('E2E Test Project v2')
await page.getByRole('button', { name: 'Save' }).click()
await expect(page.getByText('E2E Test Project v2')).toBeVisible()
// Delete
await page.getByRole('button', { name: /Delete/i }).click()
await page.getByRole('button', { name: /Confirm/i }).click()
await expect(page.getByText('E2E Test Project v2')).not.toBeVisible()
})
// --- 3. Task Management ---
test('task drag and drop status change', async ({ page }) => {
await page.goto('/projects/p1')
// Create task
await page.getByRole('button', { name: 'Add Task' }).click()
await page.getByPlaceholder('Task title').fill('Setup Playwright tests')
await page.getByText('TODO').click()
await page.getByRole('button', { name: 'Save' }).click()
// Verify task appears in TODO column
await expect(page.getByTestId('column-todo'))
.toContainText('Setup Playwright tests')
})
// --- 4. Search Function ---
test('search filters projects correctly', async ({ page }) => {
await page.goto('/projects')
await page.getByPlaceholder('Search Items').fill('Marketing')
await page.waitForTimeout(300) // debounce
const items = page.getByTestId('project-item')
const count = await items.count()
for (let i = 0; i < count; i++) {
await expect(items.nth(i)).toContainText(/Marketing/i)
}
})
// --- 5. Responsive Layout ---
test.describe('Responsive Design', () => {
test('sidebar collapses on mobile viewport', async ({ page }) => {
await page.setViewportSize({ width: 375, height: 812 })
await page.goto('/dashboard')
await expect(page.getByTestId('sidebar')).not.toBeVisible()
await page.getByRole('button', { name: /menu/i }).click()
await expect(page.getByTestId('sidebar')).toBeVisible()
})
})
// --- 6. API Mock Test ---
test('handles API errors gracefully', async ({ page }) => {
await page.route('**/api/dashboard/metrics', async route => {
await route.fulfill({
status: 503,
body: JSON.stringify({ error: 'Service Unavailable' })
})
})
await page.goto('/dashboard')
await expect(page.getByText(/The service is temporarily unavailable/i)).toBeVisible()
})
})
Running 8 tests using 2 workers
✓ e2e/taskflow-e2e.spec.ts (8 tests) 34.2s
✓ chromium | 8 passed (34.2s)
✓ firefox | 8 passed (38.1s)
✓ webkit | 8 passed (41.5s)
❓ FAQ
webServer configuration is also better suited for Next.js’s npm run dev automatic startup scenarios.storageState feature: After logging in on auth.setup.ts, save the cookies and localStorage to storageState.json, then reference that file in the configuration of other tests on use to avoid having to log in repeatedly for each test.page.route() and MSW for E2E testing?page.route() intercepts requests at the browser level and requires no additional dependencies, making it suitable for temporary mocking. MSW requires Service Worker registration and is suitable for component testing (Vitest). For E2E testing, page.route() is recommended by default to reduce external dependencies.workers: 1 to avoid resource contention; (2) Cache browser binaries (~/.cache/ms-playwright); (3) Use --project=chromium to run only critical browsers in CI; (4) Parallelize: Split tests across multiple jobs.mask option to hide dynamic elements in visual tests: toHaveScreenshot({ mask: [page.locator('[data-testid="timestamp"]')] }). For text assertions, use regular expression patterns instead of fixed values.📖 Summary
- The Playwright configuration
webServerallows the Next.js development server to start and stop automatically along with the tests - The Page Object Model encapsulates page interactions into separate classes, making test code more concise and maintainable
- Critical path testing covers core user workflows such as authentication, navigation, and form submission
toHaveScreenshot()Enables pixel-level visual regression comparisons to detect unintended UI style changespage.route()Intercepts API requests at the browser level; suitable for isolation testing and error scenario simulation- Integrating Playwright with GitHub Actions requires caching the browser, configuring the parallel matrix, and uploading failure reports
📝 Exercises
-
Basic Question (⭐): Create a
LoginPagePage Object containing three methods—goto(),login(email, password), andexpectLoggedIn()—and write a test case that uses this POM. -
Advanced Exercise (⭐⭐): Write five end-to-end (E2E) tests for the complete "Create-Edit-Delete" (CRUD) workflow in your Next.js application, including
page.route()mocks for form validation errors and API error scenarios. -
Challenge (⭐⭐⭐): Implement a complete visual regression test suite: (1) Generate baseline screenshots of 5 key pages; (2) Configure the
expect.toHaveScreenshotandmaxDiffPixelsthresholds; (3) Write a CI script to automatically compare the screenshots in a pull request and add a comment with the difference report.



