Skip to content

E2E Testing with Playwright

Terminal window
# Install Playwright (recommended for Angular)
npm init playwright@latest
# Install browsers
npx playwright install
Terminal window
# Run all tests
npx playwright test
# Run with UI mode (recommended for development)
npx playwright test --ui
# Run specific test file
npx playwright test e2e/login.spec.ts
# Run on specific browser
npx playwright test --project=chromium
npx playwright test --project=firefox
npx playwright test --project=webkit
# Debug mode (opens browser inspector)
npx playwright test --debug
# Headed mode (see browser)
npx playwright test --headed
# View HTML report
npx playwright show-report
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',
use: {
baseURL: 'http://localhost:4200',
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'] } },
// Mobile viewports
{ name: 'mobile-chrome', use: { ...devices['Pixel 5'] } },
{ name: 'mobile-safari', use: { ...devices['iPhone 12'] } },
],
// Auto-start dev server
webServer: {
command: 'npm run start',
url: 'http://localhost:4200',
reuseExistingServer: !process.env.CI,
}
});
e2e/login.spec.ts
import { test, expect } from '@playwright/test';
test.describe('Login Page', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/login');
});
test('should display login form', async ({ page }) => {
await expect(page.locator('[data-testid="email-input"]')).toBeVisible();
await expect(page.locator('[data-testid="password-input"]')).toBeVisible();
await expect(page.locator('[data-testid="submit-button"]')).toBeVisible();
});
test('should login successfully', async ({ page }) => {
await page.fill('[data-testid="email-input"]', 'test@example.com');
await page.fill('[data-testid="password-input"]', 'password123');
await page.click('[data-testid="submit-button"]');
await expect(page).toHaveURL(/.*dashboard/);
await expect(page.locator('h1')).toContainText('Dashboard');
});
test('should show validation errors', async ({ page }) => {
await page.click('[data-testid="submit-button"]');
await expect(page.locator('.error-message'))
.toContainText('Email is required');
});
test('should handle wrong credentials', async ({ page }) => {
await page.fill('[data-testid="email-input"]', 'wrong@example.com');
await page.fill('[data-testid="password-input"]', 'wrongpass');
await page.click('[data-testid="submit-button"]');
await expect(page.locator('.error-message'))
.toContainText('Invalid credentials');
});
});
e2e/users.spec.ts
import { test, expect } from '@playwright/test';
test.describe('User List', () => {
test('should load and display users', async ({ page }) => {
// Mock API response
await page.route('**/api/users', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify([
{ id: 1, name: 'John Doe', email: 'john@example.com' },
{ id: 2, name: 'Jane Smith', email: 'jane@example.com' },
{ id: 3, name: 'Bob Johnson', email: 'bob@example.com' }
])
});
});
await page.goto('/users');
await expect(page.locator('.user-card')).toHaveCount(3);
await expect(page.locator('.user-card').first())
.toContainText('John Doe');
});
test('should handle API error', async ({ page }) => {
await page.route('**/api/users', async (route) => {
await route.fulfill({
status: 500,
contentType: 'application/json',
body: JSON.stringify({ error: 'Server error' })
});
});
await page.goto('/users');
await expect(page.locator('.error-message'))
.toContainText('Failed to load users');
});
test('should handle network timeout', async ({ page }) => {
await page.route('**/api/users', async (route) => {
// Simulate slow network
await new Promise(resolve => setTimeout(resolve, 5000));
await route.abort('timedout');
});
await page.goto('/users');
await expect(page.locator('.error-message'))
.toContainText('Request timeout');
});
});
e2e/fixtures.ts
import { test as base } from '@playwright/test';
type MyFixtures = {
loginAsUser: (email?: string, password?: string) => Promise<void>;
loginAsAdmin: () => Promise<void>;
};
export const test = base.extend<MyFixtures>({
loginAsUser: async ({ page }, use) => {
const login = async (
email = 'test@example.com',
password = 'password123'
) => {
await page.goto('/login');
await page.fill('[data-testid="email-input"]', email);
await page.fill('[data-testid="password-input"]', password);
await page.click('[data-testid="submit-button"]');
await page.waitForURL('**/dashboard');
};
await use(login);
},
loginAsAdmin: async ({ page }, use) => {
const login = async () => {
await page.goto('/login');
await page.fill('[data-testid="email-input"]', 'admin@example.com');
await page.fill('[data-testid="password-input"]', 'admin123');
await page.click('[data-testid="submit-button"]');
await page.waitForURL('**/dashboard');
};
await use(login);
}
});
export { expect } from '@playwright/test';
e2e/user-crud.spec.ts
import { test, expect } from './fixtures';
test.describe('User CRUD Operations', () => {
test.beforeEach(async ({ page, loginAsAdmin }) => {
await loginAsAdmin();
await page.goto('/users');
});
test('should create new user', async ({ page }) => {
await page.click('[data-testid="create-user-button"]');
await expect(page).toHaveURL(/.*users\/create/);
await page.fill('[data-testid="name-input"]', 'Alice Cooper');
await page.fill('[data-testid="email-input"]', 'alice@example.com');
await page.selectOption('[data-testid="role-select"]', 'user');
await page.click('[data-testid="submit-button"]');
await expect(page).toHaveURL(/.*users$/);
await expect(page.locator('.success-message'))
.toContainText('User created successfully');
await expect(page.locator('.user-card', { hasText: 'Alice Cooper' }))
.toBeVisible();
});
test('should edit existing user', async ({ page }) => {
const userCard = page.locator('.user-card', { hasText: 'John Doe' });
await userCard.locator('[data-testid="edit-button"]').click();
await page.fill('[data-testid="name-input"]', 'John Updated');
await page.click('[data-testid="submit-button"]');
await expect(page.locator('.user-card', { hasText: 'John Updated' }))
.toBeVisible();
await expect(page.locator('.user-card', { hasText: 'John Doe' }))
.not.toBeVisible();
});
test('should delete user', async ({ page }) => {
const userCard = page.locator('.user-card', { hasText: 'Bob Johnson' });
await userCard.locator('[data-testid="delete-button"]').click();
// Confirm deletion
await page.click('[data-testid="confirm-delete"]');
await expect(page.locator('.success-message'))
.toContainText('User deleted');
await expect(page.locator('.user-card', { hasText: 'Bob Johnson' }))
.not.toBeVisible();
});
test('should search users', async ({ page }) => {
await page.fill('[data-testid="search-input"]', 'john');
await expect(page.locator('.user-card', { hasText: 'John' }))
.toBeVisible();
await expect(page.locator('.user-card', { hasText: 'Jane' }))
.not.toBeVisible();
});
});
e2e/responsive.spec.ts
import { test, expect } from '@playwright/test';
test.describe('Responsive Design', () => {
test('mobile view - should show hamburger menu', async ({ page }) => {
await page.setViewportSize({ width: 375, height: 667 });
await page.goto('/');
await expect(page.locator('[data-testid="hamburger-menu"]'))
.toBeVisible();
await expect(page.locator('[data-testid="desktop-nav"]'))
.not.toBeVisible();
});
test('tablet view - should show compact nav', async ({ page }) => {
await page.setViewportSize({ width: 768, height: 1024 });
await page.goto('/');
await expect(page.locator('[data-testid="tablet-nav"]'))
.toBeVisible();
});
test('desktop view - should show full navigation', async ({ page }) => {
await page.setViewportSize({ width: 1920, height: 1080 });
await page.goto('/');
await expect(page.locator('[data-testid="desktop-nav"]'))
.toBeVisible();
await expect(page.locator('[data-testid="hamburger-menu"]'))
.not.toBeVisible();
});
test('should work on iPhone 12', async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 });
await page.goto('/');
const title = page.locator('h1');
await expect(title).toBeVisible();
// Check font size is appropriate for mobile
const fontSize = await title.evaluate(el =>
window.getComputedStyle(el).fontSize
);
expect(parseInt(fontSize)).toBeLessThan(32);
});
});
.github/workflows/e2e.yml
name: E2E Tests
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
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: Build Angular app
run: npm run build
- name: Run E2E tests
run: npx playwright test
- name: Upload test results
if: always()
uses: actions/upload-artifact@v4
with:
name: playwright-report
path: playwright-report/
retention-days: 30
- name: Upload screenshots
if: failure()
uses: actions/upload-artifact@v4
with:
name: screenshots
path: test-results/
e2e/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.locator('[data-testid="email-input"]');
this.passwordInput = page.locator('[data-testid="password-input"]');
this.submitButton = page.locator('[data-testid="submit-button"]');
this.errorMessage = page.locator('.error-message');
}
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 getErrorText() {
return await this.errorMessage.textContent();
}
}
// Usage
import { test, expect } from '@playwright/test';
import { LoginPage } from './pages/login.page';
test('should login with page object', async ({ page }) => {
const loginPage = new LoginPage(page);
await loginPage.goto();
await loginPage.login('test@example.com', 'password123');
await expect(page).toHaveURL(/.*dashboard/);
});
e2e/visual.spec.ts
import { test, expect } from '@playwright/test';
test('should match homepage screenshot', async ({ page }) => {
await page.goto('/');
// Take screenshot and compare
await expect(page).toHaveScreenshot('homepage.png', {
fullPage: true,
maxDiffPixels: 100
});
});
test('should match button states', async ({ page }) => {
await page.goto('/buttons');
const button = page.locator('[data-testid="primary-button"]');
// Normal state
await expect(button).toHaveScreenshot('button-normal.png');
// Hover state
await button.hover();
await expect(button).toHaveScreenshot('button-hover.png');
// Disabled state
await button.evaluate(el => el.setAttribute('disabled', ''));
await expect(button).toHaveScreenshot('button-disabled.png');
});
  • Use data-testid attributes for stable selectors
  • Mock API calls for predictable tests
  • Test user flows, not implementation details
  • Keep tests independent (can run in any order)
  • Use fixtures for reusable test setup
  • Test critical paths thoroughly
  • Run tests in CI/CD pipeline
  • Test multiple browsers (Chromium, Firefox, WebKit)
  • Test mobile viewports for responsive design
  • Take screenshots on failure (automatic in Playwright)
  • Use Page Object pattern for complex pages
  • Avoid brittle selectors (avoid CSS classes, use data-testid)
  • Clean up test data after tests when testing real APIs
  • Keep tests fast (< 30 seconds per test)
  • Use parallel execution for faster test runs
  • Enable trace for debugging failed tests
Terminal window
# Debug mode - opens inspector
npx playwright test --debug
# Debug specific test
npx playwright test e2e/login.spec.ts --debug
# Run in headed mode to see browser
npx playwright test --headed
# Run with UI mode (best for development)
npx playwright test --ui
# Show trace viewer for failed tests
npx playwright show-trace trace.zip
// Visibility
await expect(page.locator('#element')).toBeVisible();
await expect(page.locator('#element')).not.toBeVisible();
await expect(page.locator('#element')).toBeHidden();
// Text content
await expect(page.locator('h1')).toHaveText('Welcome');
await expect(page.locator('h1')).toContainText('Wel');
// Attributes
await expect(page.locator('button')).toBeDisabled();
await expect(page.locator('button')).toBeEnabled();
await expect(page.locator('input')).toHaveValue('test');
await expect(page.locator('a')).toHaveAttribute('href', '/home');
// Count
await expect(page.locator('.user-card')).toHaveCount(5);
// URL
await expect(page).toHaveURL(/.*dashboard/);
await expect(page).toHaveURL('http://localhost:4200/dashboard');
// Screenshot comparison
await expect(page).toHaveScreenshot('page.png');