Tests e2e Angular avec Playwright : locators, fixtures, storageState, page.route() pour mocker HTTP, parallélisation et intégration GitHub Actions CI.
Playwright vs Cypress vs Selenium en 2026
En 2025-2026, Playwright est devenu le standard de facto pour les tests end-to-end dans l'écosystème JavaScript. Développé par Microsoft, il s'est imposé face à Cypress et Selenium grâce à une architecture repensée, un support multi-navigateur natif et une intégration Angular sans friction.
Pourquoi autant d'équipes Angular migrent-elles vers Playwright ? La réponse tient en trois points : isolation complète des tests, parallélisation native et support de tous les navigateurs majeurs sans configuration particulière.
| Critère | Playwright | Cypress | Selenium |
|---|---|---|---|
| Multi-navigateur | Chrome, Firefox, Safari, Edge | Chrome, Firefox, Edge (Safari limité) | Tous (via WebDriver) |
| Isolation des tests | Contexte isolé par test (BrowserContext) | Cookies/localStorage partagés | Manuelle |
| Parallélisation | Native, multi-worker | Payante (Cypress Cloud) | Via Selenium Grid |
| API async/await | Native (TypeScript first) | Chaîning custom (pas async/await) | Variable selon binding |
| Component Testing | Oui (Angular, React, Vue) | Oui | Non |
| Rapport HTML intégré | Oui (built-in) | Dashboard payant | Non (plugins tiers) |
| Licence | Apache 2.0 (100% gratuit) | MIT (dashboard payant) | Apache 2.0 |
L'équipe Angular elle-même a publié un guide officiel recommandant Playwright comme solution e2e de référence pour les nouveaux projets. La CLI Angular 17+ propose d'ailleurs Playwright comme option d'initialisation lors de la création d'un nouveau projet.
Pourquoi Playwright convient particulièrement à Angular
- Auto-wait natif : Playwright attend automatiquement que les éléments Angular soient stables avant d'interagir — plus de
cy.wait()arbitraires - TypeScript natif : les fichiers
.spec.tsfonctionnent sans configuration, avec autocomplétion complète - Network interception : mocker les appels HTTP Angular (
HttpClient) est trivial avecpage.route() - Tracing intégré : enregistrement des actions, screenshots et network logs pour déboguer les CI en échec
Installation et configuration dans Angular
L'installation de Playwright dans un projet Angular existant se fait en quelques minutes. Playwright propose un installeur interactif qui génère la configuration adaptée à votre stack.
Étape 1 — Installer Playwright
# Dans la racine de votre projet Angular
# L'installeur interactif pose les questions essentielles
npm init playwright@latest
# Ou avec une configuration silencieuse (recommandé en CI)
npm init playwright@latest -- --quiet --browser=chromium --browser=firefox --browser=webkit --lang=TypeScript
L'installeur crée les fichiers suivants :
playwright.config.ts— configuration centralee2e/— dossier des tests (outests/selon votre choix)e2e/example.spec.ts— exemple de test.github/workflows/playwright.yml— workflow GitHub Actions (optionnel)
Étape 2 — Configurer playwright.config.ts
// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
// Dossier contenant les fichiers de test
testDir: './e2e',
// Lance tous les tests en parallèle (un worker par fichier)
fullyParallel: true,
// Échoue le build CI si des tests ont été oubliés en mode .only
forbidOnly: !!process.env['CI'],
// Nombre de tentatives en cas d'échec (0 en local, 2 en CI)
retries: process.env['CI'] ? 2 : 0,
// Nombre de workers parallèles (undefined = CPU count en CI)
workers: process.env['CI'] ? 1 : undefined,
// Générer un rapport HTML interactif après les tests
reporter: [
['html', { outputFolder: 'playwright-report', open: 'never' }],
['list'] // Affichage console en temps réel
],
// Configuration commune à tous les projets
use: {
// URL de base de l'application Angular en développement
baseURL: 'http://localhost:4200',
// Enregistrer une trace complète en cas d'échec (pour debug)
trace: 'on-first-retry',
// Captures d'écran automatiques en cas d'échec
screenshot: 'only-on-failure',
// Enregistrement vidéo en cas d'échec (très utile en CI)
video: 'retain-on-failure',
},
// Projets = matrices de navigateurs et appareils
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
{
name: 'firefox',
use: { ...devices['Desktop Firefox'] },
},
{
name: 'webkit',
use: { ...devices['Desktop Safari'] },
},
// Tests mobiles (optionnel mais recommandé)
{
name: 'mobile-chrome',
use: { ...devices['Pixel 5'] },
},
],
// Démarrer le serveur Angular avant les tests
webServer: {
// Commande pour lancer ng serve
command: 'npm start',
// Port attendu — Playwright attend que le serveur réponde
url: 'http://localhost:4200',
// Réutiliser le serveur s'il est déjà lancé (évite rebuild)
reuseExistingServer: !process.env['CI'],
// Timeout de démarrage (Angular peut mettre 30s à compiler)
timeout: 120 * 1000,
},
});
webServer est particulièrement pratique : Playwright lance automatiquement ng serve avant les tests et l'arrête à la fin. En CI, le flag reuseExistingServer: false force un démarrage propre à chaque run.
Étape 3 — Ajouter les scripts npm
// package.json — ajouter les scripts Playwright
{
"scripts": {
"start": "ng serve",
"build": "ng build",
"test": "ng test",
// Lancer tous les tests e2e Playwright
"e2e": "playwright test",
// Interface graphique interactive (développement local)
"e2e:ui": "playwright test --ui",
// Mode debug avec inspecteur Playwright
"e2e:debug": "playwright test --debug",
// Rapport HTML du dernier run
"e2e:report": "playwright show-report"
}
}
Étape 4 — Installer les navigateurs
# Télécharger les binaires Chromium, Firefox et WebKit
# (environ 300MB au total)
npx playwright install
# En CI, installer également les dépendances système (Linux)
npx playwright install --with-deps
Premiers tests : navigation, locators et assertions
Un test Playwright est un fichier TypeScript .spec.ts qui importe les fonctions test et expect depuis @playwright/test. L'API est conçue pour être lisible et auto-documentée.
Structure de base d'un test
// e2e/home.spec.ts
import { test, expect } from '@playwright/test';
// test.describe() regroupe les tests liés dans une suite
test.describe('Page d\'accueil', () => {
// test.beforeEach() s'exécute avant chaque test de la suite
test.beforeEach(async ({ page }) => {
// Naviguer vers la racine de l'app Angular
// baseURL est configuré dans playwright.config.ts
await page.goto('/');
});
test('doit afficher le titre principal', async ({ page }) => {
// Localiser l'élément h1 par son rôle ARIA
const heading = page.getByRole('heading', { level: 1 });
// Vérifier qu'il est visible à l'écran
await expect(heading).toBeVisible();
// Vérifier le contenu textuel
await expect(heading).toHaveText('Bienvenue sur MonApp');
});
test('doit afficher le menu de navigation', async ({ page }) => {
// Localiser la navigation par son rôle ARIA
const nav = page.getByRole('navigation');
// Vérifier que la nav est présente
await expect(nav).toBeVisible();
// Compter le nombre de liens dans la nav
const navLinks = nav.getByRole('link');
await expect(navLinks).toHaveCount(5);
});
test('doit naviguer vers la page produits', async ({ page }) => {
// Cliquer sur un lien de navigation par son texte
await page.getByRole('link', { name: 'Produits' }).click();
// Vérifier que l'URL a changé (Angular Router)
await expect(page).toHaveURL('/produits');
// Vérifier que le titre de la page produits s'affiche
await expect(page.getByRole('heading', { level: 1 }))
.toHaveText('Nos produits');
});
});
Interagir avec les formulaires Angular
// e2e/contact-form.spec.ts
import { test, expect } from '@playwright/test';
test.describe('Formulaire de contact', () => {
test('doit soumettre le formulaire avec succès', async ({ page }) => {
await page.goto('/contact');
// Remplir le champ nom par son label (accessible)
await page.getByLabel('Votre nom').fill('Alice Dupont');
// Remplir l'email
await page.getByLabel('Adresse email').fill('alice@example.com');
// Sélectionner dans un select
await page.getByLabel('Sujet').selectOption('support');
// Remplir un textarea
await page.getByLabel('Message').fill(
'Bonjour, j\'ai une question sur votre service.'
);
// Cocher une case (RGPD)
await page.getByRole('checkbox', { name: /j'accepte/i }).check();
// Soumettre le formulaire
await page.getByRole('button', { name: 'Envoyer' }).click();
// Vérifier le message de succès affiché par Angular
await expect(page.getByRole('alert'))
.toHaveText('Votre message a bien été envoyé !');
});
test('doit afficher les erreurs de validation', async ({ page }) => {
await page.goto('/contact');
// Soumettre sans remplir les champs obligatoires
await page.getByRole('button', { name: 'Envoyer' }).click();
// Vérifier les messages d'erreur Angular Reactive Forms
await expect(page.getByText('Le nom est obligatoire'))
.toBeVisible();
await expect(page.getByText('L\'email est invalide'))
.toBeVisible();
});
});
Attentes et assertions disponibles
| Assertion | Description |
|---|---|
toBeVisible() |
L'élément est visible dans le viewport |
toHaveText(text) |
Le contenu textuel correspond (partiel ou exact) |
toHaveValue(value) |
La valeur d'un input correspond |
toHaveCount(n) |
Le locator correspond à exactement N éléments |
toBeEnabled() |
Le bouton ou input n'est pas désactivé |
toBeChecked() |
La checkbox est cochée |
toHaveURL(pattern) |
L'URL courante correspond (string ou regex) |
toHaveTitle(text) |
Le titre de la page correspond |
toBeHidden() |
L'élément est masqué (display:none ou visibility:hidden) |
toHaveAttribute(name, value) |
L'attribut HTML a la valeur attendue |
await page.waitForTimeout(1000) — Playwright gère l'asynchronisme Angular nativement.
Locators : bonnes pratiques et anti-patterns
Le choix des locators est la décision la plus importante dans l'écriture de tests e2e. Un mauvais locator rend les tests fragiles : ils cassent à chaque refactoring CSS ou renommage de classe. Playwright propose des locators orientés comportement utilisateur qui résistent aux changements d'implémentation.
Hiérarchie des locators recommandés
// e2e/locators-examples.spec.ts
import { test, expect } from '@playwright/test';
test('exemples de locators Playwright', async ({ page }) => {
await page.goto('/products');
// ✅ PRIORITÉ 1 : getByRole() — sémantique ARIA
// Le plus résistant aux refactorings, teste vraiment l'accessibilité
const addButton = page.getByRole('button', { name: 'Ajouter au panier' });
const productList = page.getByRole('list', { name: 'Liste des produits' });
const searchInput = page.getByRole('searchbox');
// ✅ PRIORITÉ 2 : getByLabel() — associe un input à son label
// Idéal pour les formulaires Angular, teste aussi l'accessibilité
const emailField = page.getByLabel('Adresse email');
const passwordField = page.getByLabel('Mot de passe');
// ✅ PRIORITÉ 3 : getByText() — pour les éléments textuels statiques
// Attention : sensible à la casse par défaut
const title = page.getByText('Nos produits phares', { exact: true });
const anyMention = page.getByText(/prix réduit/i); // regex insensible à la casse
// ✅ PRIORITÉ 4 : getByTestId() — attribut data-testid dédié aux tests
// Meilleur compromis quand pas de rôle ARIA disponible
const productCard = page.getByTestId('product-card-42');
const cartCount = page.getByTestId('cart-count');
// ✅ PRIORITÉ 5 : getByPlaceholder() — pour inputs sans label visible
const searchBox = page.getByPlaceholder('Rechercher un produit...');
// ⚠️ ACCEPTABLE : locator() avec sélecteur CSS — seulement si nécessaire
// Fragile si le CSS change, mais parfois inévitable
const badge = page.locator('.badge.badge-primary');
// ❌ À ÉVITER : locators par position ou index
// Cassent à chaque changement de structure DOM
const firstItem = page.locator('li').nth(0); // Fragile
const lastButton = page.locator('button').last(); // Fragile
const byXpath = page.locator('xpath=//div[3]/p'); // Très fragile
});
Ajouter data-testid dans vos composants Angular
<!-- Composant Angular : product-card.component.html -->
<!-- Attribut data-testid : uniquement pour les tests, ne change jamais -->
<article class="product-card" [attr.data-testid]="'product-card-' + product.id">
<img [src]="product.image" [alt]="product.name">
<!-- Titre accessible : rôle heading détectable par getByRole() -->
<h3>{{ product.name }}</h3>
<!-- Prix avec testid pour ciblage précis -->
<span data-testid="product-price">{{ product.price | currency:'EUR' }}</span>
<!-- Bouton avec aria-label pour getByRole() -->
<button
[attr.aria-label]="'Ajouter ' + product.name + ' au panier'"
(click)="addToCart(product)"
data-testid="add-to-cart-btn">
Ajouter au panier
</button>
</article>
data-testid uniquement pour les éléments sans rôle ARIA naturel. Évitez de mettre des data-testid partout — cela crée une maintenance supplémentaire. La priorité reste getByRole() et getByLabel().
Chaînage et filtrage de locators
// Playwright permet de chaîner les locators pour affiner la sélection
test('chaînage de locators', async ({ page }) => {
await page.goto('/products');
// Cibler un élément DANS un autre — évite les faux positifs
const productSection = page.getByRole('region', { name: 'Produits en vedette' });
// Chercher uniquement dans la section vedette
const featuredPrices = productSection.getByTestId('product-price');
// Vérifier que tous les prix de la section sont affichés
await expect(featuredPrices).toHaveCount(3);
// Filtrer par texte parmi plusieurs locators du même type
const expensiveProduct = page.getByTestId('product-card')
.filter({ hasText: 'Premium' });
await expect(expensiveProduct).toBeVisible();
// filter() avec un locator imbriqué
const inStockCards = page.getByTestId('product-card').filter({
has: page.getByText('En stock')
});
await expect(inStockCards).toHaveCount(2);
});
Fixtures personnalisées pour factoriser le code
// e2e/fixtures/app.fixture.ts
import { test as base, expect } from '@playwright/test';
// Définir un type pour nos fixtures personnalisées
type AppFixtures = {
// Page pré-naviguée vers /products
productsPage: ReturnType<typeof base.extend>;
};
// Étendre le test de base avec nos fixtures
export const test = base.extend<AppFixtures>({
// Fixture "productsPage" : navigue automatiquement avant chaque test
productsPage: async ({ page }, use) => {
// Setup : naviguer vers la page produits
await page.goto('/products');
// Attendre que Angular ait fini de rendre les produits
await page.waitForSelector('[data-testid="product-card"]');
// Exposer la page prête à l'utilisation dans le test
await use(page);
// Teardown (optionnel) : nettoyage après le test
// (rien à faire ici, le contexte est isolé par défaut)
},
});
// Ré-exporter expect pour usage dans les specs
export { expect };
// e2e/products.spec.ts — utiliser la fixture personnalisée
// Importer depuis notre fixture au lieu de @playwright/test
import { test, expect } from './fixtures/app.fixture';
// La fixture "productsPage" est injectée automatiquement
test('doit filtrer les produits par catégorie', async ({ productsPage }) => {
// La page est déjà sur /products avec les produits chargés
// Cliquer sur le filtre "Électronique"
await productsPage.getByRole('radio', { name: 'Électronique' }).check();
// Vérifier que la liste se met à jour (Angular reactive filtering)
await expect(productsPage.getByTestId('product-card'))
.toHaveCount(4);
});
Gérer l'authentification avec storageState
La plupart des applications Angular comportent des pages protégées par authentification. Effectuer le login à chaque test est lent et fragile. Playwright propose storageState pour sauvegarder l'état de la session (cookies, localStorage contenant le JWT) et le réutiliser entre les tests.
Étape 1 — Créer un script de login global
// e2e/auth/global-setup.ts
import { chromium, FullConfig } from '@playwright/test';
// Ce script s'exécute une seule fois avant tous les tests
async function globalSetup(config: FullConfig) {
// Ouvrir un navigateur en mode headless
const browser = await chromium.launch();
// Créer un contexte isolé pour le login
const context = await browser.newContext();
const page = await context.newPage();
// Naviguer vers la page de login
await page.goto('http://localhost:4200/login');
// Remplir les identifiants de test
await page.getByLabel('Email').fill(process.env['TEST_USER_EMAIL'] ?? 'test@example.com');
await page.getByLabel('Mot de passe').fill(process.env['TEST_USER_PASSWORD'] ?? 'test-password-123');
// Soumettre le formulaire
await page.getByRole('button', { name: 'Se connecter' }).click();
// Attendre la redirection post-login (Angular Router)
await page.waitForURL('/dashboard');
// Sauvegarder l'état complet de la session (cookies + localStorage)
// Ce fichier sera lu par les tests qui nécessitent une session active
await context.storageState({ path: '.auth/user.json' });
await browser.close();
}
export default globalSetup;
Étape 2 — Configurer le globalSetup dans playwright.config.ts
// playwright.config.ts — ajouter globalSetup
import { defineConfig } from '@playwright/test';
export default defineConfig({
// Exécuter le script de login une seule fois avant tous les tests
globalSetup: require.resolve('./e2e/auth/global-setup'),
use: {
baseURL: 'http://localhost:4200',
// Toutes les pages utilisent l'état sauvegardé par défaut
storageState: '.auth/user.json',
},
// ... reste de la config
});
Étape 3 — Tests authentifiés et non authentifiés
// e2e/dashboard.spec.ts — test authentifié (utilise storageState par défaut)
import { test, expect } from '@playwright/test';
test.describe('Dashboard (authentifié)', () => {
test('doit afficher le tableau de bord utilisateur', async ({ page }) => {
// storageState est automatiquement chargé — pas besoin de se logger
await page.goto('/dashboard');
// L'utilisateur est déjà connecté
await expect(page.getByRole('heading', { name: 'Mon tableau de bord' }))
.toBeVisible();
// Vérifier que le nom de l'utilisateur apparaît dans le header
await expect(page.getByTestId('user-greeting'))
.toHaveText(/Bonjour, Alice/);
});
});
// e2e/login.spec.ts — test NON authentifié (réinitialise le storageState)
import { test, expect } from '@playwright/test';
test.describe('Page de login (non authentifié)', () => {
// Remplacer le storageState pour ce groupe de tests
test.use({ storageState: { cookies: [], origins: [] } });
test('doit rediriger vers /login si non connecté', async ({ page }) => {
// Tenter d'accéder au dashboard sans session
await page.goto('/dashboard');
// Angular AuthGuard doit rediriger vers /login
await expect(page).toHaveURL('/login');
});
test('doit afficher un message d\'erreur pour mauvais mdp', async ({ page }) => {
await page.goto('/login');
await page.getByLabel('Email').fill('alice@example.com');
await page.getByLabel('Mot de passe').fill('mauvais-mot-de-passe');
await page.getByRole('button', { name: 'Se connecter' }).click();
// Vérifier le message d'erreur renvoyé par l'API
await expect(page.getByRole('alert'))
.toHaveText('Identifiants incorrects');
});
});
storageState, le login s'effectue une seule fois pour toute la suite de tests, peu importe le nombre de specs. Sur une suite de 50 tests, cela économise facilement 2 à 3 minutes de CI.
Gérer plusieurs rôles utilisateurs
// e2e/auth/global-setup.ts — setup multi-rôles
import { chromium } from '@playwright/test';
async function globalSetup() {
const browser = await chromium.launch();
// Helper pour se logger et sauvegarder l'état
async function loginAs(email: string, password: string, storageFile: string) {
const context = await browser.newContext();
const page = await context.newPage();
await page.goto('http://localhost:4200/login');
await page.getByLabel('Email').fill(email);
await page.getByLabel('Mot de passe').fill(password);
await page.getByRole('button', { name: 'Se connecter' }).click();
await page.waitForURL(/\/(dashboard|admin)/);
// Sauvegarder un fichier d'état par rôle
await context.storageState({ path: storageFile });
await context.close();
}
// Créer des sessions pour chaque rôle
await loginAs('user@example.com', 'pass-user', '.auth/user.json');
await loginAs('admin@example.com', 'pass-admin', '.auth/admin.json');
await browser.close();
}
export default globalSetup;
// e2e/admin.spec.ts — test avec rôle admin
import { test, expect } from '@playwright/test';
// Utiliser la session admin pour ce fichier de tests
test.use({ storageState: '.auth/admin.json' });
test('doit accéder au panneau d\'administration', async ({ page }) => {
await page.goto('/admin/users');
// Page réservée aux admins
await expect(page.getByRole('heading', { name: 'Gestion des utilisateurs' }))
.toBeVisible();
});
Intercepter les appels HTTP avec page.route()
Une des fonctionnalités les plus puissantes de Playwright est la capacité d'intercepter et de mocker les requêtes HTTP. Cela permet de tester des scénarios d'erreur API, de contrôler les données affichées et d'accélérer les tests en évitant les appels réseau réels.
Mocker une réponse API
// e2e/products-mock.spec.ts
import { test, expect } from '@playwright/test';
test('doit afficher les produits depuis l\'API mockée', async ({ page }) => {
// Intercepter toutes les requêtes vers l'API produits
await page.route('**/api/products', async (route) => {
// Répondre avec des données contrôlées (jamais de dépendance backend)
await route.fulfill({
status: 200,
contentType: 'application/json',
// Données de test déterministes
body: JSON.stringify([
{ id: 1, name: 'Produit Test A', price: 29.99, stock: 5 },
{ id: 2, name: 'Produit Test B', price: 59.99, stock: 0 },
{ id: 3, name: 'Produit Test C', price: 9.99, stock: 12 },
]),
});
});
// Naviguer APRÈS avoir configuré les routes (ordre important)
await page.goto('/products');
// Vérifier que Angular a bien rendu les 3 produits mockés
await expect(page.getByTestId('product-card')).toHaveCount(3);
await expect(page.getByText('Produit Test A')).toBeVisible();
// Tester le badge "Rupture de stock" pour le produit sans stock
await expect(page.getByText('Rupture de stock')).toBeVisible();
});
Tester les scénarios d'erreur API
// Tester le comportement Angular quand l'API retourne une erreur
test('doit afficher un message d\'erreur si l\'API échoue', async ({ page }) => {
// Simuler une erreur serveur 500
await page.route('**/api/products', async (route) => {
await route.fulfill({
status: 500,
contentType: 'application/json',
body: JSON.stringify({ message: 'Erreur interne du serveur' }),
});
});
await page.goto('/products');
// Angular doit afficher le composant d'erreur
await expect(page.getByRole('alert'))
.toHaveText(/impossible de charger les produits/i);
// Le bouton "Réessayer" doit être visible
await expect(page.getByRole('button', { name: 'Réessayer' }))
.toBeVisible();
});
test('doit afficher un loader pendant le chargement', async ({ page }) => {
let resolveRequest: () => void;
// Route qui ne répond pas tout de suite (simule une lenteur réseau)
await page.route('**/api/products', async (route) => {
// Attendre le signal avant de répondre
await new Promise<void>(resolve => { resolveRequest = resolve; });
await route.fulfill({
status: 200,
body: JSON.stringify([]),
});
});
await page.goto('/products');
// Pendant le chargement, le spinner Angular doit être visible
await expect(page.getByTestId('loading-spinner')).toBeVisible();
// Débloquer la requête
resolveRequest!();
// Le spinner doit disparaître
await expect(page.getByTestId('loading-spinner')).toBeHidden();
});
Vérifier qu'une requête a été envoyée
// Tester que Angular envoie la bonne requête lors d'une action utilisateur
test('doit envoyer une requête POST lors de la commande', async ({ page }) => {
// Mock de la réponse POST
await page.route('**/api/orders', async (route) => {
// Capturer la requête pour l'inspecter
const request = route.request();
// Vérifier la méthode HTTP
expect(request.method()).toBe('POST');
// Vérifier le corps de la requête envoyée par Angular
const body = request.postDataJSON();
expect(body).toMatchObject({
productId: expect.any(Number),
quantity: expect.any(Number),
});
// Répondre avec succès
await route.fulfill({
status: 201,
body: JSON.stringify({ orderId: 'ORD-001' }),
});
});
await page.goto('/cart');
// Cliquer sur "Commander"
await page.getByRole('button', { name: 'Passer la commande' }).click();
// Vérifier la confirmation Angular affichée après la commande
await expect(page.getByText('Commande ORD-001 confirmée'))
.toBeVisible();
});
// Utiliser page.waitForResponse() pour attendre une vraie réponse API
test('doit attendre la réponse API avant de vérifier', async ({ page }) => {
await page.goto('/products');
// Attendre que la requête API soit complète avant d'asserter
const response = await page.waitForResponse('**/api/products');
// Vérifier le statut de la réponse réelle
expect(response.status()).toBe(200);
// Maintenant Angular a eu le temps de rendre les données
await expect(page.getByTestId('product-card')).toHaveCount(10);
});
page.route() avant d'appeler page.goto(). Les routes sont enregistrées comme des intercepteurs — elles captureront les requêtes dès que la page commencera à charger.
CI/CD : Playwright dans GitHub Actions
Playwright est conçu pour s'intégrer naturellement dans les pipelines CI/CD. Il fournit des rapports HTML détaillés, enregistre les traces et vidéos en cas d'échec, et parallélise les tests pour réduire le temps de build.
Workflow GitHub Actions complet
# .github/workflows/e2e.yml
name: Tests E2E Playwright
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
jobs:
e2e-tests:
name: Tests E2E
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
# Checkout du code source
- name: Checkout
uses: actions/checkout@v4
# Installer Node.js LTS
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20
# Cache npm pour accélérer les builds suivants
cache: 'npm'
# Installer les dépendances npm
- name: Install dependencies
run: npm ci
# Installer les navigateurs Playwright + dépendances système Linux
- name: Install Playwright browsers
run: npx playwright install --with-deps
# Builder l'application Angular en mode production
- name: Build Angular app
run: npm run build
# Lancer les tests Playwright
# Le webServer démarre ng serve en arrière-plan automatiquement
- name: Run Playwright tests
run: npx playwright test
env:
# Variables d'environnement pour les tests (credentials)
TEST_USER_EMAIL: ${{ secrets.TEST_USER_EMAIL }}
TEST_USER_PASSWORD: ${{ secrets.TEST_USER_PASSWORD }}
# Indiquer à Playwright qu'on est en CI
CI: true
# Publier le rapport HTML Playwright (même en cas d'échec)
- name: Upload Playwright report
uses: actions/upload-artifact@v4
if: always()
with:
name: playwright-report
path: playwright-report/
# Garder le rapport 30 jours
retention-days: 30
# Uploader les traces/vidéos des tests en échec
- name: Upload test traces on failure
uses: actions/upload-artifact@v4
if: failure()
with:
name: playwright-traces
path: test-results/
retention-days: 7
Optimiser les temps de CI avec la sharding
# Distribuer les tests sur plusieurs runners en parallèle
# Réduit le temps de CI d'un facteur N (nombre de shards)
jobs:
playwright-shard:
name: "Tests E2E (shard ${{ matrix.shardIndex }}/${{ matrix.shardTotal }})"
runs-on: ubuntu-latest
strategy:
matrix:
# Répartir sur 4 machines en parallèle
shardIndex: [1, 2, 3, 4]
shardTotal: [4]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 20, cache: 'npm' }
- run: npm ci
- run: npx playwright install --with-deps
# Chaque runner n'exécute qu'un quart des tests
- name: Run Playwright shard
run: npx playwright test --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }}
# Uploader les blobs de résultats pour fusion
- uses: actions/upload-artifact@v4
if: always()
with:
name: blob-report-${{ matrix.shardIndex }}
path: blob-report/
retention-days: 1
merge-reports:
name: Merge Playwright reports
needs: playwright-shard
runs-on: ubuntu-latest
if: always()
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 20, cache: 'npm' }
- run: npm ci
# Télécharger tous les blobs partiels
- uses: actions/download-artifact@v4
with:
path: all-blob-reports
pattern: blob-report-*
merge-multiple: true
# Fusionner les rapports en un seul rapport HTML
- name: Merge into HTML report
run: npx playwright merge-reports --reporter html ./all-blob-reports
# Publier le rapport final
- uses: actions/upload-artifact@v4
with:
name: playwright-report-merged
path: playwright-report/
retention-days: 14
Analyser le rapport HTML Playwright
Playwright génère un rapport HTML interactif consultable localement après chaque run :
# Ouvrir le rapport du dernier run dans le navigateur
npx playwright show-report
# Ou après avoir téléchargé l'artifact CI :
npx playwright show-report ./playwright-report
Le rapport HTML permet de :
- Filtrer les tests par statut (passed, failed, flaky, skipped)
- Rejouer les tests en échec étape par étape (timeline des actions)
- Visionner les vidéos et captures d'écran automatiques
- Inspecter les traces réseau (requêtes HTTP, temps de réponse)
- Rejouer la trace complète dans l'interface Playwright Trace Viewer
retries: 2 dans playwright.config.ts uniquement pour la CI. Les tests qui passent au 2e ou 3e essai sont marqués comme "flaky" dans le rapport — un signal précieux pour identifier les tests instables à corriger.
Checklist avant de pousser en production
- Tests organisés par feature (
e2e/auth/,e2e/products/,e2e/checkout/) - Locators prioritairement
getByRole()etgetByLabel() -
storageStateconfiguré pour éviter le re-login à chaque test - API mockée pour les scénarios d'erreur et les données instables
-
retries: 2activé en CI uniquement - Artifact du rapport HTML uploadé dans le workflow CI
- Variables sensibles (credentials) dans les secrets GitHub, pas en dur
-
page.waitForTimeout()absent du code (remplacé par des assertions auto-wait) - Tests indépendants les uns des autres (pas de dépendance d'ordre)
-
test.onlyabsent du code (bloqué parforbidOnly: !!CI)
Conclusion : stratégie testing complète
Playwright s'est imposé comme la solution de référence pour les tests e2e dans l'écosystème Angular. Son architecture orientée isolation, son API TypeScript native et son intégration CI/CD clé en main en font un choix solide pour toute équipe souhaitant des tests fiables et maintenables. La courbe d'apprentissage est courte, surtout si vous êtes déjà familier avec les concepts de testing Angular.
La stratégie recommandée pour un projet Angular complet combine trois niveaux : Vitest (ou Jest) pour les tests unitaires des services et pipes, TestBed Angular pour les tests d'intégration des composants, et Playwright pour les scénarios e2e critiques — les parcours utilisateurs qui valident l'ensemble du système. Cette pyramide de tests donne confiance lors des déploiements sans sur-tester chaque détail d'implémentation.