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

Testes e Debugging em Angular: Guia completo sobre Jest, Jasmine/Karma, Cypress, Playwright e Angular Devtools

Introdução: Por Que Testes e Debugging Fazem a Diferença

Toda aplicação Angular que chega à produção acaba enfrentando a mesma pergunta: "Como sei que esta alteração não quebrou nada?". A resposta séria não é "testei à mão no browser", mas sim uma suíte de testes automáticos que roda em segundos, a cada commit, sem intervenção humana. Ao mesmo tempo, quando algo dá errado — um componente que se re-renderiza demasiadas vezes, uma memory leak, uma rota que não carrega — são necessárias ferramentas de debugging direcionadas para perceber o quê está a acontecer e onde, em vez de depender de console.log espalhados por todo o lado.

Neste guia aprofundamos três pilares do ciclo de vida de uma aplicação Angular madura:

  • Testes unitários com Jasmine/Karma (a predefinição do Angular CLI) e com Jest (a alternativa mais rápida e cada vez mais popular no ecossistema JavaScript moderno).
  • Testes E2E (end-to-end) com Cypress e Playwright, para validar fluxos completos de utilizador num browser real.
  • Debugging de desempenho com Angular DevTools, para encontrar gargalos na deteção de alterações (change detection) e na renderização.

Cada secção inclui exemplos de código prontos a usar, retirados de cenários reais envolvendo componentes, serviços, signals e chamadas HTTP — os mesmos elementos encontrados em qualquer aplicação Angular em produção.

Tópicos: #Angular #Testing #UnitTesting #Jest #Jasmine #Karma #Cypress #Playwright #E2ETesting #AngularDevTools #Debugging #Performance #WebDevelopment

A Pirâmide de Testes: Onde Investir o Tempo

Antes de escrever uma única linha de teste, vale a pena ter claro o modelo mental da pirâmide de testes, originalmente proposta por Mike Cohn:

  • Base (larga): Testes unitários. Rápidos (milissegundos), isolados, numerosos. Testam uma função, componente ou serviço isoladamente.
  • Meio: Testes de integração. Verificam se várias unidades colaboram corretamente (ex.: um componente com o seu serviço real, não mockado).
  • Topo (estreito): Testes E2E. Lentos (segundos/minutos), frágeis se mal escritos, mas os únicos que validam a experiência real do utilizador do login ao checkout.

O erro mais comum é inverter a pirâmide: poucos testes unitários e dezenas de testes E2E lentos e frágeis que transformam a CI num pesadelo de 40 minutos. O objetivo deste guia é dar- lhe as ferramentas para construir a pirâmide no sentido correto: muitos testes unitários rápidos, um número razoável de testes E2E nos fluxos críticos, e ferramentas de debugging para quando algo ainda assim escapa aos testes.

Parte 1: Testes Unitários com Jest e Jasmine/Karma

1.1 — A configuração predefinida: Jasmine + Karma

Quando cria um projeto com ng new, o Angular CLI configura automaticamente o Jasmine como framework de asserções (describe, it, expect) e o Karma como test runner, que executa os testes num browser real (Chrome Headless por predefinição) através do 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,
  });
};

Para executar os testes: ng test localmente (watch mode ativo) ou ng test --no-watch --browsers=ChromeHeadless em CI, onde não é necessário um browser interativo e queremos que o processo termine com um exit code em vez de ficar à escuta.

1.2 — Anatomia de um teste unitário: describe, it, expect

Cada ficheiro de teste Jasmine segue a mesma estrutura: describe agrupa testes logicamente relacionados, it (ou o seu alias test) define um caso único, expect efetua a asserção.

// 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();
  });
});

Este é um teste unitário puro: sem qualquer dependência do Angular, sem TestBed, apenas uma função e as suas asserções. É o tipo de teste mais rápido de escrever, ler e executar — e deve ser preferido sempre que a lógica possa ser extraída de um componente ou serviço para uma função pura, testável isoladamente.

1.3 — Testar um serviço Angular com TestBed

