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

Testing y depuración en Angular: Guía completa de Jest, Jasmine/Karma, Cypress, Playwright y Angular Devtools

Introducción: Por Qué Testing y Depuración Marcan la Diferencia

Toda aplicación Angular que llega a producción acaba enfrentándose a la misma pregunta: "¿Cómo sé que este cambio no ha roto nada?". La respuesta seria no es "lo probé a mano en el navegador", sino una suite de tests automáticos que se ejecuta en segundos, en cada commit, sin intervención humana. Al mismo tiempo, cuando algo va mal — un componente que se re-renderiza demasiadas veces, una memory leak, una ruta que no carga — se necesitan herramientas de depuración específicas para entender qué está pasando y dónde, en lugar de depender de console.log dispersos por todas partes.

En esta guía profundizamos en tres pilares del ciclo de vida de una aplicación Angular madura:

  • Tests unitarios con Jasmine/Karma (la opción predeterminada de Angular CLI) y con Jest (la alternativa más rápida y cada vez más popular en el ecosistema JavaScript moderno).
  • Tests E2E (end-to-end) con Cypress y Playwright, para validar flujos completos de usuario en un navegador real.
  • Depuración de rendimiento con Angular DevTools, para encontrar cuellos de botella en la detección de cambios y en el renderizado.

Cada sección incluye ejemplos de código listos para usar, extraídos de escenarios reales con componentes, servicios, signals y llamadas HTTP — los mismos elementos que se encuentran en cualquier aplicación Angular en producción.

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

La Pirámide de Tests: Dónde Invertir el Tiempo

Antes de escribir una sola línea de test, conviene tener claro el modelo mental de la pirámide de tests, propuesta originalmente por Mike Cohn:

  • Base (amplia): Tests unitarios. Rápidos (milisegundos), aislados, numerosos. Prueban una función, componente o servicio de forma aislada.
  • Centro: Tests de integración. Verifican que varias unidades colaboran correctamente (p. ej. un componente con su servicio real, no mockeado).
  • Cima (estrecha): Tests E2E. Lentos (segundos/minutos), frágiles si están mal escritos, pero los únicos que validan la experiencia real del usuario desde el login hasta el checkout.

El error más común es invertir la pirámide: pocos tests unitarios y decenas de tests E2E lentos y frágiles que convierten la CI en una pesadilla de 40 minutos. El objetivo de esta guía es darte las herramientas para construir la pirámide en el sentido correcto: muchos tests unitarios rápidos, un número razonable de tests E2E en los flujos críticos, y herramientas de depuración para cuando algo aún así se escapa a los tests.

Parte 1: Tests Unitarios con Jest y Jasmine/Karma

1.1 — La configuración predeterminada: Jasmine + Karma

Cuando creas un proyecto con ng new, Angular CLI configura automáticamente Jasmine como framework de aserciones (describe, it, expect) y Karma como test runner, que ejecuta los tests en un navegador real (Chrome Headless por defecto) a través de 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 ejecutar los tests: ng test localmente (watch mode activo) o ng test --no-watch --browsers=ChromeHeadless en CI, donde no se necesita un navegador interactivo y queremos que el proceso termine con un exit code en lugar de quedarse a la escucha.

1.2 — Anatomía de un test unitario: describe, it, expect

Cada archivo de test Jasmine sigue la misma estructura: describe agrupa tests relacionados lógicamente, it (o su alias test) define un caso único, expect realiza la aserción.

// 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 es un test unitario puro: sin ninguna dependencia de Angular, sin TestBed, solo una función y sus aserciones. Es el tipo de test más rápido de escribir, leer y ejecutar — y debe preferirse siempre que la lógica pueda extraerse de un componente o servicio a una función pura, testeable de forma aislada.

1.3 — Testear un servicio Angular con TestBed

Cuando la lógica depende del DI (Dependency Injection) de Angular, se necesita TestBed para construir un entorno de test con los mismos providers que usaría la aplicación real — sustituyendo, eso sí, las dependencias externas (HTTP, storage, otros servicios) 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);
  });
});

