<link rel="stylesheet" href="/assets/fonts/jetbrains-mono/jetbrains-mono.css" />
All posts

Tests et débogage en Angular : Guide complet de Jest, Jasmine/Karma, Cypress, Playwright et Angular Devtools

Introduction : Pourquoi les Tests et le Débogage Font la Différence

Chaque application Angular qui atteint la production finit par se heurter à la même question : "Comment savoir que ce changement n'a rien cassé ?". La réponse sérieuse n'est pas "je l'ai testé à la main dans le navigateur", mais une suite de tests automatisés qui s'exécute en quelques secondes, à chaque commit, sans intervention humaine. En même temps, quand quelque chose ne va pas — un composant qui se re-rend trop souvent, une memory leak, une route qui ne se charge pas — il faut des outils de débogage ciblés pour comprendre quoi se passe et où, au lieu de s'appuyer sur des console.log disséminés partout.

Dans ce guide, nous approfondissons trois piliers du cycle de vie d'une application Angular mature :

  • Les tests unitaires avec Jasmine/Karma (le choix par défaut d'Angular CLI) et avec Jest (l'alternative plus rapide et de plus en plus populaire dans l'écosystème JavaScript moderne).
  • Les tests E2E (end-to-end) avec Cypress et Playwright, pour valider des parcours utilisateurs complets dans un navigateur réel.
  • Le débogage des performances avec Angular DevTools, pour trouver les goulets d'étranglement dans la détection de changements et le rendu.

Chaque section inclut des exemples de code prêts à l'emploi, tirés de scénarios réels impliquant des composants, des services, des signals et des appels HTTP — les mêmes éléments que l'on retrouve dans n'importe quelle application Angular en production.

Sujets : #Angular #Testing #UnitTesting #Jest #Jasmine #Karma #Cypress #Playwright #E2ETesting #AngularDevTools #Debugging #Performance #WebDevelopment

La Pyramide des Tests : Où Investir Son Temps

Avant d'écrire la moindre ligne de test, il vaut la peine d'avoir en tête le modèle mental de la pyramide des tests, proposée à l'origine par Mike Cohn :

  • Base (large) : Tests unitaires. Rapides (millisecondes), isolés, nombreux. Ils testent une fonction, un composant ou un service isolément.
  • Milieu : Tests d'intégration. Vérifient que plusieurs unités collaborent correctement (ex. un composant avec son service réel, non mocké).
  • Sommet (étroit) : Tests E2E. Lents (secondes/minutes), fragiles s'ils sont mal écrits, mais les seuls à valider l'expérience utilisateur réelle, du login jusqu'au checkout.

L'erreur la plus courante est d'inverser la pyramide : peu de tests unitaires et des dizaines de tests E2E lents et fragiles qui transforment la CI en un cauchemar de 40 minutes. L'objectif de ce guide est de vous donner les outils pour construire la pyramide dans le bon sens : beaucoup de tests unitaires rapides, un nombre raisonnable de tests E2E sur les parcours critiques, et des outils de débogage pour quand quelque chose échappe malgré tout aux tests.

Partie 1 : Tests Unitaires avec Jest et Jasmine/Karma

1.1 — La configuration par défaut : Jasmine + Karma

Lorsque vous créez un projet avec ng new, Angular CLI configure automatiquement Jasmine comme framework d'assertions (describe, it, expect) et Karma comme test runner, qui exécute les tests dans un vrai navigateur (Chrome Headless par défaut) via karma.conf.js.

// karma.conf.js — configurazione generata da Angular CLI
process.env.CHROME_BIN = require('puppeteer').executablePath();

module.exports = function (config) {
  config.set({
    basePath: '',
    frameworks: ['jasmine', '@angular-devkit/build-angular'],
    plugins: [
      require('karma-jasmine'),
      require('karma-chrome-launcher'),
      require('karma-jasmine-html-reporter'),
      require('karma-coverage'),
      require('@angular-devkit/build-angular/plugins/karma'),
    ],
    client: {
      jasmine: {
        // random: false disabilita l'ordine casuale dei test (utile in debug)
        random: false,
      },
      clearContext: false,
    },
    coverageReporter: {
      dir: require('path').join(__dirname, './coverage/my-app'),
      subdir: '.',
      reporters: [{ type: 'html' }, { type: 'text-summary' }],
      // Soglie minime: la build fallisce se la copertura scende sotto questi valori
      check: {
        global: {
          statements: 80,
          branches: 75,
          functions: 80,
          lines: 80,
        },
      },
    },
    reporters: ['progress', 'kjhtml'],
    port: 9876,
    colors: true,
    logLevel: config.LOG_INFO,
    autoWatch: true,
    browsers: ['ChromeHeadless'],
    singleRun: true, // false in locale per il watch mode, true in CI
    restartOnFileChange: true,
  });
};

Pour lancer les tests : ng test en local (watch mode actif) ou ng test --no-watch --browsers=ChromeHeadless en CI, où un navigateur interactif n'est pas nécessaire et où l'on veut que le processus se termine avec un exit code plutôt que de rester en attente.

1.2 — Anatomie d'un test unitaire : describe, it, expect

Chaque fichier de test Jasmine suit la même structure : describe regroupe les tests logiquement liés, it (ou son alias test) définit un cas unique, expect effectue l'assertion.

// price-calculator.ts
export function applyDiscount(price: number, percentage: number): number {
  if (percentage < 0 || percentage > 100) {
    throw new Error('La percentuale di sconto deve essere tra 0 e 100');
  }
  return Number((price - (price * percentage) / 100).toFixed(2));
}

// price-calculator.spec.ts
import { applyDiscount } from './price-calculator';

describe('applyDiscount', () => {
  it('applica correttamente uno sconto del 20%', () => {
    expect(applyDiscount(100, 20)).toBe(80);
  });

  it('non modifica il prezzo con sconto 0%', () => {
    expect(applyDiscount(50, 0)).toBe(50);
  });

  it('arrotonda a due decimali', () => {
    expect(applyDiscount(19.99, 15)).toBe(16.99);
  });

  it('lancia un errore se la percentuale è negativa', () => {
    expect(() => applyDiscount(100, -5)).toThrowError(
      'La percentuale di sconto deve essere tra 0 e 100',
    );
  });

  it('lancia un errore se la percentuale supera 100', () => {
    expect(() => applyDiscount(100, 150)).toThrowError();
  });
});

C'est un test unitaire pur : aucune dépendance à Angular, pas de TestBed, juste une fonction et ses assertions. C'est le type de test le plus rapide à écrire, à lire et à exécuter — et il doit être privilégié chaque fois que la logique peut être extraite d'un composant ou d'un service vers une fonction pure, testable isolément.

1.3 — Tester un service Angular avec TestBed

Lorsque la logique dépend du DI (Dependency Injection) d'Angular, le TestBed est nécessaire pour construire un environnement de test avec les mêmes providers que l'application réelle utiliserait — en remplaçant toutefois les dépendances externes (HTTP, storage, autres services) par des mocks.

// cart.service.ts
import { Injectable, signal, computed } from '@angular/core';

export interface CartItem {
  id: string;
  price: number;
  quantity: number;
}

@Injectable({ providedIn: 'root' })
export class CartService {
  private readonly _items = signal([]);
  readonly items = this._items.asReadonly();

  readonly total = computed(() =>
    this._items().reduce((sum, item) => sum + item.price * item.quantity, 0),
  );

  readonly itemCount = computed(() =>
    this._items().reduce((sum, item) => sum + item.quantity, 0),
  );

  addItem(item: CartItem): void {
    const existing = this._items().find(i => i.id === item.id);
    if (existing) {
      this._items.update(items =>
        items.map(i => (i.id === item.id ? { ...i, quantity: i.quantity + item.quantity } : i)),
      );
    } else {
      this._items.update(items => [...items, item]);
    }
  }

  removeItem(id: string): void {
    this._items.update(items => items.filter(i => i.id !== id));
  }

  clear(): void {
    this._items.set([]);
  }
}

// cart.service.spec.ts
import { TestBed } from '@angular/core/testing';
import { CartService } from './cart.service';

describe('CartService', () => {
  let service: CartService;

  beforeEach(() => {
    TestBed.configureTestingModule({});
    service = TestBed.inject(CartService);
  });

  it('parte con carrello vuoto', () => {
    expect(service.items()).toEqual([]);
    expect(service.total()).toBe(0);
  });

  it('aggiunge un articolo e calcola il totale', () => {
    service.addItem({ id: 'p1', price: 10, quantity: 2 });
    expect(service.items().length).toBe(1);
    expect(service.total()).toBe(20);
  });

  it('incrementa la quantità se l\'articolo esiste già', () => {
    service.addItem({ id: 'p1', price: 10, quantity: 1 });
    service.addItem({ id: 'p1', price: 10, quantity: 3 });
    expect(service.items().length).toBe(1);
    expect(service.items()[0].quantity).toBe(4);
  });

  it('rimuove un articolo per id', () => {
    service.addItem({ id: 'p1', price: 10, quantity: 1 });
    service.addItem({ id: 'p2', price: 20, quantity: 1 });
    service.removeItem('p1');
    expect(service.items().length).toBe(1);
    expect(service.items()[0].id).toBe('p2');
  });

  it('itemCount somma le quantità, non il numero di righe', () => {
    service.addItem({ id: 'p1', price: 10, quantity: 3 });
    service.addItem({ id: 'p2', price: 5, quantity: 2 });
    expect(service.itemCount()).toBe(5);
  });
});

Remarquez que ce test valide aussi les signals computed : aucune configuration spéciale n'est nécessaire, les computeds se recalculent de façon synchrone et peuvent être lus comme une fonction normale (service.total()) immédiatement après le changement d'état.

1.4 — Tester un composant standalone avec TestBed et ComponentFixture

Tester un composant demande une étape de plus qu'un service : il faut créer une véritable instance du composant dans le DOM via ComponentFixture, forcer un cycle de change detection avec fixture.detectChanges(), puis interroger le DOM résultant avec DebugElement ou des requêtes natives.

// quantity-selector.component.ts
import { Component, input, output, computed } from '@angular/core';

@Component({
  selector: 'app-quantity-selector',
  standalone: true,
  template: `
    -
    {{ value() }}
    +
  `,
})
export class QuantitySelectorComponent {
  value = input(1);
  min = input(1);
  max = input(99);
  valueChange = output();

  readonly canIncrement = computed(() => this.value() < this.max());

  increment(): void {
    if (this.value() < this.max()) {
      this.valueChange.emit(this.value() + 1);
    }
  }

  decrement(): void {
    if (this.value() > this.min()) {
      this.valueChange.emit(this.value() - 1);
    }
  }
}

// quantity-selector.component.spec.ts
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { By } from '@angular/platform-browser';
import { QuantitySelectorComponent } from './quantity-selector.component';

describe('QuantitySelectorComponent', () => {
  let fixture: ComponentFixture;
  let component: QuantitySelectorComponent;

  beforeEach(async () => {
    await TestBed.configureTestingModule({
      imports: [QuantitySelectorComponent],
    }).compileComponents();

    fixture = TestBed.createComponent(QuantitySelectorComponent);
    component = fixture.componentInstance;
  });

  function getIncrementButton() {
    return fixture.debugElement.query(By.css('[data-testid="increment"]'));
  }

  function getQuantityText(): string {
    return fixture.debugElement.query(By.css('[data-testid="quantity-value"]')).nativeElement.textContent.trim();
  }

  it('mostra il valore iniziale', () => {
    fixture.componentRef.setInput('value', 3);
    fixture.detectChanges();
    expect(getQuantityText()).toBe('3');
  });

  it('emette valueChange con valore incrementato al click su +', () => {
    fixture.componentRef.setInput('value', 5);
    fixture.detectChanges();

    let emittedValue: number | undefined;
    component.valueChange.subscribe(v => (emittedValue = v));

    getIncrementButton().nativeElement.click();

    expect(emittedValue).toBe(6);
  });

  it('disabilita il pulsante + quando si raggiunge il massimo', () => {
    fixture.componentRef.setInput('value', 99);
    fixture.componentRef.setInput('max', 99);
    fixture.detectChanges();

    expect(getIncrementButton().nativeElement.disabled).toBeTrue();
  });

  it('non emette valueChange se il pulsante è disabilitato', () => {
    fixture.componentRef.setInput('value', 99);
    fixture.componentRef.setInput('max', 99);
    fixture.detectChanges();

    let called = false;
    component.valueChange.subscribe(() => (called = true));
    getIncrementButton().nativeElement.click();

    expect(called).toBeFalse();
  });
});

Utiliser des attributs data-testid plutôt que des classes CSS pour sélectionner les éléments dans les tests est une bonne pratique bien établie : cela découple les tests du style visuel, de sorte qu'un refactoring du CSS ne casse pas la suite de tests.

1.5 — Espionner et mocker les dépendances avec jasmine.createSpyObj

Lorsqu'un composant dépend d'un service, on ne veut pas que ses tests unitaires invoquent la logique réelle du service (qui pourrait à son tour appeler une véritable API HTTP). On crée plutôt un mock avec uniquement les méthodes nécessaires, en contrôlant exactement ce qu'elles renvoient.

// checkout.component.spec.ts (estratto)
import { of, throwError } from 'rxjs';
import { OrderService } from './order.service';
import { NotificationService } from '../shared/notification.service';

describe('CheckoutComponent', () => {
  let orderServiceSpy: jasmine.SpyObj;
  let notificationSpy: jasmine.SpyObj;

  beforeEach(async () => {
    orderServiceSpy = jasmine.createSpyObj('OrderService', ['submitOrder', 'getShippingCost']);
    notificationSpy = jasmine.createSpyObj('NotificationService', ['success', 'error']);

    await TestBed.configureTestingModule({
      imports: [CheckoutComponent],
      providers: [
        { provide: OrderService, useValue: orderServiceSpy },
        { provide: NotificationService, useValue: notificationSpy },
      ],
    }).compileComponents();
  });

  it('mostra un messaggio di successo quando l\'ordine va a buon fine', () => {
    orderServiceSpy.submitOrder.and.returnValue(of({ orderId: 'ORD-123', status: 'confirmed' }));

    const fixture = TestBed.createComponent(CheckoutComponent);
    fixture.componentInstance.submit();

    expect(orderServiceSpy.submitOrder).toHaveBeenCalledTimes(1);
    expect(notificationSpy.success).toHaveBeenCalledWith(jasmine.stringMatching(/ORD-123/));
  });

  it('mostra un errore se il servizio ordini fallisce', () => {
    orderServiceSpy.submitOrder.and.returnValue(
      throwError(() => new Error('Pagamento rifiutato')),
    );

    const fixture = TestBed.createComponent(CheckoutComponent);
    fixture.componentInstance.submit();

    expect(notificationSpy.error).toHaveBeenCalledWith('Pagamento rifiutato');
    expect(notificationSpy.success).not.toHaveBeenCalled();
  });
});

jasmine.createSpyObj crée un objet avec des méthodes factices qui enregistrent chaque appel. .and.returnValue(...) configure ce qu'il faut renvoyer, et toHaveBeenCalledWith(...) vérifie les arguments passés. Cette technique isole complètement le composant testé de la logique réelle de ses dépendances.

1.6 — Tester les appels HTTP avec HttpClientTestingModule

Pour les services qui utilisent HttpClient, Angular fournit HttpClientTestingModule et HttpTestingController : ils interceptent les vraies requêtes HTTP et permettent de répondre avec des données factices, tout en vérifiant l'URL, la méthode et les en-têtes utilisés.

// product.service.ts
@Injectable({ providedIn: 'root' })
export class ProductService {
  private readonly http = inject(HttpClient);
  private readonly baseUrl = '/api/products';

  getById(id: string): Observable {
    return this.http.get(`${this.baseUrl}/${id}`);
  }

  search(query: string, page = 1): Observable> {
    return this.http.get>(this.baseUrl, {
      params: { q: query, page: page.toString() },
    });
  }
}

// product.service.spec.ts
import { TestBed } from '@angular/core/testing';
import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
import { ProductService } from './product.service';

describe('ProductService', () => {
  let service: ProductService;
  let httpMock: HttpTestingController;

  beforeEach(() => {
    TestBed.configureTestingModule({
      imports: [HttpClientTestingModule],
    });
    service = TestBed.inject(ProductService);
    httpMock = TestBed.inject(HttpTestingController);
  });

  afterEach(() => {
    // Essentiel : vérifie qu'il n'y a pas de requêtes HTTP orphelines non gérées par le test
    httpMock.verify();
  });

  it('recupera un prodotto per id', () => {
    const mockProduct = { id: 'p1', name: 'Tastiera meccanica', price: 89.9 };

    service.getById('p1').subscribe(product => {
      expect(product).toEqual(mockProduct);
    });

    const req = httpMock.expectOne('/api/products/p1');
    expect(req.request.method).toBe('GET');
    req.flush(mockProduct);
  });

  it('propaga un errore 404 al subscriber', () => {
    service.getById('missing').subscribe({
      next: () => fail('doveva fallire'),
      error: err => expect(err.status).toBe(404),
    });

    const req = httpMock.expectOne('/api/products/missing');
    req.flush('Not found', { status: 404, statusText: 'Not Found' });
  });

  it('costruisce correttamente i query params per la ricerca', () => {
    service.search('meccanica', 2).subscribe();

    const req = httpMock.expectOne(
      r => r.url === '/api/products' && r.params.get('q') === 'meccanica' && r.params.get('page') === '2',
    );
    expect(req.request.method).toBe('GET');
    req.flush({ items: [], total: 0, page: 2 });
  });
});

L'appel httpMock.verify() dans afterEach est essentiel : il fait échouer le test s'il existe des requêtes HTTP envoyées par le composant/service mais qu'aucune assertion n'a interceptées, évitant les faux positifs où une requête est "oubliée" sans erreur.

1.7 — Code asynchrone : fakeAsync, tick() et waitForAsync

Certains scénarios — debounce sur un champ de recherche, timeouts, retries avec délai — impliquent setTimeout ou des opérateurs RxJS temporisés comme debounceTime. Les tester avec de vrais timers rendrait la suite lente et non déterministe. fakeAsync résout le problème en virtualisant le temps.

// search-box.component.ts (estratto)
export class SearchBoxComponent {
  private readonly productService = inject(ProductService);
  query = new FormControl('');
  results = signal([]);

  constructor() {
    this.query.valueChanges
      .pipe(
        debounceTime(300),
        distinctUntilChanged(),
        switchMap(q => (q ? this.productService.search(q) : of({ items: [] as Product[] }))),
        takeUntilDestroyed(),
      )
      .subscribe(result => this.results.set(result.items));
  }
}

// search-box.component.spec.ts
import { fakeAsync, tick, TestBed } from '@angular/core/testing';

it('esegue la ricerca solo dopo 300ms di inattività (debounce)', fakeAsync(() => {
  const fixture = TestBed.createComponent(SearchBoxComponent);
  const component = fixture.componentInstance;
  spyOn(TestBed.inject(ProductService), 'search').and.returnValue(
    of({ items: [{ id: 'p1', name: 'Mouse', price: 20 }] }),
  );

  component.query.setValue('mo');
  tick(100);
  component.query.setValue('mou');
  tick(100);
  component.query.setValue('mouse');

  // Seulement 200ms se sont écoulées depuis le dernier changement : la recherche NE doit PAS encore se déclencher
  expect(TestBed.inject(ProductService).search).not.toHaveBeenCalled();

  tick(300); // on complète le debounce
  fixture.detectChanges();

  expect(TestBed.inject(ProductService).search).toHaveBeenCalledOnceWith('mouse');
  expect(component.results().length).toBe(1);
}));

tick(ms) avance artificiellement l'horloge virtuelle de la zone de test, déclenchant les timers en attente sans réellement attendre ce temps. Cela rend le test déterministe et instantané, tout en validant correctement la logique de debounce.

1.8 — Tester les Effects réactifs aux Signals

Avec l'introduction des Signals, de nombreux composants utilisent effect() pour réagir aux changements d'état (ex. synchronisation avec localStorage). Tester un effect nécessite de forcer explicitement le flush du contexte réactif avec TestBed.flushEffects() ou, plus simplement, avec fixture.detectChanges() lorsque le composant est rattaché à un fixture.

// theme.service.spec.ts
import { TestBed } from '@angular/core/testing';
import { ThemeService } from './theme.service';

describe('ThemeService — persistenza tema con effect()', () => {
  beforeEach(() => localStorage.clear());

  it('salva il tema in localStorage quando cambia', () => {
    TestBed.runInInjectionContext(() => {
      const service = TestBed.inject(ThemeService);
      service.setTheme('dark');

      TestBed.tick(); // force le flush des effects en attente

      expect(localStorage.getItem('theme')).toBe('dark');
    });
  });
});

1.9 — Marble testing pour les Observables complexes

Lorsque la logique RxJS devient complexe (combinaisons de combineLatest, switchMap, retry avec backoff), le marble testing avec TestScheduler permet de décrire des séquences temporelles d'événements de manière déclarative et de les vérifier en un seul tick synchrone.

import { TestScheduler } from 'rxjs/testing';

describe('retryWithBackoff (marble testing)', () => {
  let scheduler: TestScheduler;

  beforeEach(() => {
    scheduler = new TestScheduler((actual, expected) => {
      expect(actual).toEqual(expected);
    });
  });

  it('riprova due volte prima di emettere il valore con successo', () => {
    scheduler.run(({ cold, expectObservable }) => {
      // '#' représente une erreur, '-' une frame de temps, 'a' une valeur émise
      const source$ = cold('-#', undefined, new Error('fail'));
      const expected = '  -- -- -- -- -#';

      // Dans un scénario réel, on appliquerait ici retryWithBackoff(source$, { retries: 2 })
      expectObservable(source$.pipe()).toBe('-#', undefined, new Error('fail'));
    });
  });
});

Le marble testing a une courbe d'apprentissage plus raide que les tests "classiques", mais il est payant lorsque la logique réactive implique des temporisations précises qui seraient difficiles à vérifier de manière fiable avec un simple fakeAsync.

1.10 — Passer à Jest : pourquoi et comment

Jest est devenu le standard de facto dans l'écosystème React/Node, et de plus en plus d'équipes Angular l'adoptent à la place de Karma pour trois raisons concrètes :

  • Vitesse : Jest exécute les tests dans Node.js avec JSDOM, sans ouvrir un vrai navigateur Chrome — nettement plus rapide, surtout en CI.
  • Watch mode intelligent : jest --watch n'exécute que les tests liés aux fichiers modifiés (via l'analyse du git diff).
  • Snapshot testing et mocking intégrés : jest.fn(), jest.mock() et les snapshots sont natifs, sans bibliothèques supplémentaires.