Quando a lógica depende do DI (Dependency Injection) do Angular, é necessário o TestBed para construir um ambiente de teste com os mesmos providers que a aplicação real usaria — substituindo, no entanto, as dependências externas (HTTP, storage, outros serviços) por mocks.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Note como este teste também valida os computed signals: não é necessária qualquer configuração especial, os computeds recalculam-se de forma síncrona e podem ser lidos como uma função normal (service.total()) imediatamente após a alteração do estado.

1.4 — Testar um componente standalone com TestBed e ComponentFixture

Testar um componente requer um passo adicional em relação a um serviço: é necessário criar uma instância real do componente no DOM através do ComponentFixture, forçar um ciclo de change detection com fixture.detectChanges(), e depois consultar o DOM resultante com DebugElement ou queries nativas.

// 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();
  });
});

Usar atributos data-testid em vez de classes CSS para selecionar elementos nos testes é uma boa prática consolidada: desacopla os testes do estilo visual, para que um refactoring do CSS não quebre a suíte de testes.

1.5 — Espiar e mockar dependências com jasmine.createSpyObj

Quando um componente depende de um serviço, não queremos que os seus testes unitários invoquem a lógica real do serviço (que por sua vez poderia chamar uma API HTTP real). Em vez disso, criamos um mock apenas com os métodos necessários, controlando exatamente o que devolvem.

// 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 cria um objeto com métodos falsos que registam cada chamada. .and.returnValue(...) configura o que devolver, e toHaveBeenCalledWith(...) verifica os argumentos passados. Esta técnica isola completamente o componente sob teste da lógica real das suas dependências.

1.6 — Testar chamadas HTTP com HttpClientTestingModule

Para serviços que usam HttpClient, o Angular fornece HttpClientTestingModule e HttpTestingController: interceptam pedidos HTTP reais e permitem responder com dados falsos, verificando simultaneamente o URL, o método e os headers usados.

// 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(() => {
    // Essencial: verifica que não há pedidos HTTP órfãos não tratados pelo teste
    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 });
  });
});

O método httpMock.verify() no afterEach é essencial: faz falhar o teste se existirem pedidos HTTP que o componente/serviço enviou mas que nenhuma asserção interceptou, evitando falsos positivos em que um pedido é "esquecido" sem erros.

1.7 — Código assíncrono: fakeAsync, tick() e waitForAsync

Alguns cenários — debounce num campo de pesquisa, timeouts, retries com atraso — envolvem setTimeout ou operadores RxJS temporizados como debounceTime. Testá-los com temporizadores reais tornaria a suíte lenta e não determinística. O fakeAsync resolve o problema virtualizando o 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');

  // Passaram apenas 200ms desde a última alteração: a pesquisa NÃO deve disparar ainda
  expect(TestBed.inject(ProductService).search).not.toHaveBeenCalled();

  tick(300); // completamos o debounce
  fixture.detectChanges();

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

O tick(ms) avança artificialmente o relógio virtual da zona de teste, disparando os temporizadores pendentes sem esperar efetivamente esse tempo. Isto torna o teste determinístico e instantâneo, ao mesmo tempo que valida corretamente a lógica de debounce.

1.8 — Testar Effects reativos a Signals

Com a introdução dos Signals, muitos componentes usam effect() para reagir a alterações de estado (ex.: sincronizar com localStorage). Testar um effect requer forçar explicitamente o flush do contexto reativo com TestBed.flushEffects() ou, mais simplesmente, com fixture.detectChanges() quando o componente está ligado a um 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(); // força o flush dos effects pendentes

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

1.9 — Marble testing para Observables complexos

Quando a lógica RxJS se torna complexa (combinações de combineLatest, switchMap, retry com backoff), o marble testing com TestScheduler permite descrever sequências temporais de eventos de forma declarativa e verificá-las num único tick síncrono.

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 }) => {
      // '#' representa um erro, '-' um frame de tempo, 'a' um valor emitido
      const source$ = cold('-#', undefined, new Error('fail'));
      const expected = '  -- -- -- -- -#';

      // Num cenário real aplicaríamos aqui retryWithBackoff(source$, { retries: 2 })
      expectObservable(source$.pipe()).toBe('-#', undefined, new Error('fail'));
    });
  });
});