Fíjate en cómo este test también valida los computed signals: no se necesita ninguna configuración especial, los computeds se recalculan de forma síncrona y pueden leerse como una función normal (service.total()) inmediatamente después de que cambie el estado.

1.4 — Testear un componente standalone con TestBed y ComponentFixture

Testear un componente requiere un paso más que un servicio: hay que crear una instancia real del componente en el DOM mediante ComponentFixture, forzar un ciclo de change detection con fixture.detectChanges(), y luego consultar el DOM resultante con DebugElement o 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 en lugar de clases CSS para seleccionar elementos en los tests es una buena práctica consolidada: desacopla los tests del estilo visual, de modo que un refactoring del CSS no rompe la suite de tests.

1.5 — Espiar y mockear dependencias con jasmine.createSpyObj

Cuando un componente depende de un servicio, no queremos que sus tests unitarios invoquen la lógica real del servicio (que a su vez podría llamar a una API HTTP real). En su lugar, creamos un mock con solo los métodos necesarios, controlando exactamente qué devuelven.

// 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 objeto con métodos falsos que registran cada llamada. .and.returnValue(...) configura qué devolver, y toHaveBeenCalledWith(...) verifica los argumentos pasados. Esta técnica aísla completamente el componente bajo test de la lógica real de sus dependencias.

1.6 — Testear llamadas HTTP con HttpClientTestingModule

Para los servicios que usan HttpClient, Angular proporciona HttpClientTestingModule y HttpTestingController: interceptan las peticiones HTTP reales y permiten responder con datos falsos, verificando al mismo tiempo la URL, el método y las cabeceras usadas.

// 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(() => {
    // Esencial: verifica que no haya peticiones HTTP huérfanas no gestionadas por el 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 });
  });
});

El método httpMock.verify() en afterEach es esencial: hace fallar el test si hay peticiones HTTP que el componente/servicio ha enviado pero que ninguna aserción ha interceptado, evitando falsos positivos donde una petición se "olvida" sin errores.

1.7 — Código asíncrono: fakeAsync, tick() y waitForAsync

Algunos escenarios — debounce en un campo de búsqueda, timeouts, retries con retardo — implican setTimeout u operadores RxJS temporizados como debounceTime. Testearlos con temporizadores reales haría la suite lenta y no determinista. fakeAsync resuelve el problema virtualizando el tiempo.

// 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 han pasado 200ms desde el último cambio: la búsqueda NO debe dispararse todavía
  expect(TestBed.inject(ProductService).search).not.toHaveBeenCalled();

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

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

tick(ms) avanza artificialmente el reloj virtual de la zona de test, disparando los temporizadores pendientes sin esperar realmente ese tiempo. Esto hace el test determinista e instantáneo, a la vez que valida correctamente la lógica de debounce.

1.8 — Testear Effects reactivos a Signals

Con la introducción de los Signals, muchos componentes usan effect() para reaccionar a cambios de estado (p. ej. sincronizar con localStorage). Testear un effect requiere forzar explícitamente el flush del contexto reactivo con TestBed.flushEffects() o, más sencillamente, con fixture.detectChanges() cuando el componente está conectado 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(); // fuerza el flush de los effects pendientes

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

1.9 — Marble testing para Observables complejos

Cuando la lógica RxJS se vuelve compleja (combinaciones de combineLatest, switchMap, retry con backoff), el marble testing con TestScheduler permite describir secuencias temporales de eventos de forma declarativa y verificarlas en un ú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 un error, '-' un frame de tiempo, 'a' un valor emitido
      const source$ = cold('-#', undefined, new Error('fail'));
      const expected = '  -- -- -- -- -#';

      // En un escenario real aquí aplicaríamos retryWithBackoff(source$, { retries: 2 })
      expectObservable(source$.pipe()).toBe('-#', undefined, new Error('fail'));
    });
  });
});

El marble testing tiene una curva de aprendizaje más pronunciada comparado con los tests "clásicos", pero compensa cuando la lógica reactiva implica temporizaciones precisas que serían difíciles de verificar de forma fiable solo con fakeAsync.

