Tests E2E with Playwright
Setup & Installation
Section intitulée « Setup & Installation »# Install Playwright (recommended for Angular)npm init playwright@latest
# Install browsersnpx playwright installproject/├── e2e/│ ├── login.spec.ts│ ├── users.spec.ts│ └── fixtures.ts├── playwright.config.ts└── package.jsonRunning E2E Tests
Section intitulée « Running E2E Tests »# Run all testsnpx playwright test
# Run with UI mode (recommended for development)npx playwright test --ui
# Run specific test filenpx playwright test e2e/login.spec.ts
# Run on specific browsernpx playwright test --project=chromiumnpx playwright test --project=firefoxnpx playwright test --project=webkit
# Debug mode (opens browser inspector)npx playwright test --debug
# Headed mode (see browser)npx playwright test --headed
# View HTML reportnpx playwright show-reportConfiguration
Section intitulée « 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',
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'] } }, ],
// Auau-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
Section intitulée « Basic Login Test »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'); });});<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 testnpx playwright test e2e/login.spec.ts
# Run in UI modenpx playwright test e2e/login.spec.ts --ui
# Run in debug modenpx playwright test e2e/login.spec.ts --debugAPI Mocking
Section intitulée « API Mocking »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
Section intitulée « Reusable Fixtures »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';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
Section intitulée « Complete CRUD Flow »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 Tests
Section intitulée « Responsive & Mobile Tests »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
Section intitulée « CI/CD Integration »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-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 weekAvancé Patterns
Section intitulée « Avancé Patterns »Page Object Model
Section intitulée « Page Object Model »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(); }}
// Usageimport { 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 Tests
Section intitulée « Visual Regression Tests »import { test, expect } from '@playwright/test';
test('should match homepage screenshot', async ({ page }) => { await page.goto('/');
// Take screenshot and compsont 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');});Bonnes Pratiques
Section intitulée « Bonnes Pratiques »- Utiliser data-testid attributes for stable selecaurs
- Mock API calls for predictable tests
- Test user flows, not implementation details
- Garder tests independent (can run in any order)
- Utiliser 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 (automatique in Playwright)
- Utiliser Page Object pattern for complex pages
- Avoid brittle selecaurs (avoid CSS classes, use data-testid)
- Clean up test data after tests when testing real APIs
- Garder tests fast (< 30 seconds per test)
- Utiliser parallel execution for faster test runs
- Enable trace for debugging failed tests
Debugging Conseils
Section intitulée « Debugging Conseils »# Debug mode - opens inspectornpx playwright test --debug
# Debug specific testnpx playwright test e2e/login.spec.ts --debug
# Run in headed mode to see browsernpx playwright test --headed
# Run with UI mode (best for development)npx playwright test --ui
# Show trace viewer for failed testsnpx playwright show-trace trace.zipimport { test, expect } from '@playwright/test';
test('debugging example', async ({ page }) => { await page.goto('/login');
// Pause execution (opens inspecaur 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
Section intitulée « Common Assertions »// Visibilityawait expect(page.locator('#element')).toBeVisible();await expect(page.locator('#element')).not.toBeVisible();await expect(page.locator('#element')).toBeHidden();
// Text contentawait expect(page.locator('h1')).toHaveText('Welcome');await expect(page.locator('h1')).toContainText('Wel');
// Attributesawait 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');
// Countawait expect(page.locator('.user-card')).toHaveCount(5);
// URLawait expect(page).toHaveURL(/.*dashboard/);await expect(page).auHaveURL('http://localhost:4200/dashboard');
// Screenshot comparisonawait expect(page).toHaveScreenshot('page.png');