npm install --save-dev jest jest-preset-angular @types/jest
npm uninstall karma karma-chrome-launcher karma-coverage karma-jasmine karma-jasmine-html-reporter
// jest.config.js
module.exports = {
  preset: 'jest-preset-angular',
  setupFilesAfterEach: ['/setup-jest.ts'],
  testPathIgnorePatterns: ['/node_modules/', '/dist/'],
  collectCoverage: true,
  coverageDirectory: 'coverage',
  coverageThreshold: {
    global: {
      statements: 80,
      branches: 75,
      functions: 80,
      lines: 80,
    },
  },
  transformIgnorePatterns: ['node_modules/(?!.*\\.mjs$)'],
};
// setup-jest.ts
import 'jest-preset-angular/setup-jest';
// package.json (estratto)
{
  "scripts": {
    "test": "jest",
    "test:watch": "jest --watch",
    "test:ci": "jest --ci --coverage --maxWorkers=2"
  }
}

1.11 — Jasmine vs Jest : différences pratiques de syntaxe

Bonne nouvelle : la syntaxe describe/it/expect est quasiment identique, donc la migration de la plupart des tests est souvent une simple question de configuration. Les différences apparaissent surtout au niveau du mocking.

AspectJasmineJest
Créer un spyjasmine.createSpy('name')jest.fn()
Mocker un module entierNon natif (nécessite le DI)jest.mock('./module')
Configurer la valeur de retourspy.and.returnValue(x)fn.mockReturnValue(x)
Snapshot testingNon supporté nativementexpect(x).toMatchSnapshot()
Timers facticesfakeAsync + tick()jest.useFakeTimers() + jest.advanceTimersByTime()
Environnement d'exécutionNavigateur réel (Karma + Chrome)Node.js + JSDOM
Vitesse typique (500 tests)~25-40s~8-15s
// Exemple de mock de module avec Jest — utile pour les bibliothèques externes lourdes
jest.mock('./analytics.service', () => ({
  AnalyticsService: jest.fn().mockImplementation(() => ({
    track: jest.fn(),
    identify: jest.fn(),
  })),
}));

