Introduzione: Perché Test e Debugging Fanno la Differenza
Ogni applicazione Angular che raggiunge la produzione affronta prima o poi la stessa domanda:
"Come faccio a sapere che questa modifica non ha rotto nulla?". La risposta seria non è
"l'ho provato a mano nel browser", ma una suite di test automatici che gira in pochi secondi, ad
ogni commit, senza intervento umano. Allo stesso tempo, quando qualcosa va storto — un componente
che si ridisegna troppe volte, una memory leak, una route che non si carica — servono strumenti di
debugging mirati per capire cosa sta succedendo e dove, invece di
affidarsi a console.log sparsi ovunque.
In questa guida copriamo in profondità tre pilastri del ciclo di vita di un'applicazione Angular matura:
- Unit test con Jasmine/Karma (il default di Angular CLI) e con Jest (l'alternativa più veloce e diffusa nell'ecosistema JavaScript moderno).
- Test E2E (end-to-end) con Cypress e Playwright, per validare flussi utente completi in un browser reale.
- Debugging delle performance con Angular DevTools, per individuare colli di bottiglia nel change detection e nel rendering.
Ogni sezione include esempi di codice pronti all'uso, tratti da scenari reali su componenti, servizi, signals e chiamate HTTP — gli stessi elementi che si trovano in qualsiasi applicazione Angular di produzione.
Argomenti: #Angular #Testing #UnitTesting #Jest #Jasmine #Karma #Cypress #Playwright #E2ETesting #AngularDevTools #Debugging #Performance #WebDevelopment
La Piramide dei Test: Dove Investire il Tempo
Prima di scrivere una singola riga di test, conviene avere chiaro il modello mentale della piramide dei test, proposta originariamente da Mike Cohn:
- Base (ampia): Unit test. Veloci (millisecondi), isolati, numerosi. Testano una funzione, un componente o un servizio in isolamento.
- Centro: Integration test. Verificano che più unità collaborino correttamente (es. un componente con il suo servizio reale, non mockato).
- Punta (stretta): E2E test. Lenti (secondi/minuti), fragili se mal scritti, ma sono gli unici che validano l'esperienza utente reale dal login al checkout.
L'errore più comune è invertire la piramide: pochi unit test e decine di E2E test lenti e fragili che rendono la CI un incubo di 40 minuti. L'obiettivo di questa guida è darti gli strumenti per costruire la piramide nel verso giusto: tanti unit test veloci, un numero ragionevole di E2E test sui flussi critici, e strumenti di debugging per quando qualcosa sfugge comunque ai test.
Parte 1: Unit Test con Jest e Jasmine/Karma
1.1 — Il setup di default: Jasmine + Karma
Quando crei un progetto con ng new, Angular CLI configura automaticamente
Jasmine come framework di asserzioni (describe, it,
expect) e Karma come test runner, che lancia i test in un vero
browser (Chrome Headless di default) tramite 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,
});
};
Per lanciare i test: ng test in locale (watch mode attivo) oppure
ng test --no-watch --browsers=ChromeHeadless in CI, dove non serve un browser
interattivo e vogliamo che il processo termini con un exit code invece di restare in ascolto.
1.2 — Anatomia di uno unit test: describe, it, expect
Ogni file di test Jasmine segue la stessa struttura: describe raggruppa i test
logicamente correlati, it (o il suo alias test) definisce un singolo
caso, expect effettua l'asserzione.
// 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();
});
});
Questo è un unit test puro: nessuna dipendenza da Angular, nessun TestBed, solo una funzione e le sue asserzioni. È il tipo di test più veloce da scrivere, leggere ed eseguire — e va preferito ogni volta che la logica può essere estratta da un componente o servizio in una funzione pura testabile in isolamento.
1.3 — Testare un servizio Angular con TestBed
Quando la logica dipende dal DI (Dependency Injection) di Angular, serve
TestBed per costruire un ambiente di test con gli stessi provider che userebbe
l'applicazione reale — sostituendo però le dipendenze esterne (HTTP, storage, altri servizi)
con dei mock.
// 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);
});
});
Nota come questo test verifichi anche i signals computed: non serve alcuna
configurazione speciale, i computed si ricalcolano in modo sincrono e possono essere letti
come una normale funzione (service.total()) subito dopo la modifica dello stato.
1.4 — Testare un componente standalone con TestBed e ComponentFixture
Testare un componente richiede un passo in più rispetto a un servizio: bisogna creare
un'istanza reale del componente nel DOM tramite ComponentFixture, forzare un
ciclo di change detection con fixture.detectChanges(), e poi interrogare il DOM
risultante con DebugElement o query native.
// 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();
});
});
L'uso di attributi data-testid invece di classi CSS per selezionare gli elementi
nei test è una best practice consolidata: disaccoppia i test dallo stile visivo, così un
refactoring del CSS non rompe la suite di test.
1.5 — Spiare e mockare le dipendenze con jasmine.createSpyObj
Quando un componente dipende da un servizio, non vogliamo che i suoi unit test invochino la logica reale del servizio (che a sua volta potrebbe chiamare un'API HTTP reale). Creiamo invece un mock con solo i metodi necessari, controllando esattamente cosa restituiscono.
// 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 crea un oggetto con metodi finti che registrano ogni
chiamata. .and.returnValue(...) configura cosa restituire, e
toHaveBeenCalledWith(...) verifica gli argomenti passati. Questa tecnica isola
completamente il componente sotto test dalla logica reale delle sue dipendenze.
1.6 — Testare chiamate HTTP con HttpClientTestingModule
Per i servizi che usano HttpClient, Angular fornisce
HttpClientTestingModule e HttpTestingController: intercettano le
richieste HTTP reali e permettono di rispondere con dati finti, verificando al contempo URL,
metodo e headers usati.
// 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(() => {
// Fondamentale: verifica che non ci siano richieste HTTP orfane non gestite dal 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 });
});
});
Il metodo httpMock.verify() in afterEach è essenziale: fa fallire il
test se ci sono richieste HTTP che il componente/servizio ha inviato ma che nessuna asserzione
ha intercettato, evitando falsi positivi dove una richiesta viene "dimenticata" senza errori.
1.7 — Codice asincrono: fakeAsync, tick() e waitForAsync
Alcuni scenari — debounce su un campo di ricerca, timeout, retry con delay — coinvolgono
setTimeout o operatori RxJS temporizzati come debounceTime. Testarli
con veri timer renderebbe la suite lenta e non deterministica. fakeAsync risolve
il problema virtualizzando il tempo.
// 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');
// Solo 200ms sono passati dall'ultimo cambio: la ricerca NON deve ancora partire
expect(TestBed.inject(ProductService).search).not.toHaveBeenCalled();
tick(300); // completiamo il debounce
fixture.detectChanges();
expect(TestBed.inject(ProductService).search).toHaveBeenCalledOnceWith('mouse');
expect(component.results().length).toBe(1);
}));
tick(ms) avanza artificialmente l'orologio virtuale della zona di test, facendo
scattare i timer pendenti senza attendere realmente quel tempo. Questo rende il test
deterministico e istantaneo, pur validando correttamente la logica di debounce.
1.8 — Testare gli Effects reattivi ai Signals
Con l'introduzione dei Signals, molti componenti usano effect() per reagire ai
cambiamenti di stato (es. sincronizzare con localStorage). Testare un effect
richiede di forzare esplicitamente il flush del contesto reattivo con
TestBed.flushEffects() o, più semplicemente, con fixture.detectChanges()
quando il componente è collegato a 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(); // forza il flush degli effect pendenti
expect(localStorage.getItem('theme')).toBe('dark');
});
});
});
1.9 — Marble testing per Observable complessi
Quando la logica RxJS diventa complessa (combinazioni di combineLatest,
switchMap, retry con backoff), il marble testing con
TestScheduler permette di descrivere sequenze temporali di eventi in modo
dichiarativo e verificarle in un singolo tick sincrono.
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 }) => {
// '#' rappresenta un errore, '-' un frame di tempo, 'a' un valore emesso
const source$ = cold('-#', undefined, new Error('fail'));
const expected = ' -- -- -- -- -#';
// In uno scenario reale qui applicheremmo retryWithBackoff(source$, { retries: 2 })
expectObservable(source$.pipe()).toBe('-#', undefined, new Error('fail'));
});
});
});
Il marble testing ha una curva di apprendimento più ripida rispetto ai test "classici", ma
ripaga quando la logica reattiva coinvolge tempistiche precise che sarebbero difficili da
verificare in modo affidabile con fakeAsync puro.
1.10 — Passare a Jest: perché e come
Jest è diventato lo standard de facto nell'ecosistema React/Node, e sempre più team Angular lo adottano al posto di Karma per tre motivi concreti:
- Velocità: Jest esegue i test in Node.js con JSDOM, senza aprire un vero browser Chrome — significativamente più veloce, specialmente in CI.
- Watch mode intelligente:
jest --watchesegue solo i test collegati ai file modificati (tramite analisi del git diff). - Snapshot testing e mocking integrato:
jest.fn(),jest.mock()e gli snapshot sono nativi, senza librerie aggiuntive.
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: differenze pratiche nella sintassi
Buona notizia: la sintassi describe/it/expect è quasi identica, quindi la
migrazione di gran parte dei test è spesso solo questione di configurazione. Le differenze
emergono soprattutto nel mocking.
| Aspetto | Jasmine | Jest |
|---|---|---|
| Creare uno spy | jasmine.createSpy('name') | jest.fn() |
| Mock di un intero modulo | Non nativo (serve DI) | jest.mock('./module') |
| Configurare il valore di ritorno | spy.and.returnValue(x) | fn.mockReturnValue(x) |
| Snapshot testing | Non supportato nativamente | expect(x).toMatchSnapshot() |
| Timer finti | fakeAsync + tick() | jest.useFakeTimers() + jest.advanceTimersByTime() |
| Ambiente di esecuzione | Browser reale (Karma + Chrome) | Node.js + JSDOM |
| Velocità tipica (500 test) | ~25-40s | ~8-15s |
// Esempio di mock di modulo con Jest — utile per librerie esterne pesanti
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();
// ... setup del componente con il mock ...
expect(trackSpy).toHaveBeenCalledWith('purchase', expect.objectContaining({ orderId: expect.any(String) }));
});
});
1.12 — Snapshot testing: quando usarlo (e quando evitarlo)
Gli snapshot testing catturano l'output renderizzato di un componente e lo confrontano con una
versione salvata su disco. Sono utili per componenti puramente presentazionali con output
stabile, ma diventano un problema quando il team li aggiorna automaticamente con
--ci=false --updateSnapshot senza rivedere il diff — trasformandoli in un
controllo puramente formale.
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();
});
Regola pratica: usa gli snapshot solo per markup semplice e stabile (badge, icone, formattatori),
mai per componenti con logica di business — lì un'asserzione esplicita
(expect(text).toBe('Spedito')) comunica l'intento molto meglio di uno snapshot
opaco di 200 righe di HTML.
1.13 — Best practice trasversali per unit test efficaci
- Pattern AAA (Arrange-Act-Assert): struttura ogni test in tre blocchi chiari: prepara i dati, esegui l'azione, verifica il risultato.
- Un'asserzione concettuale per test: più
expectnello stesso test vanno bene se verificano la stessa cosa da angolazioni diverse, ma un test che verifica tre comportamenti indipendenti va spezzato in tre. - Non testare i dettagli implementativi: testa il comportamento osservabile (output, eventi emessi, DOM renderizzato), non variabili private o chiamate a metodi interni.
- Nomi di test descrittivi:
it('disabilita il pulsante quando il carrello è vuoto')è infinitamente più utile diit('test 3')quando un test fallisce in CI alle 2 di notte. - Test indipendenti: ogni test deve poter girare da solo, in qualsiasi ordine. Se un test dipende dallo stato lasciato da un altro, è un test fragile.
- Coverage come indicatore, non obiettivo: il 100% di code coverage con asserzioni deboli (
expect(true).toBe(true)) è peggio di un 70% con asserzioni solide. Usa la coverage per trovare codice non testato, non come KPI da massimizzare a ogni costo.
1.14 — Testare Guard e Resolver
Le route guard e i resolver sono spesso il punto in cui si annida la logica di autorizzazione più delicata di un'applicazione — ed è esattamente il tipo di codice che deve avere una copertura di test solida, perché un bug qui significa utenti non autenticati che accedono a pagine protette, oppure il contrario.
// 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' },
});
});
});
Testare il guard come una semplice funzione (grazie a TestBed.runInInjectionContext)
invece che montando un intero componente rotta è molto più rapido e mette a fuoco esattamente
la logica di autorizzazione, senza rumore proveniente dal rendering.
1.15 — Testare Pipe e Direttive personalizzate
// 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('');
});
});
Pipe e direttive, essendo unità di logica molto piccole e senza dipendenze pesanti, sono tra gli elementi più economici da testare in modo esaustivo: pochi minuti di scrittura garantiscono una copertura pressoché completa di tutti gli edge case (stringa vuota, valore limite, input malformato).
Parte 2: E2E Testing con Cypress e Playwright
2.1 — Perché servono i test E2E oltre agli unit test
Gli unit test verificano che i singoli pezzi funzionino in isolamento, ma non possono catturare problemi di integrazione: un routing mal configurato, un CSS che nasconde un pulsante, un CORS bloccato solo in un ambiente specifico, o una regressione nel flusso completo di checkout. I test E2E aprono un vero browser (o uno headless), navigano l'applicazione come farebbe un utente reale e verificano il risultato finale.
2.2 — Cypress: installazione e struttura del progetto
npm install --save-dev cypress
npx cypress open # modalità interattiva, utile in sviluppo
npx cypress run # modalità headless, per 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, // disabilitato in locale, spesso riabilitato in CI per il debug
retries: {
runMode: 2, // riprova i test falliti 2 volte in CI (mitiga flakiness di rete)
openMode: 0,
},
env: {
apiUrl: 'http://localhost:3000/api',
},
},
});
La struttura tipica di un progetto 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 — Primo test Cypress: flusso di login
// 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 — Intercettare le chiamate di rete con cy.intercept
Un test E2E che dipende da un backend reale è lento e fragile (dati che cambiano, servizi
esterni giù). cy.intercept permette di stubbare le risposte HTTP mantenendo il
test realistico ma deterministico.
// 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: DRY nei test Cypress
Azioni ripetute in molti test (login, aggiungere un prodotto al carrello) vanno estratte in custom commands per evitare duplicazione.
// 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 {};
// Utilizzo in un test — login "silenzioso" via API invece che compilare il form ogni volta
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');
});
});
Effettuare il login via chiamata API diretta (cy.request) invece che tramite UI
ogni volta è una delle ottimizzazioni più efficaci per velocizzare una suite E2E: un test che
non deve testare il login stesso non deve nemmeno passarci attraverso via interfaccia.
2.6 — Cypress Component Testing
Oltre ai test E2E classici, Cypress supporta il Component Testing: monta un singolo componente Angular in isolamento in un vero browser, unendo la velocità degli unit test alla fedeltà di un rendering reale (niente 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: installazione e configurazione
npm init playwright@latest
# Installa i browser necessari (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', // registra una trace solo quando un test fallisce e viene ritentato
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 — Primo test Playwright: lo stesso flusso di login
// 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 e network mocking
Playwright usa i locators, oggetti "lazy" che si ricollegano automaticamente
al DOM prima di ogni azione, con auto-waiting integrato: non serve alcun
sleep o attesa esplicita, Playwright aspetta automaticamente che l'elemento sia
visibile, abilitato e stabile prima di interagirci.
// 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 — Esecuzione parallela cross-browser e Trace Viewer
Uno dei punti di forza di Playwright è l'esecuzione nativa parallela su Chromium, Firefox e WebKit senza infrastruttura aggiuntiva (grid, cloud provider). Quando un test fallisce in CI, la trace registrata permette di rivedere l'intera esecuzione — DOM snapshot, network, console — passo per passo, come un video interattivo.
# Esegue la suite su tutti i browser configurati, in parallelo
npx playwright test
# Esegue solo su Chromium con UI interattiva di debug
npx playwright test --project=chromium --ui
# Analizza la trace di un test fallito in CI
npx playwright show-trace trace.zip
2.11 — Cypress vs Playwright: tabella comparativa
| Caratteristica | Cypress | Playwright |
|---|---|---|
| Architettura | Gira dentro il browser (stesso event loop dell'app) | Controlla il browser dall'esterno via protocollo (CDP/WebSocket) |
| Browser supportati | Chrome, Edge, Firefox, Electron | Chromium, Firefox, WebKit (quindi anche Safari) |
| Multi-tab / multi-dominio | Supporto limitato storicamente | Nativo, senza workaround |
| Esecuzione parallela | Richiede Cypress Cloud (a pagamento) o sharding manuale | Nativa, gratuita, integrata nel config |
| Velocità tipica | Buona | Generalmente superiore, specialmente in CI |
| Debug tool | Time-travel debugging nell'UI interattiva | Trace Viewer + UI Mode |
| Component testing | Sì, maturo | Sì (Playwright Component Testing, più recente) |
| Curva di apprendimento | Molto dolce, API molto leggibile | Leggermente più ampia (async/await ovunque) |
In pratica: se il team è già abituato a Cypress e il progetto non richiede Safari/WebKit, Cypress resta un'ottima scelta per la sua developer experience. Se serve copertura multi-browser reale (incluso Safari) e parallelizzazione nativa senza costi aggiuntivi, Playwright è oggi la scelta più solida per progetti nuovi.
2.12 — Integrazione in CI/CD con 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 dettaglio spesso trascurato: eseguire i test E2E solo sulle pull request
che toccano codice frontend (tramite paths: nel trigger) evita di sprecare minuti
di CI su modifiche che riguardano solo, ad esempio, la documentazione o il backend.
2.13 — Visual regression testing con Playwright
Oltre alle asserzioni funzionali, Playwright permette di catturare screenshot e confrontarli automaticamente con una versione di riferimento salvata su disco — utile per intercettare regressioni puramente visive (un margine che cambia, un colore sbagliato) che nessuna asserzione testuale rileverebbe.
// 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 prima esecuzione genera lo screenshot di riferimento (--update-snapshots);
// le esecuzioni successive falliscono se ci sono differenze pixel oltre la soglia.
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');
});
});
# Aggiorna gli screenshot di riferimento dopo una modifica UI intenzionale
npx playwright test --update-snapshots
# In CI, salva sempre i diff generati come artifact per la revisione visiva
Regola pratica: la soglia maxDiffPixelRatio va calibrata per tollerare piccole
variazioni di anti-aliasing tra ambienti diversi (locale vs CI), altrimenti il test diventa
flaky per motivi che non hanno nulla a che fare con un vero bug visivo.
Parte 3: Trucchi di Debugging con Angular DevTools
3.1 — Installazione e primo avvio
Angular DevTools è un'estensione ufficiale per Chrome e Firefox che aggiunge
un pannello dedicato agli strumenti di sviluppo del browser. Dopo l'installazione dal Chrome
Web Store, apparirà una scheda "Angular" negli DevTools (F12) di qualsiasi pagina che esegue
un'applicazione Angular in modalità development (in produzione, con
enableProdMode() attivo, i metadati necessari sono rimossi per motivi di
performance e sicurezza).
3.2 — Component Explorer: ispezionare l'albero dei componenti
Il tab Components mostra l'intero albero dei componenti renderizzati, in modo simile all'albero DOM ma a livello di componenti Angular invece che di tag HTML. Selezionando un componente si possono ispezionare in tempo reale:
- I valori correnti di tutti gli
@Input()e dei signal passati come input. - Lo stato interno del componente (proprietà, signal, computed).
- La gerarchia dei componenti padre/figlio, per capire da dove arriva un dato inatteso.
È inoltre possibile modificare al volo il valore di una proprietà dal pannello e vedere il componente re-renderizzarsi istantaneamente — utilissimo per riprodurre un edge case (es. una lista vuota, un prezzo negativo) senza dover modificare i dati a monte.
3.3 — L'Injector Tree: capire da dove arriva un servizio
Il tab Injector Tree visualizza la gerarchia degli injector di Dependency
Injection dell'applicazione — utile per diagnosticare bug del tipo "perché questo componente
riceve un'istanza diversa del servizio rispetto a quello accanto?", tipicamente causati da un
provider dichiarato a livello di componente invece che root, che crea
un'istanza isolata per quel sottoalbero.
3.4 — Il Profiler: registrare un ciclo di Change Detection
Il tab Profiler è lo strumento più potente per il debugging delle performance. Premendo "Start recording" e interagendo con l'app (click, scroll, digitazione), Angular DevTools registra ogni ciclo di change detection e produce un grafico a barre — simile a una flame graph — dove ogni barra rappresenta un componente e la sua altezza il tempo impiegato a controllare i suoi binding.
// Scenario tipico da diagnosticare col Profiler:
// un componente lista che si ridisegna ad ogni digitazione in un campo di ricerca,
// anche se i dati della lista non sono cambiati.
@Component({
selector: 'app-product-list',
standalone: true,
// ❌ Senza OnPush, Angular controlla questo componente ad OGNI ciclo di CD
// dell'intera applicazione, anche per digitazioni in componenti non correlati.
template: ``,
})
export class ProductListComponent {
@Input() products: Product[] = [];
}
Nel Profiler, questo si manifesta come una barra larga e ripetuta per
ProductListComponent ad ogni singolo evento di input, anche quando l'utente sta
solo digitando in un campo che non ha nulla a che fare con la lista prodotti.
3.5 — Dalla diagnosi alla correzione: 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([]);
}
Con OnPush, Angular controlla il componente solo quando: (1) un
@Input/signal input cambia per riferimento, (2) un evento del DOM avviene al suo
interno, o (3) viene marcato esplicitamente con markForCheck(). Rieseguendo la
registrazione nel Profiler dopo questa modifica, la barra di ProductListComponent
scompare durante la digitazione nel campo di ricerca — la prova visiva che l'ottimizzazione ha
funzionato.
3.6 — trackBy (o track nella nuova sintassi @for) per liste grandi
Un'altra causa comune di colli di bottiglia visibile nel Profiler: liste che vengono completamente ri-renderizzate (distrutte e ricreate) ad ogni aggiornamento, invece di aggiornare solo gli elementi effettivamente cambiati.
@for (product of products(); track $index) {
}
@for (product of products(); track product.id) {
}
Nel Profiler, l'effetto di track $index su una lista che viene riordinata (es.
dopo un'operazione di sort) si vede come un numero elevato di componenti
ProductCardComponent distrutti e ricreati, invece di un semplice riposizionamento
— un chiaro segnale che il track scelto non identifica correttamente gli elementi.
3.7 — Combinare Angular DevTools con il pannello Performance di Chrome
Angular DevTools eccelle nel mostrare quali componenti Angular sta controllando, ma non entra nel dettaglio di cosa succede a livello di motore JavaScript (garbage collection, layout thrashing, long tasks). Per un'analisi completa, si registra una sessione parallela nel tab Performance nativo di Chrome DevTools:
- Le Long Tasks (barre rosse oltre 50ms) indicano lavoro sincrono che blocca il thread principale — spesso un ciclo di change detection troppo pesante.
- La sezione Layout / Reflow segnala letture/scritture del DOM alternate in modo inefficiente (es. leggere
offsetHeightdentro un loop che poi scrive stili). - Il grafico della memoria nel tempo aiuta a individuare memory leak — tipicamente subscription RxJS non disiscritte in componenti distrutti e ricreati ripetutamente (es. in una route riaperta più volte).
// Causa comune di memory leak: subscription manuale mai disiscritta
export class DashboardComponent implements OnInit {
ngOnInit(): void {
// ❌ Ogni volta che il componente viene ricreato, si accumula una nuova subscription
interval(5000).subscribe(() => this.refreshStats());
}
}
// ✅ Corretto con takeUntilDestroyed — la subscription viene ripulita
// automaticamente quando il componente viene distrutto da Angular
export class DashboardComponent implements OnInit {
private readonly destroyRef = inject(DestroyRef);
ngOnInit(): void {
interval(5000)
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(() => this.refreshStats());
}
}
3.8 — Trucchi extra da console: console.table, console.time, debugger
Non tutto il debugging richiede strumenti dedicati. Alcuni metodi nativi della console, spesso dimenticati, accelerano molto l'analisi di dati complessi:
// console.table: visualizza array di oggetti come una tabella leggibile,
// molto più utile di console.log per debuggare liste di dati
console.table(this.products());
// console.time / console.timeEnd: misura rapidamente la durata di un blocco
// senza dover importare strumenti di profiling esterni
console.time('calcolaTotaleCarrello');
const total = this.calculateTotal();
console.timeEnd('calcolaTotaleCarrello'); // stampa: calcolaTotaleCarrello: 2.341ms
// console.trace: stampa lo stack di chiamata completo — utilissimo per capire
// DA DOVE viene invocata una funzione chiamata da troppi punti nel codice
someSharedUtilityFunction(): void {
console.trace('someSharedUtilityFunction chiamata da:');
}
// L'istruzione debugger interrompe l'esecuzione esattamente come un breakpoint
// impostato manualmente, ma vive nel codice sorgente — utile per condizioni rare
if (order.total < 0) {
debugger; // il browser si ferma qui SOLO se i DevTools sono aperti
}
Un ultimo consiglio pratico: nei breakpoint condizionali di Chrome DevTools (click destro sul
numero di riga → "Add conditional breakpoint") si può inserire un'espressione come
product.price < 0, fermando l'esecuzione solo quando la condizione è vera,
invece di dover cliccare "continue" decine di volte in un loop.
3.9 — Heap snapshot: individuare memory leak con precisione
Quando il Profiler di Angular DevTools mostra un'applicazione che rallenta progressivamente dopo diversi minuti di utilizzo (sintomo classico di una memory leak), il passo successivo è il tab Memory di Chrome DevTools:
- Naviga nell'app fino a uno stato "pulito" (es. dashboard vuota), poi registra un primo Heap snapshot.
- Esegui l'azione sospetta ripetutamente (es. apri e chiudi un dialog 10 volte).
- Forza una garbage collection manuale (icona del cestino) e registra un secondo Heap snapshot.
- Usa la vista "Comparison" tra i due snapshot: gli oggetti che continuano ad aumentare di numero (es. istanze di
DialogComponentche dovrebbero essere state distrutte) sono il sintomo della leak.
// Causa tipica rilevabile in un Comparison heap snapshot:
// un EventEmitter custom o una subscription RxJS mantenuta viva da un servizio singleton,
// che a sua volta mantiene un riferimento al componente e ne impedisce la garbage collection.
@Injectable({ providedIn: 'root' })
export class NotificationBusService {
private readonly _messages = new Subject();
readonly messages$ = this._messages.asObservable();
}
// ❌ Il componente si sottoscrive al servizio singleton ma non si disiscrive mai:
// ogni istanza del componente resta "agganciata" al Subject per sempre.
export class ToastComponent implements OnInit {
constructor(private bus: NotificationBusService) {}
ngOnInit(): void {
this.bus.messages$.subscribe(msg => this.show(msg));
}
}
In un'applicazione dove il componente ToastComponent viene creato e distrutto
ripetutamente (es. dentro una route che l'utente visita spesso), questa singola subscription
non disiscritta accumula un riferimento per ogni istanza mai creata — esattamente il pattern
che un Comparison heap snapshot rende visibile con un numero di istanze "Detached" che cresce
senza mai tornare a zero.
Conclusione: Costruire una Rete di Sicurezza a Più Livelli
Nessuno di questi strumenti sostituisce gli altri: unit test veloci con Jest o Jasmine/Karma danno fiducia immediata su ogni singola unità di codice ad ogni salvataggio; i test E2E con Cypress o Playwright validano che i flussi critici (login, checkout, ricerca) funzionino davvero end-to-end prima del rilascio; Angular DevTools interviene quando serve capire perché qualcosa è lento o si comporta in modo inatteso, cosa che nessun test automatico da solo può diagnosticare a fondo.
La combinazione di questi tre livelli — moltiplicata per una CI che li esegue automaticamente ad ogni pull request — è ciò che trasforma un progetto Angular da "funziona sul mio computer" a un prodotto su cui il team può fare refactoring con fiducia, sapendo che una regressione verrà intercettata prima di raggiungere gli utenti.
- Checklist minima per un progetto Angular maturo:
- ✅ Unit test per ogni servizio con logica non banale, coverage minima configurata in CI.
- ✅ Test E2E sui 3-5 flussi realmente critici per il business (non su ogni singola pagina).
- ✅
ChangeDetectionStrategy.OnPushcome default per i componenti nuovi. - ✅ Una sessione di profiling con Angular DevTools prima di ogni release importante sulle pagine ad alto traffico.