1.10 — Pasarse a Jest: por qué y cómo

Jest se ha convertido en el estándar de facto en el ecosistema React/Node, y cada vez más equipos Angular lo adoptan en lugar de Karma por tres motivos concretos:

  • Velocidad: Jest ejecuta los tests en Node.js con JSDOM, sin abrir un navegador Chrome real — significativamente más rápido, especialmente en CI.
  • Watch mode inteligente: jest --watch ejecuta solo los tests relacionados con los archivos modificados (mediante análisis del git diff).
  • Snapshot testing y mocking integrados: jest.fn(), jest.mock() y los snapshots son nativos, sin librerías adicionales.
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: diferencias prácticas de sintaxis

Buena noticia: la sintaxis describe/it/expect es casi idéntica, así que migrar la mayoría de los tests es a menudo solo cuestión de configuración. Las diferencias aparecen sobre todo en el mocking.

AspectoJasmineJest
Crear un spyjasmine.createSpy('name')jest.fn()
Mock de un módulo enteroNo nativo (requiere DI)jest.mock('./module')
Configurar el valor de retornospy.and.returnValue(x)fn.mockReturnValue(x)
Snapshot testingNo soportado nativamenteexpect(x).toMatchSnapshot()
Timers falsosfakeAsync + tick()jest.useFakeTimers() + jest.advanceTimersByTime()
Entorno de ejecuciónNavegador real (Karma + Chrome)Node.js + JSDOM
Velocidad típica (500 tests)~25-40s~8-15s
// Ejemplo de mock de módulo con Jest — útil para librerías 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();
    // ... configuración del componente con el mock ...
    expect(trackSpy).toHaveBeenCalledWith('purchase', expect.objectContaining({ orderId: expect.any(String) }));
  });
});

1.12 — Snapshot testing: cuándo usarlo (y cuándo evitarlo)

Los tests de snapshot capturan la salida renderizada de un componente y la comparan con una versión guardada en disco. Son útiles para componentes puramente presentacionales con salida estable, pero se convierten en un problema cuando el equipo los actualiza automáticamente con --ci=false --updateSnapshot sin revisar el diff — convirtiéndolos en una comprobación 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();
});

Regla práctica: usa los snapshots solo para markup simple y estable (badges, iconos, formateadores), nunca para componentes con lógica de negocio — ahí, una aserción explícita (expect(text).toBe('Spedito')) comunica la intención mucho mejor que un snapshot opaco de 200 líneas de HTML.

1.13 — Buenas prácticas transversales para tests unitarios eficaces

  • Patrón AAA (Arrange-Act-Assert): estructura cada test en tres bloques claros: prepara los datos, ejecuta la acción, verifica el resultado.
  • Una aserción conceptual por test: varios expect en el mismo test están bien si verifican lo mismo desde ángulos diferentes, pero un test que verifica tres comportamientos independientes debe dividirse en tres.
  • No testees detalles de implementación: testea el comportamiento observable (salida, eventos emitidos, DOM renderizado), no variables privadas ni llamadas a métodos internos.
  • Nombres de test descriptivos: it('deshabilita el botón cuando el carrito está vacío') es infinitamente más útil que it('test 3') cuando un test falla en CI a las 2 de la madrugada.
  • Tests independientes: cada test debe poder ejecutarse solo, en cualquier orden. Si un test depende del estado dejado por otro, es un test frágil.
  • Coverage como indicador, no como objetivo: 100% de code coverage con aserciones débiles (expect(true).toBe(true)) es peor que 70% con aserciones sólidas. Usa la coverage para encontrar código no testeado, no como un KPI a maximizar a toda costa.

1.14 — Testear Guards y Resolvers

Las route guards y los resolvers son a menudo donde reside la lógica de autorización más delicada de una aplicación — y es exactamente el tipo de código que debe tener una cobertura de tests sólida, porque un bug aquí significa usuarios no autenticados accediendo a páginas protegidas, o lo 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' },
    });
  });
});