describe('CheckoutComponent con Jest', () => {
  it('traccia l\'evento purchase al completamento ordine', () => {
    const trackSpy = jest.fn();
    // ... configuration du composant avec le mock ...
    expect(trackSpy).toHaveBeenCalledWith('purchase', expect.objectContaining({ orderId: expect.any(String) }));
  });
});

1.12 — Snapshot testing : quand l'utiliser (et quand l'éviter)

Les tests de snapshot capturent la sortie rendue d'un composant et la comparent à une version enregistrée sur disque. Ils sont utiles pour les composants purement présentationnels à sortie stable, mais deviennent un problème lorsque l'équipe les met à jour automatiquement avec --ci=false --updateSnapshot sans relire le diff — les transformant en un contrôle purement formel.

it('il badge di stato ordine ha il markup atteso', () => {
  const fixture = TestBed.createComponent(OrderStatusBadgeComponent);
  fixture.componentRef.setInput('status', 'shipped');
  fixture.detectChanges();

  expect(fixture.nativeElement.innerHTML).toMatchSnapshot();
});

Règle pratique : n'utilisez les snapshots que pour du markup simple et stable (badges, icônes, formateurs), jamais pour des composants avec de la logique métier — là, une assertion explicite (expect(text).toBe('Spedito')) communique l'intention bien mieux qu'un snapshot opaque de 200 lignes de HTML.