O marble testing tem uma curva de aprendizagem mais acentuada em comparação com os testes "clássicos", mas compensa quando a lógica reativa envolve temporizações precisas que seriam difíceis de verificar de forma fiável apenas com fakeAsync.

1.10 — Migrar para Jest: porquê e como

O Jest tornou-se o padrão de facto no ecossistema React/Node, e cada vez mais equipas Angular adotam-no em vez do Karma por três motivos concretos:

  • Velocidade: o Jest executa os testes em Node.js com JSDOM, sem abrir um browser Chrome real — significativamente mais rápido, especialmente em CI.
  • Watch mode inteligente: jest --watch executa apenas os testes relacionados com os ficheiros alterados (através de análise do git diff).
  • Snapshot testing e mocking integrados: jest.fn(), jest.mock() e os snapshots são nativos, sem bibliotecas adicionais.
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: diferenças práticas na sintaxe

Boa notícia: a sintaxe describe/it/expect é quase idêntica, pelo que a migração da maior parte dos testes é frequentemente apenas uma questão de configuração. As diferenças surgem sobretudo no mocking.

AspetoJasmineJest
Criar um spyjasmine.createSpy('name')jest.fn()
Mock de um módulo inteiroNão nativo (requer DI)jest.mock('./module')
Configurar o valor de retornospy.and.returnValue(x)fn.mockReturnValue(x)
Snapshot testingNão suportado nativamenteexpect(x).toMatchSnapshot()
Temporizadores falsosfakeAsync + tick()jest.useFakeTimers() + jest.advanceTimersByTime()
Ambiente de execuçãoBrowser real (Karma + Chrome)Node.js + JSDOM
Velocidade típica (500 testes)~25-40s~8-15s
// Exemplo de mock de módulo com Jest — útil para bibliotecas externas pesadas
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();
    // ... configuração do componente com o mock ...
    expect(trackSpy).toHaveBeenCalledWith('purchase', expect.objectContaining({ orderId: expect.any(String) }));
  });
});

1.12 — Snapshot testing: quando usar (e quando evitar)

Os testes de snapshot capturam o output renderizado de um componente e comparam-no com uma versão guardada em disco. São úteis para componentes puramente apresentacionais com output estável, mas tornam-se um problema quando a equipa os atualiza automaticamente com --ci=false --updateSnapshot sem rever o diff — transformando-os numa verificação puramente formal.

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();
});

Regra prática: use snapshots apenas para markup simples e estável (badges, ícones, formatadores), nunca para componentes com lógica de negócio — aí, uma asserção explícita (expect(text).toBe('Spedito')) comunica a intenção muito melhor do que um snapshot opaco de 200 linhas de HTML.

1.13 — Boas práticas transversais para testes unitários eficazes

  • Padrão AAA (Arrange-Act-Assert): estruture cada teste em três blocos claros: prepare os dados, execute a ação, verifique o resultado.
  • Uma asserção conceptual por teste: vários expect no mesmo teste são aceitáveis se verificarem a mesma coisa de ângulos diferentes, mas um teste que verifica três comportamentos independentes deve ser dividido em três.
  • Não teste detalhes de implementação: teste o comportamento observável (output, eventos emitidos, DOM renderizado), não variáveis privadas ou chamadas a métodos internos.
  • Nomes de teste descritivos: it('desativa o botão quando o carrinho está vazio') é infinitamente mais útil do que it('teste 3') quando um teste falha em CI às 2 da manhã.
  • Testes independentes: cada teste deve poder correr sozinho, em qualquer ordem. Se um teste depende do estado deixado por outro, é um teste frágil.
  • Coverage como indicador, não como objetivo: 100% de code coverage com asserções fracas (expect(true).toBe(true)) é pior do que 70% com asserções sólidas. Use a coverage para encontrar código não testado, não como um KPI a maximizar a todo o custo.

1.14 — Testar Guards e Resolvers

As route guards e os resolvers são frequentemente onde reside a lógica de autorização mais delicada de uma aplicação — e é exatamente o tipo de código que deve ter uma cobertura de testes sólida, porque um bug aqui significa utilizadores não autenticados a aceder a páginas protegidas, ou o oposto.