Testear el guard como una simple función (gracias a TestBed.runInInjectionContext) en lugar de montar un componente de ruta entero es mucho más rápido y enfoca exactamente la lógica de autorización, sin ruido proveniente del renderizado.

1.15 — Testear Pipes y Directivas 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 y directivas, al ser unidades de lógica muy pequeñas y sin dependencias pesadas, están entre los elementos más económicos de testear exhaustivamente: unos minutos de escritura garantizan una cobertura casi completa de todos los edge cases (string vacío, valor límite, input malformado).

Parte 2: Testing E2E con Cypress y Playwright

2.1 — Por qué se necesitan tests E2E además de los unitarios

Los tests unitarios verifican que las piezas individuales funcionan de forma aislada, pero no pueden detectar problemas de integración: routing mal configurado, CSS que oculta un botón, CORS bloqueado solo en un entorno específico, o una regresión en el flujo completo de checkout. Los tests E2E abren un navegador real (o uno headless), navegan la aplicación como lo haría un usuario real y verifican el resultado final.

2.2 — Cypress: instalación y estructura del proyecto

npm install --save-dev cypress
npx cypress open   # modo interactivo, útil durante el desarrollo
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, // deshabilitado en local, a menudo reactivado en CI para debug
    retries: {
      runMode: 2,  // reintenta los tests fallidos 2 veces en CI (mitiga flakiness de red)
      openMode: 0,
    },
    env: {
      apiUrl: 'http://localhost:3000/api',
    },
  },
});

La estructura típica de un proyecto 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 — Primer test Cypress: flujo 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 llamadas de red con cy.intercept

Un test E2E que depende de un backend real es lento y frágil (datos que cambian, servicios externos caídos). cy.intercept permite hacer stub de las respuestas HTTP manteniendo el test realista pero determinista.

// 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: manteniendo DRY en los tests Cypress

Las acciones repetidas en muchos tests (login, añadir un producto al carrito) deben extraerse a custom commands para evitar duplicación.

// 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 {};
// Uso en un test — login "silencioso" vía API en lugar de rellenar el formulario cada vez
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');
  });
});

Hacer login vía llamada directa a la API (cy.request) en lugar de mediante la UI cada vez es una de las optimizaciones más eficaces para acelerar una suite E2E: un test que no pretende testear el propio login tampoco necesita pasar por él vía interfaz.

2.6 — Cypress Component Testing

Más allá de los tests E2E clásicos, Cypress soporta el Component Testing: monta un único componente Angular de forma aislada en un navegador real, combinando la velocidad de los tests unitarios con la fidelidad de un renderizado real (sin 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: instalación y configuración

npm init playwright@latest
# Instala los navegadores necesarios (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 cuando un test falla y se reintenta
    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 — Primer test Playwright: el mismo flujo 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 y network mocking

Playwright usa locators, objetos "lazy" que se reconectan automáticamente al DOM antes de cada acción, con auto-waiting integrado: no se necesita ningún sleep ni espera explícita, Playwright espera automáticamente a que el elemento esté visible, habilitado y estable antes de interactuar con él.

// 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 — Ejecución paralela cross-browser y Trace Viewer

Uno de los puntos fuertes de Playwright es la ejecución nativa paralela en Chromium, Firefox y WebKit sin infraestructura adicional (grid, proveedor cloud). Cuando un test falla en CI, la trace registrada permite revisar toda la ejecución — DOM snapshot, network, consola — paso a paso, como un vídeo interactivo.

# Ejecuta la suite en todos los navegadores configurados, en paralelo
npx playwright test

# Ejecuta solo en Chromium con UI interactiva de debug
npx playwright test --project=chromium --ui

# Analiza la trace de un test fallido en CI
npx playwright show-trace trace.zip

2.11 — Cypress vs Playwright: tabla comparativa

CaracterísticaCypressPlaywright
ArquitecturaCorre dentro del navegador (mismo event loop que la app)Controla el navegador desde fuera vía protocolo (CDP/WebSocket)
Navegadores soportadosChrome, Edge, Firefox, ElectronChromium, Firefox, WebKit (por tanto también Safari)
Multi-tab / multi-dominioSoporte históricamente limitadoNativo, sin workarounds
Ejecución paralelaRequiere Cypress Cloud (de pago) o sharding manualNativa, gratuita, integrada en la config
Velocidad típicaBuenaGeneralmente superior, especialmente en CI
Herramientas de debugTime-travel debugging en la UI interactivaTrace Viewer + UI Mode
Component testingSí, maduroSí (Playwright Component Testing, más reciente)
Curva de aprendizajeMuy suave, API muy legibleLigeramente más amplia (async/await en todas partes)

En la práctica: si el equipo ya está acostumbrado a Cypress y el proyecto no requiere Safari/WebKit, Cypress sigue siendo una gran elección por su developer experience. Si se necesita cobertura real multi-navegador (incluido Safari) y paralelización nativa sin costes adicionales, Playwright es hoy la opción más sólida para proyectos nuevos.

2.12 — Integración en 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 detalle a menudo pasado por alto: ejecutar los tests E2E solo en pull requests que tocan código frontend (mediante paths: en el trigger) evita desperdiciar minutos de CI en cambios que afectan solo, por ejemplo, a la documentación o al backend.

2.13 — Visual regression testing con Playwright

Más allá de las aserciones funcionales, Playwright permite capturar screenshots y compararlos automáticamente con una versión de referencia guardada en disco — útil para detectar regresiones puramente visuales (un margen que cambia, un color equivocado) que ninguna aserción textual detectaría.

// 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 primera ejecución genera el screenshot de referencia (--update-snapshots);
    // las ejecuciones siguientes fallan si hay diferencias de píxeles por encima del umbral.
    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');
  });
});
# Actualiza los screenshots de referencia tras un cambio de UI intencionado
npx playwright test --update-snapshots