1.13 — Bonnes pratiques transversales pour des tests unitaires efficaces

  • Motif AAA (Arrange-Act-Assert) : structurez chaque test en trois blocs clairs : préparez les données, exécutez l'action, vérifiez le résultat.
  • Une assertion conceptuelle par test : plusieurs expect dans le même test sont acceptables s'ils vérifient la même chose sous différents angles, mais un test qui vérifie trois comportements indépendants doit être scindé en trois.
  • Ne testez pas les détails d'implémentation : testez le comportement observable (sortie, événements émis, DOM rendu), pas les variables privées ni les appels de méthodes internes.
  • Noms de test descriptifs : it('désactive le bouton quand le panier est vide') est infiniment plus utile que it('test 3') lorsqu'un test échoue en CI à 2h du matin.
  • Tests indépendants : chaque test doit pouvoir s'exécuter seul, dans n'importe quel ordre. Si un test dépend de l'état laissé par un autre, c'est un test fragile.
  • La coverage comme indicateur, pas comme objectif : 100% de code coverage avec des assertions faibles (expect(true).toBe(true)) est pire que 70% avec des assertions solides. Utilisez la coverage pour trouver du code non testé, pas comme un KPI à maximiser à tout prix.

1.14 — Tester les Guards et les Resolvers

Les route guards et les resolvers sont souvent l'endroit où réside la logique d'autorisation la plus délicate d'une application — et c'est exactement le type de code qui doit avoir une couverture de tests solide, car un bug ici signifie des utilisateurs non authentifiés accédant à des pages protégées, ou l'inverse.

// auth.guard.ts
import { inject } from '@angular/core';
import { CanActivateFn, Router } from '@angular/router';
import { AuthService } from './auth.service';

export const authGuard: CanActivateFn = (route, state) => {
  const authService = inject(AuthService);
  const router = inject(Router);

  if (authService.isAuthenticated()) {
    return true;
  }

  router.navigate(['/login'], { queryParams: { returnUrl: state.url } });
  return false;
};

// auth.guard.spec.ts
import { TestBed } from '@angular/core/testing';
import { Router } from '@angular/router';
import { authGuard } from './auth.guard';
import { AuthService } from './auth.service';

describe('authGuard', () => {
  let authServiceSpy: jasmine.SpyObj;
  let routerSpy: jasmine.SpyObj;

  beforeEach(() => {
    authServiceSpy = jasmine.createSpyObj('AuthService', ['isAuthenticated']);
    routerSpy = jasmine.createSpyObj('Router', ['navigate']);

    TestBed.configureTestingModule({
      providers: [
        { provide: AuthService, useValue: authServiceSpy },
        { provide: Router, useValue: routerSpy },
      ],
    });
  });

  it('permette la navigazione se l\'utente è autenticato', () => {
    authServiceSpy.isAuthenticated.and.returnValue(true);

    const result = TestBed.runInInjectionContext(() =>
      authGuard({} as any, { url: '/dashboard' } as any),
    );

    expect(result).toBeTrue();
    expect(routerSpy.navigate).not.toHaveBeenCalled();
  });

  it('reindirizza al login con returnUrl se non autenticato', () => {
    authServiceSpy.isAuthenticated.and.returnValue(false);

    const result = TestBed.runInInjectionContext(() =>
      authGuard({} as any, { url: '/dashboard/settings' } as any),
    );

    expect(result).toBeFalse();
    expect(routerSpy.navigate).toHaveBeenCalledWith(['/login'], {
      queryParams: { returnUrl: '/dashboard/settings' },
    });
  });
});

