E2E Testing with Playwright
Setup & Installation
- Install Playwright
- Folder Structure
# Install Playwright (recommended for Angular)
npm init playwright@latest
# Install browsers
npx playwright install
project/
├── e2e/
│ ├── login.spec.ts
│ ├── users.spec.ts
│ └── fixtures.ts
├── playwright.config.ts
└── package.json
Running E2E Tests
# 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
Configuration
- playwright.config.ts
- package.json Scripts
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,
}
});
{
"scripts": {
"e2e": "playwright test",
"e2e:ui": "playwright test --ui",
"e2e:headed": "playwright test --headed",
"e2e:debug": "playwright test --debug",
"e2e:chrome": "playwright test --project=chromium",
"e2e:report": "playwright show-report"
}
}
Basic Login Test
- Test File
- Component HTML
- Run This Test
// 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');
});
});
<!-- login.component.html -->
<form [formGroup]="loginForm" (ngSubmit)="onSubmit()">
<input
data-testid="email-input"
formControlName="email"
type="email"
placeholder="Email">
<input
data-testid="password-input"
formControlName="password"
type="password"
placeholder="Password">
<div class="error-message" *ngIf="error">
{{ error }}
</div>
<button
data-testid="submit-button"
type="submit"
[disabled]="loginForm.invalid">
Login
</button>
</form>
# Run just this test
npx playwright test e2e/login.spec.ts
# Run in UI mode
npx playwright test e2e/login.spec.ts --ui
# Run in debug mode
npx playwright test e2e/login.spec.ts --debug
API Mocking
- Test with Mock
- Component
// 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');
});
});
import { Component, inject, OnInit } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { catchError, of } from 'rxjs';
@Component({
selector: 'app-users',
standalone: true,
template: `
<div *ngIf="error" class="error-message">{{ error }}</div>
<div class="user-card" *ngFor="let user of users">
<h3>{{ user.name }}</h3>
<p>{{ user.email }}</p>
</div>
`
})
export class UsersComponent implements OnInit {
http = inject(HttpClient);
users: User[] = [];
error = '';
ngOnInit() {
this.http.get<User[]>('/api/users').pipe(
catchError(err => {
this.error = 'Failed to load users';
return of([]);
})
).subscribe(users => this.users = users);
}
}
Reusable Fixtures
- Fixture Definition
- Using Fixtures
// 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/dashboard.spec.ts
import { test, expect } from './fixtures';
test('should access user dashboard', async ({ page, loginAsUser }) => {
await loginAsUser();
await expect(page.locator('[data-testid="dashboard-title"]'))
.toBeVisible();
await expect(page.locator('[data-testid="user-profile"]'))
.toContainText('test@example.com');
});
test('should access admin panel', async ({ page, loginAsAdmin }) => {
await loginAsAdmin();
await page.goto('/admin');
await expect(page.locator('[data-testid="admin-panel"]'))
.toBeVisible();
});
test('should login with custom credentials', async ({ page, loginAsUser }) => {
await loginAsUser('custom@example.com', 'custompass');
await expect(page).toHaveURL(/.*dashboard/);
});
Complete CRUD Flow
- CRUD Test
- User List Component
// 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();
});
});
import { Component, inject, signal } from '@angular/core';
import { Router } from '@angular/router';
import { UserService } from './user.service';
@Component({
selector: 'app-users',
standalone: true,
template: `
<button data-testid="create-user-button" (click)="createUser()">
Create User
</button>
<input
data-testid="search-input"
[(ngModel)]="searchTerm"
(input)="search()"
placeholder="Search users">
<div class="success-message" *ngIf="message">{{ message }}</div>
<div class="user-card" *ngFor="let user of filteredUsers()">
<h3>{{ user.name }}</h3>
<p>{{ user.email }}</p>
<button
data-testid="edit-button"
(click)="editUser(user.id)">
Edit
</button>
<button
data-testid="delete-button"
(click)="deleteUser(user.id)">
Delete
</button>
</div>
`
})
export class UsersComponent {
router = inject(Router);
userService = inject(UserService);
users = signal<User[]>([]);
filteredUsers = signal<User[]>([]);
searchTerm = '';
message = '';
createUser() {
this.router.navigate(['/users/create']);
}
editUser(id: number) {
this.router.navigate(['/users', id, 'edit']);
}
deleteUser(id: number) {
this.userService.delete(id).subscribe(() => {
this.message = 'User deleted';
this.loadUsers();
});
}
search() {
const term = this.searchTerm.toLowerCase();
this.filteredUsers.set(
this.users().filter(u => u.name.toLowerCase().includes(term))
);
}
}
Responsive & Mobile Testing
// 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);
});
});
CI/CD Integration
- GitHub Actions
- GitLab CI
# .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/
# .gitlab-ci.yml
e2e-tests:
image: mcr.microsoft.com/playwright:v1.40.0-focal
stage: test
before_script:
- npm ci
- npx playwright install
script:
- npm run build
- npx playwright test
artifacts:
when: always
paths:
- playwright-report/
- test-results/
expire_in: 1 week
Advanced Patterns
Page Object Model
// 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/);
});
Visual Regression Testing
// 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');
});
Best Practices
- 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
Debugging Tips
- Debug Commands
- Debug in Code
# 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
import { test, expect } from '@playwright/test';
test('debugging example', async ({ page }) => {
await page.goto('/login');
// Pause execution (opens inspector in headed mode)
await page.pause();
// Take screenshot
await page.screenshot({ path: 'debug.png' });
// Log element text
const title = await page.locator('h1').textContent();
console.log('Page title:', title);
// Log page title and URL
console.log('Title:', await page.title());
console.log('URL:', page.url());
// Wait for specific state
await page.waitForLoadState('networkidle');
// Check if element exists
const exists = await page.locator('.error-message').isVisible();
console.log('Error visible:', exists);
});
Common Assertions
// 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');