# En CI, guarda siempre los diffs generados como artifact para revisión visual

Regla práctica: el umbral maxDiffPixelRatio debe calibrarse para tolerar pequeñas variaciones de anti-aliasing entre entornos distintos (local vs CI), de lo contrario el test se vuelve flaky por motivos que no tienen nada que ver con un bug visual real.

Parte 3: Trucos de Depuración con Angular DevTools

3.1 — Instalación y primer arranque

Angular DevTools es una extensión oficial para Chrome y Firefox que añade un panel dedicado a las herramientas de desarrollo del navegador. Tras instalarla desde la Chrome Web Store, aparecerá una pestaña "Angular" en las DevTools (F12) de cualquier página que ejecute una aplicación Angular en modo development (en producción, con enableProdMode() activo, los metadatos necesarios se eliminan por motivos de rendimiento y seguridad).

3.2 — Component Explorer: inspeccionar el árbol de componentes

La pestaña Components muestra todo el árbol de componentes renderizados, de forma similar al árbol DOM pero a nivel de componentes Angular en lugar de etiquetas HTML. Al seleccionar un componente se puede inspeccionar en tiempo real:

  • Los valores actuales de todos los @Input() y de los signals pasados como input.
  • El estado interno del componente (propiedades, signals, computeds).
  • La jerarquía de componentes padre/hijo, para entender de dónde viene un dato inesperado.

También es posible modificar sobre la marcha el valor de una propiedad desde el panel y ver el componente re-renderizarse al instante — extremadamente útil para reproducir un edge case (p. ej. una lista vacía, un precio negativo) sin tener que cambiar los datos de origen.

3.3 — El Injector Tree: entender de dónde viene un servicio

La pestaña Injector Tree visualiza la jerarquía de los injectors de Dependency Injection de la aplicación — útil para diagnosticar bugs del tipo "¿por qué este componente recibe una instancia distinta del servicio respecto al de al lado?", típicamente causados por un provider declarado a nivel de componente en lugar de root, lo que crea una instancia aislada para ese subárbol.

3.4 — El Profiler: registrar un ciclo de Change Detection

La pestaña Profiler es la herramienta más potente para la depuración de rendimiento. Al pulsar "Start recording" e interactuar con la app (clics, scroll, escritura), Angular DevTools registra cada ciclo de change detection y produce un gráfico de barras — similar a una flame graph — donde cada barra representa un componente y su altura el tiempo empleado en comprobar sus bindings.