Tester la guard comme une simple fonction (grâce à TestBed.runInInjectionContext) plutôt que de monter tout un composant de route est beaucoup plus rapide et se concentre exactement sur la logique d'autorisation, sans bruit provenant du rendu.

1.15 — Tester les Pipes et les Directives personnalisées

// truncate.pipe.ts
import { Pipe, PipeTransform } from '@angular/core';

@Pipe({ name: 'truncate', standalone: true })
export class TruncatePipe implements PipeTransform {
  transform(value: string, maxLength = 100, suffix = '…'): string {
    if (!value || value.length <= maxLength) return value;
    return value.slice(0, maxLength).trim() + suffix;
  }
}

// truncate.pipe.spec.ts
describe('TruncatePipe', () => {
  const pipe = new TruncatePipe();

  it('non modifica stringhe più corte del limite', () => {
    expect(pipe.transform('Ciao mondo', 20)).toBe('Ciao mondo');
  });

  it('tronca e aggiunge il suffisso di default', () => {
    const result = pipe.transform('Questo è un testo piuttosto lungo da troncare', 10);
    expect(result).toBe('Questo è u…');
  });

  it('gestisce stringhe vuote o undefined senza errori', () => {
    expect(pipe.transform('', 10)).toBe('');
  });
});

// highlight-on-hover.directive.ts
import { Directive, ElementRef, HostListener, inject, input } from '@angular/core';

@Directive({ selector: '[appHighlightOnHover]', standalone: true })
export class HighlightOnHoverDirective {
  private readonly el = inject(ElementRef);
  color = input('#fff3cd');

  @HostListener('mouseenter') onEnter(): void {
    this.el.nativeElement.style.backgroundColor = this.color();
  }

  @HostListener('mouseleave') onLeave(): void {
    this.el.nativeElement.style.backgroundColor = '';
  }
});

// highlight-on-hover.directive.spec.ts
@Component({
  standalone: true,
  imports: [HighlightOnHoverDirective],
  template: `
Testo
`, }) class HostComponent {} describe('HighlightOnHoverDirective', () => { it('applica il colore di sfondo al mouseenter e lo rimuove al mouseleave', () => { const fixture = TestBed.createComponent(HostComponent); fixture.detectChanges(); const div: HTMLElement = fixture.nativeElement.querySelector('div'); div.dispatchEvent(new Event('mouseenter')); expect(div.style.backgroundColor).toBe('rgb(255, 0, 0)'); div.dispatchEvent(new Event('mouseleave')); expect(div.style.backgroundColor).toBe(''); }); });

Les pipes et les directives, étant de très petites unités de logique sans dépendances lourdes, comptent parmi les éléments les plus économiques à tester de manière exhaustive : quelques minutes d'écriture garantissent une couverture quasi complète de tous les cas limites (chaîne vide, valeur limite, entrée malformée).

Partie 2 : Tests E2E avec Cypress et Playwright

2.1 — Pourquoi les tests E2E sont nécessaires au-delà des tests unitaires

Les tests unitaires vérifient que les pièces individuelles fonctionnent isolément, mais ils ne peuvent pas détecter les problèmes d'intégration : un routage mal configuré, un CSS qui cache un bouton, un CORS bloqué uniquement dans un environnement spécifique, ou une régression dans le parcours complet de checkout. Les tests E2E ouvrent un vrai navigateur (ou un navigateur headless), naviguent dans l'application comme le ferait un utilisateur réel et vérifient le résultat final.

2.2 — Cypress : installation et structure du projet

npm install --save-dev cypress
npx cypress open   # mode interactif, utile pendant le développement
npx cypress run    # mode headless, pour la CI
// cypress.config.ts
import { defineConfig } from 'cypress';

export default defineConfig({
  e2e: {
    baseUrl: 'http://localhost:4200',
    supportFile: 'cypress/support/e2e.ts',
    specPattern: 'cypress/e2e/**/*.cy.ts',
    viewportWidth: 1280,
    viewportHeight: 800,
    video: false, // désactivé en local, souvent réactivé en CI pour le débogage
    retries: {
      runMode: 2,  // réessaie les tests échoués 2 fois en CI (atténue la flakiness réseau)
      openMode: 0,
    },
    env: {
      apiUrl: 'http://localhost:3000/api',
    },
  },
});

La structure typique d'un projet Cypress :

cypress/
├── e2e/
│   ├── auth/
│   │   ├── login.cy.ts
│   │   └── register.cy.ts
│   ├── checkout/
│   │   └── checkout-flow.cy.ts
│   └── blog/
│       └── blog-navigation.cy.ts
├── fixtures/
│   └── products.json
├── support/
│   ├── commands.ts
│   └── e2e.ts

2.3 — Premier test Cypress : parcours de connexion

// cypress/e2e/auth/login.cy.ts
describe('Login', () => {
  beforeEach(() => {
    cy.visit('/login');
  });

  it('effettua il login con credenziali valide e reindirizza alla dashboard', () => {
    cy.get('[data-testid="email-input"]').type('utente@example.com');
    cy.get('[data-testid="password-input"]').type('PasswordSicura123!');
    cy.get('[data-testid="login-submit"]').click();

    cy.url().should('include', '/dashboard');
    cy.get('[data-testid="welcome-message"]').should('contain.text', 'Bentornato');
  });

  it('mostra un errore con credenziali errate', () => {
    cy.get('[data-testid="email-input"]').type('utente@example.com');
    cy.get('[data-testid="password-input"]').type('passwordSbagliata');
    cy.get('[data-testid="login-submit"]').click();

    cy.get('[data-testid="login-error"]')
      .should('be.visible')
      .and('contain.text', 'Credenziali non valide');
    cy.url().should('include', '/login');
  });

  it('disabilita il pulsante submit finché i campi non sono validi', () => {
    cy.get('[data-testid="login-submit"]').should('be.disabled');
    cy.get('[data-testid="email-input"]').type('non-una-email');
    cy.get('[data-testid="login-submit"]').should('be.disabled');
    cy.get('[data-testid="email-input"]').clear().type('valida@example.com');
    cy.get('[data-testid="password-input"]').type('almeno8caratteri');
    cy.get('[data-testid="login-submit"]').should('not.be.disabled');
  });
});

2.4 — Intercepter les appels réseau avec cy.intercept

Un test E2E qui dépend d'un backend réel est lent et fragile (données changeantes, services externes indisponibles). cy.intercept permet de stubber les réponses HTTP tout en gardant le test réaliste mais déterministe.