// 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' },
    });
  });
});

Testar a guard como uma simples função (graças ao TestBed.runInInjectionContext) em vez de montar um componente de rota inteiro é muito mais rápido e foca-se exatamente na lógica de autorização, sem ruído proveniente da renderização.

1.15 — Testar Pipes e Diretivas personalizadas

// 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(''); }); });

Pipes e diretivas, sendo unidades de lógica muito pequenas e sem dependências pesadas, estão entre os elementos mais económicos de testar exaustivamente: alguns minutos de escrita garantem uma cobertura quase completa de todos os edge cases (string vazia, valor limite, input malformado).

Parte 2: Testes E2E com Cypress e Playwright

2.1 — Por que são necessários testes E2E além dos testes unitários

Os testes unitários verificam que as peças individuais funcionam isoladamente, mas não conseguem apanhar problemas de integração: routing mal configurado, CSS que esconde um botão, CORS bloqueado apenas num ambiente específico, ou uma regressão no fluxo completo de checkout. Os testes E2E abrem um browser real (ou um headless), navegam na aplicação como faria um utilizador real e verificam o resultado final.

2.2 — Cypress: instalação e estrutura do projeto

npm install --save-dev cypress
npx cypress open   # modo interativo, útil durante o desenvolvimento
npx cypress run    # modo headless, para 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, // desativado localmente, frequentemente reativado em CI para debug
    retries: {
      runMode: 2,  // repete os testes falhados 2 vezes em CI (mitiga flakiness de rede)
      openMode: 0,
    },
    env: {
      apiUrl: 'http://localhost:3000/api',
    },
  },
});

A estrutura típica de um projeto 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 — Primeiro teste Cypress: fluxo de 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 — Interceptar chamadas de rede com cy.intercept

Um teste E2E que depende de um backend real é lento e frágil (dados que mudam, serviços externos indisponíveis). O cy.intercept permite fazer stub das respostas HTTP mantendo o teste realista mas determinístico.

// 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: mantendo DRY nos testes Cypress

Ações repetidas em muitos testes (login, adicionar um produto ao carrinho) devem ser extraídas para custom commands para evitar duplicação.

// 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 {};
// Utilização num teste — login "silencioso" via API em vez de preencher o formulário sempre
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');
  });
});

Fazer login via chamada direta à API (cy.request) em vez de através da UI sempre é uma das otimizações mais eficazes para acelerar uma suíte E2E: um teste que não pretende testar o próprio login também não precisa de passar por ele através da interface.

2.6 — Cypress Component Testing

