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 --watchn'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.
| Aspect | Jasmine | Jest |
|---|---|---|
| Créer un spy | jasmine.createSpy('name') | jest.fn() |
| Mocker un module entier | Non natif (nécessite le DI) | jest.mock('./module') |
| Configurer la valeur de retour | spy.and.returnValue(x) | fn.mockReturnValue(x) |
| Snapshot testing | Non supporté nativement | expect(x).toMatchSnapshot() |
| Timers factices | fakeAsync + tick() | jest.useFakeTimers() + jest.advanceTimersByTime() |
| Environnement d'exécution | Navigateur 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
expectdans 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 queit('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éristique | Cypress | Playwright |
|---|---|---|
| Architecture | S'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és | Chrome, Edge, Firefox, Electron | Chromium, Firefox, WebKit (donc aussi Safari) |
| Multi-onglet / multi-domaine | Support historiquement limité | Natif, sans contournements |
| Exécution parallèle | Nécessite Cypress Cloud (payant) ou un sharding manuel | Native, gratuite, intégrée à la config |
| Vitesse typique | Bonne | Généralement supérieure, surtout en CI |
| Outils de débogage | Time-travel debugging dans l'UI interactive | Trace Viewer + UI Mode |
| Component testing | Oui, mature | Oui (Playwright Component Testing, plus récent) |
| Courbe d'apprentissage | Très douce, API très lisible | Lé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
offsetHeightdans 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 :
- Naviguez dans l'app jusqu'à un état "propre" (ex. un dashboard vide), puis enregistrez un premier Heap snapshot.
- Effectuez l'action suspecte à plusieurs reprises (ex. ouvrez et fermez un dialogue 10 fois).
- Forcez une garbage collection manuelle (icône de la corbeille) et enregistrez un second Heap snapshot.
- Utilisez la vue "Comparison" entre les deux snapshots : les objets dont le nombre continue d'augmenter (ex. des instances de
DialogComponentqui 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.OnPushcomme valeur par défaut pour les nouveaux composants. - ✅ Une session de profiling avec Angular DevTools avant chaque release importante sur les pages à fort trafic.