// cypress/e2e/checkout/checkout-flow.cy.ts
describe('Flusso di checkout completo', () => {
  beforeEach(() => {
    cy.intercept('GET', '/api/cart', { fixture: 'cart-with-items.json' }).as('getCart');
    cy.intercept('POST', '/api/orders', {
      statusCode: 201,
      body: { orderId: 'ORD-9981', status: 'confirmed' },
    }).as('submitOrder');

    cy.visit('/checkout');
    cy.wait('@getCart');
  });

  it('completa un ordine e mostra la conferma', () => {
    cy.get('[data-testid="shipping-name"]').type('Mario Rossi');
    cy.get('[data-testid="shipping-address"]').type('Via Roma 1, Milano');
    cy.get('[data-testid="place-order"]').click();

    cy.wait('@submitOrder').its('request.body').should('deep.include', {
      shippingName: 'Mario Rossi',
    });

    cy.get('[data-testid="order-confirmation"]').should('contain.text', 'ORD-9981');
  });

  it('mostra un errore se il pagamento viene rifiutato dal backend', () => {
    cy.intercept('POST', '/api/orders', {
      statusCode: 402,
      body: { message: 'Pagamento rifiutato' },
    }).as('submitOrderFailed');

    cy.get('[data-testid="shipping-name"]').type('Mario Rossi');
    cy.get('[data-testid="shipping-address"]').type('Via Roma 1, Milano');
    cy.get('[data-testid="place-order"]').click();

    cy.wait('@submitOrderFailed');
    cy.get('[data-testid="checkout-error"]').should('contain.text', 'Pagamento rifiutato');
  });
});

2.5 — Custom commands : rester DRY dans les tests Cypress

Les actions répétées dans de nombreux tests (connexion, ajout d'un produit au panier) doivent être extraites en custom commands pour éviter la duplication.

// cypress/support/commands.ts
declare global {
  namespace Cypress {
    interface Chainable {
      loginViaApi(email: string, password: string): Chainable;
    }
  }
}

Cypress.Commands.add('loginViaApi', (email: string, password: string) => {
  cy.request('POST', '/api/auth/login', { email, password }).then(response => {
    window.localStorage.setItem('authToken', response.body.token);
  });
});

export {};
// Utilisation dans un test — connexion "silencieuse" via l'API plutôt que de remplir le formulaire à chaque fois
describe('Dashboard (utente già autenticato)', () => {
  beforeEach(() => {
    cy.loginViaApi('utente@example.com', 'PasswordSicura123!');
    cy.visit('/dashboard');
  });

  it('mostra le statistiche dell\'utente', () => {
    cy.get('[data-testid="stats-panel"]').should('be.visible');
  });
});

Se connecter via un appel API direct (cy.request) plutôt que via l'UI à chaque fois est l'une des optimisations les plus efficaces pour accélérer une suite E2E : un test qui n'a pas vocation à tester la connexion elle-même n'a pas non plus besoin d'y passer via l'interface.

2.6 — Cypress Component Testing

Au-delà des tests E2E classiques, Cypress prend en charge le Component Testing : il monte un seul composant Angular isolément dans un vrai navigateur, combinant la vitesse des tests unitaires à la fidélité d'un rendu réel (pas de JSDOM).

// quantity-selector.cy.ts (Cypress Component Testing)
import { QuantitySelectorComponent } from './quantity-selector.component';

describe('QuantitySelectorComponent (component test)', () => {
  it('incrementa il valore al click su +', () => {
    cy.mount(QuantitySelectorComponent, {
      componentProperties: { value: 5, max: 10 },
    });

    cy.get('[data-testid="increment"]').click();
    cy.get('[data-testid="quantity-value"]').should('have.text', '6');
  });
});

2.7 — Playwright : installation et configuration

npm init playwright@latest
# Installe les navigateurs nécessaires (Chromium, Firefox, WebKit)
npx playwright install
// playwright.config.ts
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 ? 4 : undefined,
  reporter: [['html', { open: 'never' }], ['github']],
  use: {
    baseURL: 'http://localhost:4200',
    trace: 'on-first-retry', // enregistre une trace uniquement quand un test échoue et est réessayé
    screenshot: 'only-on-failure',
  },
  projects: [
    { name: 'chromium', use: { ...devices['Desktop Chrome'] } },
    { name: 'firefox', use: { ...devices['Desktop Firefox'] } },
    { name: 'webkit', use: { ...devices['Desktop Safari'] } },
    { name: 'mobile-chrome', use: { ...devices['Pixel 7'] } },
  ],
  webServer: {
    command: 'npm run start',
    url: 'http://localhost:4200',
    reuseExistingServer: !process.env.CI,
  },
});

2.8 — Premier test Playwright : le même parcours de connexion

// e2e/auth/login.spec.ts
import { test, expect } from '@playwright/test';

test.describe('Login', () => {
  test.beforeEach(async ({ page }) => {
    await page.goto('/login');
  });

  test('effettua il login con credenziali valide', async ({ page }) => {
    await page.getByTestId('email-input').fill('utente@example.com');
    await page.getByTestId('password-input').fill('PasswordSicura123!');
    await page.getByTestId('login-submit').click();

    await expect(page).toHaveURL(/\/dashboard/);
    await expect(page.getByTestId('welcome-message')).toContainText('Bentornato');
  });

  test('mostra un errore con credenziali non valide', async ({ page }) => {
    await page.getByTestId('email-input').fill('utente@example.com');
    await page.getByTestId('password-input').fill('sbagliata');
    await page.getByTestId('login-submit').click();

    await expect(page.getByTestId('login-error')).toBeVisible();
    await expect(page.getByTestId('login-error')).toContainText('Credenziali non valide');
  });
});

2.9 — Locators, auto-waiting et network mocking

Playwright utilise les locators, des objets "lazy" qui se rattachent automatiquement au DOM avant chaque action, avec un auto-waiting intégré : aucun sleep ni attente explicite n'est nécessaire, Playwright attend automatiquement que l'élément soit visible, activé et stable avant d'interagir avec lui.

// e2e/checkout/checkout-flow.spec.ts
import { test, expect } from '@playwright/test';

test.describe('Flusso di checkout', () => {
  test.beforeEach(async ({ page }) => {
    await page.route('**/api/cart', route =>
      route.fulfill({ path: 'e2e/fixtures/cart-with-items.json' }),
    );
    await page.route('**/api/orders', route =>
      route.fulfill({
        status: 201,
        json: { orderId: 'ORD-9981', status: 'confirmed' },
      }),
    );
    await page.goto('/checkout');
  });

  test('completa un ordine e mostra la conferma', async ({ page }) => {
    await page.getByTestId('shipping-name').fill('Mario Rossi');
    await page.getByTestId('shipping-address').fill('Via Roma 1, Milano');

    const [orderRequest] = await Promise.all([
      page.waitForRequest('**/api/orders'),
      page.getByTestId('place-order').click(),
    ]);

    expect(orderRequest.postDataJSON()).toMatchObject({ shippingName: 'Mario Rossi' });
    await expect(page.getByTestId('order-confirmation')).toContainText('ORD-9981');
  });
});

2.10 — Exécution parallèle cross-browser et Trace Viewer

L'un des points forts de Playwright est l'exécution native parallèle sur Chromium, Firefox et WebKit sans infrastructure supplémentaire (grid, fournisseur cloud). Lorsqu'un test échoue en CI, la trace enregistrée permet de revoir toute l'exécution — DOM snapshot, réseau, console — étape par étape, comme une vidéo interactive.

# Exécute la suite sur tous les navigateurs configurés, en parallèle
npx playwright test

# Exécute uniquement sur Chromium avec l'UI interactive de débogage
npx playwright test --project=chromium --ui

# Analyse la trace d'un test échoué en CI
npx playwright show-trace trace.zip

2.11 — Cypress vs Playwright : tableau comparatif