Além dos testes E2E clássicos, o Cypress suporta o Component Testing: monta um único componente Angular isoladamente num browser real, combinando a velocidade dos testes unitários com a fidelidade de uma renderização real (sem 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: instalação e configuração

npm init playwright@latest
# Instala os browsers necessários (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', // regista uma trace apenas quando um teste falha e é repetido
    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 — Primeiro teste Playwright: o mesmo fluxo de 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

O Playwright usa locators, objetos "lazy" que se reconectam automaticamente ao DOM antes de cada ação, com auto-waiting integrado: não é necessário qualquer sleep ou espera explícita, o Playwright espera automaticamente que o elemento esteja visível, ativo e estável antes de interagir com ele.

// 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 — Execução paralela cross-browser e Trace Viewer

Um dos pontos fortes do Playwright é a execução nativa paralela em Chromium, Firefox e WebKit sem infraestrutura adicional (grid, cloud provider). Quando um teste falha em CI, a trace registada permite rever toda a execução — DOM snapshot, network, console — passo a passo, como um vídeo interativo.

# Executa a suíte em todos os browsers configurados, em paralelo
npx playwright test

# Executa apenas no Chromium com UI interativa de debug
npx playwright test --project=chromium --ui

# Analisa a trace de um teste falhado em CI
npx playwright show-trace trace.zip

2.11 — Cypress vs Playwright: tabela comparativa

CaracterísticaCypressPlaywright
ArquiteturaCorre dentro do browser (mesmo event loop da app)Controla o browser a partir de fora via protocolo (CDP/WebSocket)
Browsers suportadosChrome, Edge, Firefox, ElectronChromium, Firefox, WebKit (portanto também Safari)
Multi-tab / multi-domínioSuporte historicamente limitadoNativo, sem workarounds
Execução paralelaRequer Cypress Cloud (pago) ou sharding manualNativa, gratuita, integrada na config
Velocidade típicaBoaGeralmente superior, especialmente em CI
Ferramentas de debugTime-travel debugging na UI interativaTrace Viewer + UI Mode
Component testingSim, maduroSim (Playwright Component Testing, mais recente)
Curva de aprendizagemMuito suave, API muito legívelLigeiramente mais ampla (async/await em todo o lado)

Na prática: se a equipa já está habituada ao Cypress e o projeto não requer Safari/WebKit, o Cypress continua a ser uma ótima escolha pela sua developer experience. Se for necessária cobertura real multi-browser (incluindo Safari) e paralelização nativa sem custos adicionais, o Playwright é hoje a escolha mais sólida para novos projetos.

2.12 — Integração em CI/CD com 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

Um detalhe frequentemente esquecido: executar os testes E2E apenas em pull requests que tocam código frontend (através de paths: no trigger) evita desperdiçar minutos de CI em alterações que afetam apenas, por exemplo, a documentação ou o backend.

2.13 — Visual regression testing com Playwright

Para além das asserções funcionais, o Playwright permite capturar screenshots e compará-los automaticamente com uma versão de referência guardada em disco — útil para apanhar regressões puramente visuais (uma margem que muda, uma cor errada) que nenhuma asserção textual detetaria.

// 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');

    // A primeira execução gera o screenshot de referência (--update-snapshots);
    // as execuções seguintes falham se houver diferenças de pixels acima do limiar.
    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');
  });
});
# Atualiza os screenshots de referência após uma alteração de UI intencional
npx playwright test --update-snapshots

# Em CI, guarde sempre os diffs gerados como artifact para revisão visual

Regra prática: o limiar maxDiffPixelRatio deve ser calibrado para tolerar pequenas variações de anti-aliasing entre ambientes diferentes (local vs CI), caso contrário o teste torna-se flaky por motivos que nada têm a ver com um verdadeiro bug visual.

Parte 3: Truques de Debugging com Angular DevTools

3.1 — Instalação e primeiro arranque

O Angular DevTools é uma extensão oficial para Chrome e Firefox que adiciona um painel dedicado às ferramentas de desenvolvimento do browser. Após a instalação a partir da Chrome Web Store, aparecerá um separador "Angular" nas DevTools (F12) de qualquer página que execute uma aplicação Angular em modo development (em produção, com enableProdMode() ativo, os metadados necessários são removidos por motivos de desempenho e segurança).

3.2 — Component Explorer: inspecionar a árvore de componentes

O separador Components mostra toda a árvore de componentes renderizados, de forma semelhante à árvore DOM mas ao nível dos componentes Angular em vez de tags HTML. Ao selecionar um componente, é possível inspecionar em tempo real:

  • Os valores atuais de todos os @Input() e dos signals passados como input.
  • O estado interno do componente (propriedades, signals, computeds).
  • A hierarquia de componentes pai/filho, para perceber de onde vem um dado inesperado.

É ainda possível modificar em tempo real o valor de uma propriedade a partir do painel e ver o componente re-renderizar-se instantaneamente — extremamente útil para reproduzir um edge case (ex.: uma lista vazia, um preço negativo) sem ter de alterar os dados a montante.

3.3 — A Injector Tree: perceber de onde vem um serviço

O separador Injector Tree visualiza a hierarquia dos injectors de Dependency Injection da aplicação — útil para diagnosticar bugs do tipo "por que razão este componente recebe uma instância diferente do serviço em relação ao que está ao lado?", tipicamente causados por um provider declarado ao nível do componente em vez de root, o que cria uma instância isolada para essa subárvore.

3.4 — O Profiler: registar um ciclo de Change Detection

