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 --watchejecuta 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.
| Aspecto | Jasmine | Jest |
|---|---|---|
| Crear un spy | jasmine.createSpy('name') | jest.fn() |
| Mock de un módulo entero | No nativo (requiere DI) | jest.mock('./module') |
| Configurar el valor de retorno | spy.and.returnValue(x) | fn.mockReturnValue(x) |
| Snapshot testing | No soportado nativamente | expect(x).toMatchSnapshot() |
| Timers falsos | fakeAsync + tick() | jest.useFakeTimers() + jest.advanceTimersByTime() |
| Entorno de ejecución | Navegador 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
expecten 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 queit('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ística | Cypress | Playwright |
|---|---|---|
| Arquitectura | Corre dentro del navegador (mismo event loop que la app) | Controla el navegador desde fuera vía protocolo (CDP/WebSocket) |
| Navegadores soportados | Chrome, Edge, Firefox, Electron | Chromium, Firefox, WebKit (por tanto también Safari) |
| Multi-tab / multi-dominio | Soporte históricamente limitado | Nativo, sin workarounds |
| Ejecución paralela | Requiere Cypress Cloud (de pago) o sharding manual | Nativa, gratuita, integrada en la config |
| Velocidad típica | Buena | Generalmente superior, especialmente en CI |
| Herramientas de debug | Time-travel debugging en la UI interactiva | Trace Viewer + UI Mode |
| Component testing | Sí, maduro | Sí (Playwright Component Testing, más reciente) |
| Curva de aprendizaje | Muy suave, API muy legible | Ligeramente 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
offsetHeightdentro 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:
- Navega la app hasta un estado "limpio" (p. ej. un dashboard vacío), luego registra un primer Heap snapshot.
- Realiza la acción sospechosa repetidamente (p. ej. abre y cierra un diálogo 10 veces).
- Fuerza una garbage collection manual (icono de la papelera) y registra un segundo Heap snapshot.
- Usa la vista "Comparison" entre los dos snapshots: los objetos que siguen aumentando en número (p. ej. instancias de
DialogComponentque 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.OnPushcomo 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.