// Escenario típico a diagnosticar con el Profiler:
// un componente lista que se re-renderiza con cada tecla pulsada en un campo de búsqueda,
// aunque los datos de la lista no hayan cambiado.

@Component({
  selector: 'app-product-list',
  standalone: true,
  // ❌ Sin OnPush, Angular comprueba este componente en CADA ciclo de CD
  // de toda la aplicación, incluso para teclas pulsadas en componentes no relacionados.
  template: ``,
})
export class ProductListComponent {
  @Input() products: Product[] = [];
}

En el Profiler, esto se manifiesta como una barra ancha y repetida para ProductListComponent en cada evento de input, incluso cuando el usuario solo está escribiendo en un campo que no tiene relación con la lista de productos.

3.5 — Del diagnóstico a la corrección: 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 comprueba el componente solo cuando: (1) un @Input/signal input cambia por referencia, (2) ocurre un evento DOM dentro de él, o (3) se marca explícitamente con markForCheck(). Al repetir el registro en el Profiler tras este cambio, la barra de ProductListComponent desaparece mientras se escribe en el campo de búsqueda — la prueba visual de que la optimización ha funcionado.

3.6 — trackBy (o track en la nueva sintaxis @for) para listas grandes

Otra causa común de cuellos de botella visible en el Profiler: listas que se re-renderizan completamente (se destruyen y recrean) con cada actualización, en lugar de actualizar solo los elementos efectivamente cambiados.


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


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

En el Profiler, el efecto de track $index en una lista que se reordena (p. ej. tras una operación de sort) se ve como un número elevado de componentes ProductCardComponent destruidos y recreados, en lugar de un simple reposicionamiento — una señal clara de que el track elegido no identifica correctamente los elementos.

3.7 — Combinar Angular DevTools con el panel Performance de Chrome

Angular DevTools destaca mostrando qué componentes está comprobando Angular, pero no entra en detalle sobre qué ocurre a nivel del motor JavaScript (garbage collection, layout thrashing, long tasks). Para un análisis completo, se registra una sesión paralela en la pestaña nativa Performance de Chrome DevTools:

  • Las Long Tasks (barras rojas de más de 50ms) indican trabajo síncrono que bloquea el hilo principal — a menudo un ciclo de change detection demasiado pesado.
  • La sección Layout / Reflow señala lecturas/escrituras del DOM alternadas de forma ineficiente (p. ej. leer offsetHeight dentro de un loop que luego escribe estilos).
  • El gráfico de memoria a lo largo del tiempo ayuda a detectar memory leaks — típicamente subscriptions RxJS nunca canceladas en componentes destruidos y recreados repetidamente (p. ej. en una ruta que el usuario reabre a menudo).
// Causa común de memory leak: subscription manual nunca cancelada
export class DashboardComponent implements OnInit {
  ngOnInit(): void {
    // ❌ Cada vez que el componente se recrea, se acumula una nueva subscription
    interval(5000).subscribe(() => this.refreshStats());
  }
}