O separador Profiler é a ferramenta mais poderosa para debugging de desempenho. Ao premir "Start recording" e interagir com a app (cliques, scroll, digitação), o Angular DevTools regista cada ciclo de change detection e produz um gráfico de barras — semelhante a uma flame graph — onde cada barra representa um componente e a sua altura o tempo gasto a verificar os seus bindings.

// Cenário típico a diagnosticar com o Profiler:
// um componente lista que se re-renderiza a cada tecla premida num campo de pesquisa,
// mesmo que os dados da lista não tenham mudado.

@Component({
  selector: 'app-product-list',
  standalone: true,
  // ❌ Sem OnPush, o Angular verifica este componente em TODOS os ciclos de CD
  // de toda a aplicação, mesmo para teclas premidas em componentes não relacionados.
  template: ``,
})
export class ProductListComponent {
  @Input() products: Product[] = [];
}

No Profiler, isto manifesta-se como uma barra larga e repetida para ProductListComponent em cada evento de input, mesmo quando o utilizador está apenas a escrever num campo que não tem qualquer relação com a lista de produtos.

3.5 — Do diagnóstico à correção: 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([]);
}

Com o OnPush, o Angular verifica o componente apenas quando: (1) um @Input/signal input muda por referência, (2) ocorre um evento DOM dentro dele, ou (3) é explicitamente marcado com markForCheck(). Ao repetir o registo no Profiler após esta alteração, a barra de ProductListComponent desaparece durante a digitação no campo de pesquisa — a prova visual de que a otimização funcionou.

3.6 — trackBy (ou track na nova sintaxe @for) para listas grandes

Outra causa comum de gargalos visível no Profiler: listas que são completamente re-renderizadas (destruídas e recriadas) a cada atualização, em vez de atualizar apenas os elementos efetivamente alterados.


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


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

No Profiler, o efeito de track $index numa lista que é reordenada (ex.: após uma operação de sort) vê-se como um número elevado de componentes ProductCardComponent destruídos e recriados, em vez de um simples reposicionamento — um sinal claro de que o track escolhido não identifica corretamente os elementos.

3.7 — Combinar o Angular DevTools com o painel Performance do Chrome

O Angular DevTools destaca-se a mostrar quais componentes o Angular está a verificar, mas não entra em detalhe sobre o que acontece ao nível do motor JavaScript (garbage collection, layout thrashing, long tasks). Para uma análise completa, regista-se uma sessão paralela no separador nativo Performance do Chrome DevTools:

  • As Long Tasks (barras vermelhas acima de 50ms) indicam trabalho síncrono que bloqueia a thread principal — frequentemente um ciclo de change detection demasiado pesado.
  • A secção Layout / Reflow assinala leituras/escritas do DOM alternadas de forma ineficiente (ex.: ler offsetHeight dentro de um loop que depois escreve estilos).
  • O gráfico da memória ao longo do tempo ajuda a identificar memory leaks — tipicamente subscriptions RxJS nunca canceladas em componentes destruídos e recriados repetidamente (ex.: numa rota que o utilizador reabre frequentemente).
// Causa comum de memory leak: subscription manual nunca cancelada
export class DashboardComponent implements OnInit {
  ngOnInit(): void {
    // ❌ Cada vez que o componente é recriado, acumula-se uma nova subscription
    interval(5000).subscribe(() => this.refreshStats());
  }
}