CaractéristiqueCypressPlaywright
ArchitectureS'exécute à l'intérieur du navigateur (même event loop que l'app)Contrôle le navigateur de l'extérieur via un protocole (CDP/WebSocket)
Navigateurs supportésChrome, Edge, Firefox, ElectronChromium, Firefox, WebKit (donc aussi Safari)
Multi-onglet / multi-domaineSupport historiquement limitéNatif, sans contournements
Exécution parallèleNécessite Cypress Cloud (payant) ou un sharding manuelNative, gratuite, intégrée à la config
Vitesse typiqueBonneGénéralement supérieure, surtout en CI
Outils de débogageTime-travel debugging dans l'UI interactiveTrace Viewer + UI Mode
Component testingOui, matureOui (Playwright Component Testing, plus récent)
Courbe d'apprentissageTrès douce, API très lisibleLégèrement plus large (async/await partout)

En pratique : si l'équipe est déjà habituée à Cypress et que le projet ne nécessite pas Safari/WebKit, Cypress reste un excellent choix pour sa developer experience. S'il faut une vraie couverture multi-navigateurs (Safari inclus) et une parallélisation native sans coût supplémentaire, Playwright est aujourd'hui le choix le plus solide pour les nouveaux projets.

2.12 — Intégration CI/CD avec GitHub Actions

# .github/workflows/e2e-playwright.yml
name: E2E Tests (Playwright)

on:
  pull_request:
    branches: [main, develop]

jobs:
  e2e:
    runs-on: ubuntu-latest
    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

      - name: Build applicazione
        run: npm run build

      - name: Esegui test E2E
        run: npx playwright test

      - name: Carica report HTML come artifact
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: playwright-report
          path: playwright-report/
          retention-days: 14
# .github/workflows/e2e-cypress.yml (equivalente con Cypress)
name: E2E Tests (Cypress)

on:
  pull_request:
    branches: [main, develop]

jobs:
  cypress:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: cypress-io/github-action@v6
        with:
          build: npm run build
          start: npm run start
          wait-on: 'http://localhost:4200'
          browser: chrome

Un détail souvent négligé : exécuter les tests E2E uniquement sur les pull requests qui touchent au code frontend (via paths: dans le trigger) évite de gaspiller des minutes de CI sur des modifications qui ne concernent, par exemple, que la documentation ou le backend.

2.13 — Visual regression testing avec Playwright

Au-delà des assertions fonctionnelles, Playwright permet de capturer des captures d'écran et de les comparer automatiquement à une version de référence enregistrée sur disque — utile pour détecter des régressions purement visuelles (une marge qui change, une couleur erronée) qu'aucune assertion textuelle ne détecterait.

// e2e/visual/product-card.spec.ts
import { test, expect } from '@playwright/test';

test.describe('Regressione visiva — ProductCard', () => {
  test('la card prodotto corrisponde allo screenshot di riferimento', async ({ page }) => {
    await page.goto('/prodotti/tastiera-meccanica');
    const card = page.getByTestId('product-card');

    // La première exécution génère la capture de référence (--update-snapshots) ;
    // les exécutions suivantes échouent en cas de différences de pixels au-delà du seuil.
    await expect(card).toHaveScreenshot('product-card-baseline.png', {
      maxDiffPixelRatio: 0.02,
    });
  });

  test('la card in stato "esaurito" ha un badge visivamente distinto', async ({ page }) => {
    await page.goto('/prodotti/tastiera-meccanica?stock=0');
    await expect(page.getByTestId('product-card')).toHaveScreenshot('product-card-sold-out.png');
  });
});
# Met à jour les captures de référence après un changement UI intentionnel
npx playwright test --update-snapshots

# En CI, sauvegardez toujours les diffs générés comme artifact pour la revue visuelle

Règle pratique : le seuil maxDiffPixelRatio doit être calibré pour tolérer de petites variations d'anti-aliasing entre environnements différents (local vs CI), sinon le test devient flaky pour des raisons qui n'ont rien à voir avec un véritable bug visuel.

Partie 3 : Astuces de Débogage avec Angular DevTools

3.1 — Installation et premier lancement

Angular DevTools est une extension officielle pour Chrome et Firefox qui ajoute un panneau dédié aux outils de développement du navigateur. Après l'installation depuis le Chrome Web Store, un onglet "Angular" apparaîtra dans les DevTools (F12) de toute page exécutant une application Angular en mode development (en production, avec enableProdMode() actif, les métadonnées nécessaires sont supprimées pour des raisons de performance et de sécurité).

3.2 — Component Explorer : inspecter l'arbre des composants

L'onglet Components affiche tout l'arbre des composants rendus, de façon similaire à l'arbre DOM mais au niveau des composants Angular plutôt que des balises HTML. En sélectionnant un composant, on peut inspecter en temps réel :

  • Les valeurs actuelles de tous les @Input() et des signals passés en input.
  • L'état interne du composant (propriétés, signals, computeds).
  • La hiérarchie des composants parent/enfant, pour comprendre d'où vient une donnée inattendue.

Il est également possible de modifier à la volée la valeur d'une propriété depuis le panneau et de voir le composant se re-rendre instantanément — extrêmement utile pour reproduire un cas limite (ex. une liste vide, un prix négatif) sans avoir à modifier les données en amont.

3.3 — L'Injector Tree : comprendre d'où vient un service

L'onglet Injector Tree visualise la hiérarchie des injectors de Dependency Injection de l'application — utile pour diagnostiquer des bugs du type "pourquoi ce composant reçoit-il une instance différente du service par rapport à celui juste à côté ?", typiquement causés par un provider déclaré au niveau du composant plutôt que root, ce qui crée une instance isolée pour ce sous-arbre.

3.4 — Le Profiler : enregistrer un cycle de Change Detection

L'onglet Profiler est l'outil le plus puissant pour le débogage des performances. En appuyant sur "Start recording" et en interagissant avec l'app (clics, scroll, saisie), Angular DevTools enregistre chaque cycle de change detection et produit un graphique en barres — similaire à une flame graph — où chaque barre représente un composant et sa hauteur le temps passé à vérifier ses bindings.

// Scénario typique à diagnostiquer avec le Profiler :
// un composant liste qui se re-rend à chaque frappe dans un champ de recherche,
// même si les données de la liste n'ont pas changé.

@Component({
  selector: 'app-product-list',
  standalone: true,
  // ❌ Sans OnPush, Angular vérifie ce composant à CHAQUE cycle de CD
  // de toute l'application, même pour des frappes dans des composants sans rapport.
  template: ``,
})
export class ProductListComponent {
  @Input() products: Product[] = [];
}

Dans le Profiler, cela se manifeste par une barre large et répétée pour ProductListComponent à chaque événement de saisie, même quand l'utilisateur tape simplement dans un champ qui n'a aucun rapport avec la liste de produits.

3.5 — Du diagnostic à la correction : ChangeDetectionStrategy.OnPush

@Component({
  selector: 'app-product-list',
  standalone: true,
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    @for (p of products(); track p.id) {
      
    }
  `,
})
export class ProductListComponent {
  products = input([]);
}

Avec OnPush, Angular vérifie le composant uniquement quand : (1) un @Input/signal input change par référence, (2) un événement DOM se produit en son sein, ou (3) il est explicitement marqué avec markForCheck(). En relançant l'enregistrement dans le Profiler après ce changement, la barre de ProductListComponent disparaît pendant la saisie dans le champ de recherche — la preuve visuelle que l'optimisation a fonctionné.

3.6 — trackBy (ou track dans la nouvelle syntaxe @for) pour les grandes listes

Une autre cause fréquente de goulets d'étranglement visible dans le Profiler : des listes entièrement re-rendues (détruites et recréées) à chaque mise à jour, au lieu de mettre à jour uniquement les éléments réellement modifiés.


@for (product of products(); track $index) {
  
}


@for (product of products(); track product.id) {
  
}

Dans le Profiler, l'effet de track $index sur une liste réordonnée (ex. après un tri) se voit comme un nombre élevé de composants ProductCardComponent détruits et recréés, au lieu d'un simple repositionnement — un signal clair que le track choisi n'identifie pas correctement les éléments.

3.7 — Combiner Angular DevTools avec le panneau Performance de Chrome

Angular DevTools excelle à montrer quels composants Angular vérifie, mais n'entre pas dans le détail de ce qui se passe au niveau du moteur JavaScript (garbage collection, layout thrashing, long tasks). Pour une analyse complète, on enregistre une session parallèle dans l'onglet natif Performance de Chrome DevTools :

  • Les Long Tasks (barres rouges de plus de 50ms) indiquent du travail synchrone bloquant le thread principal — souvent un cycle de change detection trop lourd.
  • La section Layout / Reflow signale des lectures/écritures du DOM alternées de manière inefficace (ex. lire offsetHeight dans une boucle qui écrit ensuite des styles).
  • Le graphique de mémoire dans le temps aide à repérer les memory leaks — typiquement des subscriptions RxJS jamais désinscrites dans des composants détruits et recréés à répétition (ex. dans une route que l'utilisateur rouvre souvent).
// Cause fréquente de memory leak : une subscription manuelle jamais désinscrite
export class DashboardComponent implements OnInit {
  ngOnInit(): void {
    // ❌ Chaque fois que le composant est recréé, une nouvelle subscription s'accumule
    interval(5000).subscribe(() => this.refreshStats());
  }
}

// ✅ Corrigé avec takeUntilDestroyed — la subscription est nettoyée
//    automatiquement quand Angular détruit le composant
export class DashboardComponent implements OnInit {
  private readonly destroyRef = inject(DestroyRef);

  ngOnInit(): void {
    interval(5000)
      .pipe(takeUntilDestroyed(this.destroyRef))
      .subscribe(() => this.refreshStats());
  }
}

3.8 — Astuces console supplémentaires : console.table, console.time, debugger

Tout le débogage ne nécessite pas d'outils dédiés. Quelques méthodes natives de la console, souvent oubliées, accélèrent grandement l'analyse de données complexes :

// console.table : affiche des tableaux d'objets sous forme de table lisible,
// bien plus utile que console.log pour déboguer des listes de données
console.table(this.products());

// console.time / console.timeEnd : mesure rapidement la durée d'un bloc
// sans avoir à importer des outils de profiling externes
console.time('calcolaTotaleCarrello');
const total = this.calculateTotal();
console.timeEnd('calcolaTotaleCarrello'); // affiche : calcolaTotaleCarrello: 2.341ms

// console.trace : affiche la pile d'appels complète — très utile pour comprendre
// D'OÙ est invoquée une fonction appelée depuis trop d'endroits dans le code
someSharedUtilityFunction(): void {
  console.trace('someSharedUtilityFunction chiamata da:');
}

// L'instruction debugger interrompt l'exécution exactement comme un breakpoint
// posé manuellement, mais elle vit dans le code source — utile pour les conditions rares
if (order.total < 0) {
  debugger; // le navigateur s'arrête ici UNIQUEMENT si les DevTools sont ouverts
}

Un dernier conseil pratique : dans les breakpoints conditionnels de Chrome DevTools (clic droit sur le numéro de ligne → "Add conditional breakpoint"), on peut saisir une expression comme product.price < 0, arrêtant l'exécution uniquement quand la condition est vraie, au lieu de devoir cliquer sur "continue" des dizaines de fois dans une boucle.

3.9 — Heap snapshot : localiser les memory leaks avec précision

Lorsque le Profiler d'Angular DevTools montre une application qui ralentit progressivement après plusieurs minutes d'utilisation (symptôme classique de memory leak), l'étape suivante est l'onglet Memory de Chrome DevTools :

  1. Naviguez dans l'app jusqu'à un état "propre" (ex. un dashboard vide), puis enregistrez un premier Heap snapshot.
  2. Effectuez l'action suspecte à plusieurs reprises (ex. ouvrez et fermez un dialogue 10 fois).
  3. Forcez une garbage collection manuelle (icône de la corbeille) et enregistrez un second Heap snapshot.
  4. Utilisez la vue "Comparison" entre les deux snapshots : les objets dont le nombre continue d'augmenter (ex. des instances de DialogComponent qui auraient dû être détruites) sont le symptôme de la leak.
// Cause typique détectable dans un Comparison heap snapshot :
// un EventEmitter personnalisé ou une subscription RxJS maintenue en vie par un service singleton,
// qui à son tour retient une référence au composant et empêche la garbage collection.

@Injectable({ providedIn: 'root' })
export class NotificationBusService {
  private readonly _messages = new Subject();
  readonly messages$ = this._messages.asObservable();
}

// ❌ Le composant s'abonne au service singleton mais ne se désabonne jamais :
// chaque instance du composant reste "accrochée" au Subject pour toujours.
export class ToastComponent implements OnInit {
  constructor(private bus: NotificationBusService) {}
  ngOnInit(): void {
    this.bus.messages$.subscribe(msg => this.show(msg));
  }
}

Dans une application où le composant ToastComponent est créé et détruit à répétition (ex. au sein d'une route que l'utilisateur visite souvent), cette unique subscription non désinscrite accumule une référence pour chaque instance jamais créée — exactement le motif qu'un Comparison heap snapshot rend visible, avec un nombre d'instances "Detached" qui continue de croître et ne revient jamais à zéro.

Conclusion : Construire un Filet de Sécurité à Plusieurs Niveaux

Aucun de ces outils ne remplace les autres : des tests unitaires rapides avec Jest ou Jasmine/Karma donnent une confiance immédiate sur chaque unité de code à chaque sauvegarde ; les tests E2E avec Cypress ou Playwright valident que les parcours critiques (login, checkout, recherche) fonctionnent vraiment de bout en bout avant la mise en production ; Angular DevTools intervient quand il faut comprendre pourquoi quelque chose est lent ou se comporte de façon inattendue, ce qu'aucun test automatique seul ne peut diagnostiquer en profondeur.

La combinaison de ces trois niveaux — multipliée par une CI qui les exécute automatiquement à chaque pull request — est ce qui transforme un projet Angular de "ça marche sur ma machine" en un produit sur lequel l'équipe peut faire du refactoring en toute confiance, sachant qu'une régression sera détectée avant d'atteindre les utilisateurs.

  • Checklist minimale pour un projet Angular mature :
  • ✅ Des tests unitaires pour chaque service avec une logique non triviale, avec une coverage minimale configurée en CI.
  • ✅ Des tests E2E sur les 3 à 5 parcours réellement critiques pour l'activité (pas sur chaque page individuellement).
  • ✅ ChangeDetectionStrategy.OnPush comme valeur par défaut pour les nouveaux composants.
  • ✅ Une session de profiling avec Angular DevTools avant chaque release importante sur les pages à fort trafic.

💬 Notes des lecteurs

0 notes

Écrire une note

Partagez votre avis, une suggestion ou un compliment

Dernières notes

Aucune note pour le moment. Soyez le premier à commenter !