// ✅ Corregido con takeUntilDestroyed — la subscription se limpia
//    automáticamente cuando Angular destruye el componente
export class DashboardComponent implements OnInit {
  private readonly destroyRef = inject(DestroyRef);

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

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

No toda la depuración requiere herramientas dedicadas. Algunos métodos nativos de la consola, a menudo olvidados, aceleran mucho el análisis de datos complejos:

// console.table: muestra arrays de objetos como una tabla legible,
// mucho más útil que console.log para depurar listas de datos
console.table(this.products());

// console.time / console.timeEnd: mide rápidamente la duración de un bloque
// sin tener que importar herramientas de profiling externas
console.time('calcolaTotaleCarrello');
const total = this.calculateTotal();
console.timeEnd('calcolaTotaleCarrello'); // imprime: calcolaTotaleCarrello: 2.341ms

// console.trace: imprime la pila de llamadas completa — muy útil para entender
// DESDE DÓNDE se invoca una función llamada desde demasiados puntos del código
someSharedUtilityFunction(): void {
  console.trace('someSharedUtilityFunction chiamata da:');
}

// La instrucción debugger interrumpe la ejecución exactamente como un breakpoint
// puesto manualmente, pero vive en el código fuente — útil para condiciones raras
if (order.total < 0) {
  debugger; // el navegador se detiene aquí SOLO si las DevTools están abiertas
}

Un último consejo práctico: en los breakpoints condicionales de Chrome DevTools (clic derecho sobre el número de línea → "Add conditional breakpoint") se puede introducir una expresión como product.price < 0, deteniendo la ejecución solo cuando la condición sea verdadera, en lugar de tener que hacer clic en "continue" decenas de veces dentro de un loop.

3.9 — Heap snapshot: localizar memory leaks con precisión

Cuando el Profiler de Angular DevTools muestra una aplicación que se ralentiza progresivamente tras varios minutos de uso (síntoma clásico de memory leak), el siguiente paso es la pestaña Memory de Chrome DevTools:

  1. Navega la app hasta un estado "limpio" (p. ej. un dashboard vacío), luego registra un primer Heap snapshot.
  2. Realiza la acción sospechosa repetidamente (p. ej. abre y cierra un diálogo 10 veces).
  3. Fuerza una garbage collection manual (icono de la papelera) y registra un segundo Heap snapshot.
  4. Usa la vista "Comparison" entre los dos snapshots: los objetos que siguen aumentando en número (p. ej. instancias de DialogComponent que deberían haberse destruido) son el síntoma de la leak.
// Causa típica detectable en un Comparison heap snapshot:
// un EventEmitter personalizado o una subscription RxJS mantenida viva por un servicio singleton,
// que a su vez mantiene una referencia al componente e impide la garbage collection.

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

// ❌ El componente se suscribe al servicio singleton pero nunca se desuscribe:
// cada instancia del componente queda "enganchada" al Subject para siempre.
export class ToastComponent implements OnInit {
  constructor(private bus: NotificationBusService) {}
  ngOnInit(): void {
    this.bus.messages$.subscribe(msg => this.show(msg));
  }
}

En una aplicación donde el componente ToastComponent se crea y destruye repetidamente (p. ej. dentro de una ruta que el usuario visita a menudo), esta única subscription no desuscrita acumula una referencia por cada instancia creada — exactamente el patrón que un Comparison heap snapshot hace visible, con un número de instancias "Detached" que sigue creciendo y nunca vuelve a cero.

Conclusión: Construir una Red de Seguridad en Varias Capas

Ninguna de estas herramientas sustituye a las demás: los tests unitarios rápidos con Jest o Jasmine/Karma dan confianza inmediata en cada unidad de código con cada guardado; los tests E2E con Cypress o Playwright validan que los flujos críticos (login, checkout, búsqueda) realmente funcionan de principio a fin antes del lanzamiento; Angular DevTools entra en juego cuando hace falta entender por qué algo es lento o se comporta de forma inesperada, algo que ningún test automático por sí solo puede diagnosticar a fondo.

La combinación de estas tres capas — multiplicada por una CI que las ejecuta automáticamente en cada pull request — es lo que convierte un proyecto Angular de "funciona en mi máquina" en un producto sobre el que el equipo puede hacer refactoring con confianza, sabiendo que una regresión se detectará antes de llegar a los usuarios.

  • Checklist mínima para un proyecto Angular maduro:
  • ✅ Tests unitarios para cada servicio con lógica no trivial, con coverage mínima configurada en CI.
  • ✅ Tests E2E en los 3-5 flujos realmente críticos para el negocio (no en cada página individual).
  • ✅ ChangeDetectionStrategy.OnPush como predeterminado para los componentes nuevos.
  • ✅ Una sesión de profiling con Angular DevTools antes de cada release importante en las páginas de mayor tráfico.

💬 Notas de los lectores

0 notas

Escribe una nota

Comparte tu opinión, una sugerencia o un cumplido

Últimas notas

Aún no hay notas. ¡Sé el primero en comentar!