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,getByLabelover 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.
See the live comparison
View playwright vs. cypress on PkgPulse →