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

Test e Debugging in Angular: Guida completa a Jest, Jasmine/Karma, Cypress, Playwright e Angular Devtools

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 --watch esegue 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.

AspettoJasmineJest
Creare uno spyjasmine.createSpy('name')jest.fn()
Mock di un intero moduloNon nativo (serve DI)jest.mock('./module')
Configurare il valore di ritornospy.and.returnValue(x)fn.mockReturnValue(x)
Snapshot testingNon supportato nativamenteexpect(x).toMatchSnapshot()
Timer fintifakeAsync + tick()jest.useFakeTimers() + jest.advanceTimersByTime()
Ambiente di esecuzioneBrowser 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ù expect nello 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 di it('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

CaratteristicaCypressPlaywright
ArchitetturaGira dentro il browser (stesso event loop dell'app)Controlla il browser dall'esterno via protocollo (CDP/WebSocket)
Browser supportatiChrome, Edge, Firefox, ElectronChromium, Firefox, WebKit (quindi anche Safari)
Multi-tab / multi-dominioSupporto limitato storicamenteNativo, senza workaround
Esecuzione parallelaRichiede Cypress Cloud (a pagamento) o sharding manualeNativa, gratuita, integrata nel config
Velocità tipicaBuonaGeneralmente superiore, specialmente in CI
Debug toolTime-travel debugging nell'UI interattivaTrace Viewer + UI Mode
Component testingSì, maturoSì (Playwright Component Testing, più recente)
Curva di apprendimentoMolto dolce, API molto leggibileLeggermente 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 offsetHeight dentro 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:

  1. Naviga nell'app fino a uno stato "pulito" (es. dashboard vuota), poi registra un primo Heap snapshot.
  2. Esegui l'azione sospetta ripetutamente (es. apri e chiudi un dialog 10 volte).
  3. Forza una garbage collection manuale (icona del cestino) e registra un secondo Heap snapshot.
  4. Usa la vista "Comparison" tra i due snapshot: gli oggetti che continuano ad aumentare di numero (es. istanze di DialogComponent che 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.OnPush come default per i componenti nuovi.
  • ✅ Una sessione di profiling con Angular DevTools prima di ogni release importante sulle pagine ad alto traffico.

💬 Note dei lettori

0 note

Scrivi una nota

Condividi la tua opinione, un suggerimento o un complimento

Ultime note

Nessuna nota ancora. Sii il primo a commentare!