// ✅ Corrigido com takeUntilDestroyed — a subscription é limpa
//    automaticamente quando o Angular destrói o componente
export class DashboardComponent implements OnInit {
  private readonly destroyRef = inject(DestroyRef);

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

3.8 — Truques extra de consola: console.table, console.time, debugger

Nem todo o debugging requer ferramentas dedicadas. Alguns métodos nativos da consola, frequentemente esquecidos, aceleram muito a análise de dados complexos:

// console.table: mostra arrays de objetos como uma tabela legível,
// muito mais útil do que console.log para depurar listas de dados
console.table(this.products());

// console.time / console.timeEnd: mede rapidamente a duração de um bloco
// sem ter de importar ferramentas de profiling externas
console.time('calcolaTotaleCarrello');
const total = this.calculateTotal();
console.timeEnd('calcolaTotaleCarrello'); // imprime: calcolaTotaleCarrello: 2.341ms

// console.trace: imprime a stack de chamadas completa — muito útil para perceber
// DE ONDE é invocada uma função chamada a partir de demasiados pontos no código
someSharedUtilityFunction(): void {
  console.trace('someSharedUtilityFunction chiamata da:');
}

// A instrução debugger interrompe a execução exatamente como um breakpoint
// definido manualmente, mas vive no código fonte — útil para condições raras
if (order.total < 0) {
  debugger; // o browser para aqui APENAS se as DevTools estiverem abertas
}

Uma última dica prática: nos breakpoints condicionais do Chrome DevTools (clique direito no número da linha → "Add conditional breakpoint") pode inserir-se uma expressão como product.price < 0, parando a execução apenas quando a condição for verdadeira, em vez de ter de clicar "continue" dezenas de vezes dentro de um loop.

3.9 — Heap snapshot: identificar memory leaks com precisão

Quando o Profiler do Angular DevTools mostra uma aplicação a abrandar progressivamente após vários minutos de utilização (sintoma clássico de memory leak), o passo seguinte é o separador Memory do Chrome DevTools:

  1. Navegue na app até um estado "limpo" (ex.: dashboard vazia), depois registe um primeiro Heap snapshot.
  2. Execute a ação suspeita repetidamente (ex.: abra e feche um diálogo 10 vezes).
  3. Force uma garbage collection manual (ícone do caixote do lixo) e registe um segundo Heap snapshot.
  4. Use a vista "Comparison" entre os dois snapshots: os objetos que continuam a aumentar em número (ex.: instâncias de DialogComponent que deveriam ter sido destruídas) são o sintoma da leak.
// Causa típica detetável num Comparison heap snapshot:
// um EventEmitter personalizado ou uma subscription RxJS mantida viva por um serviço singleton,
// que por sua vez mantém uma referência ao componente e impede a garbage collection.

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

// ❌ O componente subscreve o serviço singleton mas nunca cancela a subscrição:
// cada instância do componente fica "presa" ao Subject para sempre.
export class ToastComponent implements OnInit {
  constructor(private bus: NotificationBusService) {}
  ngOnInit(): void {
    this.bus.messages$.subscribe(msg => this.show(msg));
  }
}

Numa aplicação onde o componente ToastComponent é criado e destruído repetidamente (ex.: dentro de uma rota que o utilizador visita frequentemente), esta única subscription não cancelada acumula uma referência por cada instância alguma vez criada — exatamente o padrão que um Comparison heap snapshot torna visível, com um número de instâncias "Detached" que continua a crescer e nunca volta a zero.

Conclusão: Construir uma Rede de Segurança em Várias Camadas

Nenhuma destas ferramentas substitui as outras: testes unitários rápidos com Jest ou Jasmine/Karma dão confiança imediata em cada unidade de código a cada gravação; os testes E2E com Cypress ou Playwright validam que os fluxos críticos (login, checkout, pesquisa) realmente funcionam de ponta a ponta antes do lançamento; o Angular DevTools entra em ação quando é preciso perceber porquê algo está lento ou se comporta de forma inesperada, algo que nenhum teste automático sozinho consegue diagnosticar por completo.

A combinação destas três camadas — multiplicada por uma CI que as executa automaticamente em cada pull request — é o que transforma um projeto Angular de "funciona no meu computador" num produto sobre o qual a equipa pode fazer refactoring com confiança, sabendo que uma regressão será apanhada antes de chegar aos utilizadores.

  • Checklist mínima para um projeto Angular maduro:
  • ✅ Testes unitários para cada serviço com lógica não trivial, com coverage mínima configurada em CI.
  • ✅ Testes E2E nos 3-5 fluxos realmente críticos para o negócio (não em todas as páginas individualmente).
  • ✅ ChangeDetectionStrategy.OnPush como predefinição para novos componentes.
  • ✅ Uma sessão de profiling com Angular DevTools antes de cada release importante nas páginas de maior tráfego.

💬 Notas dos leitores

0 notas

Escreva uma nota

Partilhe a sua opinião, uma sugestão ou um elogio

Notas recentes

Ainda não há notas. Seja o primeiro a comentar!