Parte II · Angular

8. Testing, debugging y calidad en Angular

Un test no se escribe para subir un porcentaje en un informe, sino para poder cambiar el código mañana sin miedo. Este capítulo trata cómo probar componentes, señales, servicios, rutas y formularios sin acabar con una suite lenta y frágil que el equipo termine ignorando, y cómo diagnosticar un problema cuando ya se ha colado en producción.

CORE Tiempo de lectura: ~75 min Prerrequisitos: capítulos 2 a 7

8.1 Qué vas a poder hacer al terminar

  • Decidir qué merece un test y qué no, y en qué nivel de la pirámide colocarlo.
  • Configurar el TestBed de un componente standalone sin pelearte con proveedores que faltan.
  • Probar entradas y salidas, incluidas las basadas en señales, y componentes con contenido proyectado.
  • Testear código asíncrono con fakeAsync, whenStable y marbles sin usar esperas fijas.
  • Interceptar y verificar peticiones HTTP con HttpTestingController.
  • Probar guards, resolvers e interceptores funcionales de forma aislada.
  • Escribir tests de extremo a extremo con Playwright que no sean intermitentes.
  • Diagnosticar con Angular DevTools por qué una vista se redibuja de más.
  • Configurar ESLint, Prettier y strictTemplates para que los errores se detecten antes de ejecutar nada.

8.2 Por qué se testea (y por qué el 100 % no es el objetivo)

El argumento habitual a favor de los tests es la detección temprana de defectos, y es cierto: el coste de un fallo crece de forma brutal según la fase en que se descubre.

  COSTE RELATIVO DE CORREGIR UN DEFECTO SEGÚN DÓNDE SE DETECTA

  Mientras escribes el código   █                      x1
  Test unitario en local        ██                     x2      segundos
  Test en CI                    ████                   x5      minutos
  Revisión de código            ██████                 x8      horas
  QA / entorno de pruebas       ███████████████        x20     días
  Producción                    ████████████████████…  x100+   incidencia,
                                                               datos corruptos,
                                                               confianza perdida

Pero el motivo más valioso es otro, y se nota a los seis meses de proyecto: los tests son lo que te permite refactorizar. Sin ellos, cada cambio en código que no escribiste tú es una apuesta, y el equipo acaba prefiriendo duplicar una función antes que tocar la existente. La deuda técnica no crece porque la gente sea descuidada, sino porque cambiar da miedo. Un buen conjunto de tests elimina ese miedo.

Analogía: el arnés de escalada Un arnés no te hace escalar más rápido; de hecho te frena un poco al colocarlo. Lo que hace es permitirte intentar un movimiento arriesgado sabiendo que, si fallas, no te matas. Los tests son exactamente eso: no aceleran escribir la primera versión, aceleran todas las siguientes.

8.2.1 Por qué perseguir el 100 % de cobertura es un error

La cobertura mide qué líneas se ejecutaron durante la suite. No mide si comprobaste algo útil. Este test da un 100 % de cobertura sobre la función y no verifica absolutamente nada:

total.spec.tsINCORRECTO
it('calcula el total', () => {
  const servicio = new CarritoService();
  servicio.calcularTotal([{ precio: 10, cantidad: 2 }]);
  // Sin un solo expect. La línea se ejecuta, la cobertura sube,
  // y si mañana la función devuelve NaN el test sigue en verde.
});

Perseguir el número tiene además un efecto perverso conocido: se acaban escribiendo tests para el código trivial (getters, constructores, plantillas sin lógica), que es fácil de cubrir, mientras la lógica de negocio compleja, que es donde de verdad hay riesgo, sigue sin probar porque cuesta más. Un 70 % bien repartido vale mucho más que un 95 % concentrado en lo fácil.

8.2.2 Pirámide y trofeo

  PIRÁMIDE CLÁSICA                   "TROFEO" (Kent C. Dodds)

        /\                                  ████
       /E2E\      pocos, lentos,           E2E        pocos
      /------\    caros, realistas       ████████
     / Integr.\                          Integración  MUCHOS  ← el mejor
    /----------\                       ████████████     retorno
   /  Unitarios \  muchos, rápidos      Unitarios     los justos
  /--------------\ baratos, aislados   ██████████████
                                        Estático (TS + ESLint)  gratis

  Reparto razonable en un proyecto Angular real:

    Estático  ── TypeScript estricto + ESLint + strictTemplates
                 Coste marginal cero. Caza más errores que muchos tests.
    Unitario  ── 60 % · servicios, pipes, funciones puras, lógica de señales
    Integr.   ── 30 % · componente + su plantilla + sus hijos reales
    E2E       ── 10 % · 5 a 15 recorridos críticos, no más
El nivel más rentable es el de integración Un test que monta el componente con su plantilla real y sus hijos reales, y solo sustituye la frontera HTTP, se parece mucho a lo que hace el usuario y rompe poco cuando refactorizas por dentro. Los tests unitarios de componentes que llaman a métodos de la clase sin renderizar nada dan una falsa sensación de seguridad: comprueban tu implementación, no tu comportamiento.

8.3 Anatomía de un buen test

test difícil de mantenerINCORRECTO
it('test 1', () => {
  const s = TestBed.inject(TaskService);
  s.cargar();
  expect(s.tareas().length).toBe(3);
  s.completar(1);
  expect(s.tareas()[0].hecha).toBe(true);
  s.borrar(1);
  expect(s.tareas().length).toBe(2);
  expect(s.error()).toBeNull();
  expect(s.cargando()).toBe(false);
});
// Nombre que no dice nada.
// Cinco comportamientos en un test: si falla, no sabes cuál.
// Cada aserción depende del estado que dejó la anterior.
test que documentaCORRECTO
describe('TaskService', () => {
  describe('completar()', () => {
    it('marca la tarea como hecha', () => {
      // Arrange: el estado de partida es explícito
      const servicio = crearServicioCon([tarea({ id: 1, hecha: false })]);

      // Act: una sola acción
      servicio.completar(1);

      // Assert: un solo motivo de fallo
      expect(servicio.tareas()[0].hecha).toBe(true);
    });

    it('no altera las demás tareas', () => {
      const servicio = crearServicioCon([tarea({ id: 1 }), tarea({ id: 2 })]);
      servicio.completar(1);
      expect(servicio.tareas()[1].hecha).toBe(false);
    });
  });
});

Las reglas que sostienen el ejemplo de la derecha son cuatro y no negociables:

8.4 El ecosistema de herramientas

Comprueba qué ejecutor usa tu proyecto antes de copiar nada El ejecutor de tests por defecto de Angular ha cambiado varias veces: durante años fue Karma con Jasmine, después el equipo lo declaró obsoleto y ha ido incorporando soporte para otros. En lugar de fiarte de lo que diga cualquier tutorial (incluido este), mira el objetivo test en tu angular.json y las dependencias de tu package.json. La sintaxis de las aserciones y de los dobles cambia entre Jasmine y Jest, y ese es el 90 % de la confusión al copiar ejemplos de internet.
HerramientaCómo ejecutaA favorEn contra
Karma + JasmineNavegador realFidelidad total del DOM y del CSS; fue el estándar de Angular durante años, así que hay ejemplos para todoLento al arrancar; Karma está descontinuado
JestNode con jsdomMuy rápido, aislamiento por archivo, dobles y snapshots integrados, enorme ecosistemajsdom no es un navegador: layout, medidas y algunas API web no existen o se comportan distinto
VitestNode con jsdom, sobre ViteArranque casi instantáneo, API compatible con Jest, encaja con el sistema de compilación moderno de AngularEcosistema Angular más joven
Ejecutor en navegador (Playwright/WebDriver como entorno)Navegador real, sin KarmaFidelidad de navegador con herramientas modernasMás lento que jsdom; configuración más nueva

La diferencia práctica que más te va a afectar no es la velocidad, sino jsdom frente a navegador real. En jsdom no hay motor de renderizado: getBoundingClientRect() devuelve ceros, no hay layout, no se aplican las hojas de estilo y las animaciones no ocurren. Si tu componente calcula posiciones, mide elementos o depende de IntersectionObserver, o lo pruebas en un navegador real o lo diseñas para que esa parte sea sustituible.

8.5 TestBed: el módulo de pruebas

TestBed construye un entorno de Angular en miniatura: crea un inyector, compila los componentes que le indiques y te devuelve instancias reales. Es, literalmente, un bootstrapApplication reducido y controlable.

task-list.component.spec.ts · configuración típica
import { TestBed, ComponentFixture } from '@angular/core/testing';
import { provideHttpClient } from '@angular/common/http';
import { provideHttpClientTesting } from '@angular/common/http/testing';
import { provideRouter } from '@angular/router';
import { TaskListComponent } from './task-list.component';
import { TaskService } from './task.service';

describe('TaskListComponent', () => {
  let fixture: ComponentFixture<TaskListComponent>;
  let componente: TaskListComponent;

  beforeEach(async () => {
    await TestBed.configureTestingModule({
      // Un componente standalone se IMPORTA, no se declara.
      imports: [TaskListComponent],
      providers: [
        provideHttpClient(),
        provideHttpClientTesting(),   // debe ir DESPUÉS de provideHttpClient
        provideRouter([]),            // basta con rutas vacías si no navegas
      ],
    }).compileComponents();

    fixture = TestBed.createComponent(TaskListComponent);
    componente = fixture.componentInstance;
  });

  it('se crea', () => {
    fixture.detectChanges();          // dispara ngOnInit y el primer render
    expect(componente).toBeTruthy();
  });
});
APIPara qué sirveCuándo la necesitas
configureTestingModuleDeclara imports y providers del entorno de pruebaSiempre
compileComponents()Resuelve plantillas y estilos externosCon templateUrl; con compilación previa suele ser innecesario, pero ponerlo no molesta
createComponent()Instancia el componente y devuelve la fixtureSiempre que pruebes un componente
TestBed.inject(Token)Recupera una dependencia del inyector de pruebaPara servicios, o para el doble que has registrado
overrideComponentSustituye metadatos de un componente ya importadoReemplazar un hijo pesado por uno falso
overrideProviderCambia un proveedor concretoUn caso puntual dentro de un describe anidado
runInInjectionContextEjecuta una función como si estuviera dentro de un contexto de inyecciónGuards, resolvers e interceptores funcionales
El TestBed es donde se pierde el tiempo La mayor parte de las horas perdidas escribiendo tests en Angular se van en errores de configuración del inyector, no en lógica. Dos consejos que ahorran mucho: primero, extrae la configuración común a una función auxiliar por carpeta de features en lugar de copiarla en cada archivo; segundo, cuando veas NullInjectorError: No provider for X, no añadas a ciegas el proveedor real, pregúntate si ese componente debería depender de X, porque muchas veces el error está señalando un problema de diseño.

8.6 ComponentFixture y consultas del DOM

operaciones habituales sobre la fixture
// 1. Renderizar. Sin esto la plantilla NO se ha ejecutado todavía.
fixture.detectChanges();

// 2. Acceder al DOM
const raiz: HTMLElement = fixture.nativeElement;
const titulo = raiz.querySelector('h2');
expect(titulo?.textContent?.trim()).toBe('Mis tareas');

// 3. debugElement: envoltorio de Angular; permite consultar por directiva
//    y acceder al inyector del propio elemento.
const boton = fixture.debugElement.query(By.css('[data-testid="crear"]'));
const hijo  = fixture.debugElement.query(By.directive(TaskCardComponent));
const instanciaHija = hijo.componentInstance as TaskCardComponent;

// 4. Disparar eventos
boton.triggerEventHandler('click', null);          // vía Angular
raiz.querySelector('input')!.dispatchEvent(new Event('input'));  // vía DOM

// 5. Esperar a que se resuelvan las promesas pendientes
await fixture.whenStable();
fixture.detectChanges();
selectores frágilesINCORRECTO
// Se rompe si el diseñador cambia una clase de Tailwind
const b = el.querySelector('.btn.btn-primary.mt-4');

// Se rompe si alguien reordena los botones
const b2 = el.querySelectorAll('button')[2];

// Se rompe al traducir la aplicación
const b3 = [...el.querySelectorAll('button')]
  .find(x => x.textContent === 'Guardar');
selectores establesCORRECTO
// Contrato explícito entre la plantilla y el test.
// <button data-testid="guardar-tarea">…</button>
const b = el.querySelector('[data-testid="guardar-tarea"]');

// O por rol accesible, que además valida la accesibilidad:
const b2 = el.querySelector('button[aria-label="Guardar tarea"]');

// En e2e con Playwright, lo mismo:
// page.getByRole('button', { name: 'Guardar tarea' })
Los atributos de prueba no son «ensuciar» la plantilla Un data-testid es un contrato deliberado y visible: quien modifique ese elemento ve que algo depende de él. Es infinitamente mejor que un test acoplado a clases de estilo, que se rompe en cuanto alguien retoca el CSS y genera la peor sensación posible en un equipo: «los tests fallan pero la aplicación funciona». En la compilación de producción puedes eliminarlos con un simple paso del proceso de construcción si te preocupa el tamaño.

8.7 Dobles de prueba: el vocabulario exacto

TipoDefiniciónEjemplo en TaskFlow
DummySe pasa para rellenar un parámetro, nunca se usaUn objeto vacío como segundo argumento obligatorio
StubDevuelve respuestas predefinidas{ getTasks: () => of([tarea1]) }
SpyRegistra cómo se le llamó; puede envolver la implementación realVerificar que se llamó a analytics.track()
MockStub con expectativas de interacción incorporadas«Se debe llamar a save exactamente una vez»
FakeImplementación real pero simplificadaUn repositorio en memoria con un Map
creación de dobles
// --- Stub sencillo: casi siempre es suficiente ---
const taskServiceStub: Partial<TaskService> = {
  getTasks: () => of([{ id: 1, title: 'Revisar informe', done: false }]),
};

TestBed.configureTestingModule({
  imports: [TaskListComponent],
  providers: [{ provide: TaskService, useValue: taskServiceStub }],
});

// --- Spy con Jasmine ---
const spy = jasmine.createSpyObj<TaskService>('TaskService', ['getTasks', 'complete']);
spy.getTasks.and.returnValue(of([]));
expect(spy.complete).toHaveBeenCalledOnceWith(1);

// --- Spy con Jest ---
const spyJest = { getTasks: jest.fn().mockReturnValue(of([])) };
expect(spyJest.getTasks).toHaveBeenCalledTimes(1);

// --- Fake: implementación simplificada pero coherente ---
class FakeTaskRepository implements TaskRepository {
  private datos = new Map<number, Task>();
  async save(t: Task) { this.datos.set(t.id, t); return t; }
  async findById(id: number) { return this.datos.get(id) ?? null; }
}
// Un fake permite escribir tests de comportamiento reales
// (guardo y luego leo) en lugar de tests de interacción frágiles.
sobre-mockeoINCORRECTO
it('muestra las tareas', () => {
  const spy = jasmine.createSpyObj('TaskService', ['getTasks']);
  spy.getTasks.and.returnValue(of([{ titulo: 'X' }]));
  // ↑ la propiedad real se llama 'title', no 'titulo'

  // El test pasa. La aplicación está rota: la plantilla
  // pinta task.title y siempre saldrá vacío.
  expect(spy.getTasks).toHaveBeenCalled();
});
doble tipado y verificación realCORRECTO
// El doble está TIPADO: si el contrato cambia, el test no compila.
const stub: Pick<TaskService, 'getTasks'> = {
  getTasks: () => of([crearTarea({ title: 'X' })]),
};

it('muestra el título de cada tarea', () => {
  fixture.detectChanges();
  // Comprueba el RESULTADO visible, no la interacción.
  const textos = [...fixture.nativeElement
    .querySelectorAll('[data-testid="task-title"]')]
    .map((e: Element) => e.textContent?.trim());
  expect(textos).toEqual(['X']);
});
La regla del sobre-mockeo Cuantos más dobles use un test, menos se parece a la realidad y más probable es que pase mientras la aplicación falla. Sustituye solo las fronteras lentas o no deterministas: la red, el reloj, el almacenamiento y la aleatoriedad. Todo lo demás (los componentes hijos, los pipes, las funciones puras) déjalo real siempre que puedas. Y usa factorías tipadas para construir los datos de prueba: así un cambio en el modelo rompe la compilación en lugar de generar tests verdes que mienten.

8.8 Testear componentes

8.8.1 Entradas y salidas

task-card.component.spec.ts
// Componente bajo prueba (resumido):
// @Component({ selector: 'app-task-card', standalone: true, template: `...` })
// export class TaskCardComponent {
//   readonly task = input.required<Task>();
//   readonly compacta = input(false);
//   readonly completar = output<number>();
// }

describe('TaskCardComponent', () => {
  let fixture: ComponentFixture<TaskCardComponent>;

  beforeEach(() => {
    TestBed.configureTestingModule({ imports: [TaskCardComponent] });
    fixture = TestBed.createComponent(TaskCardComponent);
  });

  it('pinta el título de la tarea', () => {
    // Con entradas de señal se usa setInput, NO se asigna la propiedad.
    fixture.componentRef.setInput('task', crearTarea({ title: 'Revisar informe' }));
    fixture.detectChanges();

    const titulo = fixture.nativeElement.querySelector('[data-testid="titulo"]');
    expect(titulo.textContent.trim()).toBe('Revisar informe');
  });

  it('emite el identificador al pulsar completar', () => {
    fixture.componentRef.setInput('task', crearTarea({ id: 42 }));
    fixture.detectChanges();

    const emitidos: number[] = [];
    fixture.componentInstance.completar.subscribe(id => emitidos.push(id));

    fixture.nativeElement
      .querySelector('[data-testid="completar"]')
      .click();

    expect(emitidos).toEqual([42]);
  });
});
El error número uno con entradas de señal Asignar directamente fixture.componentInstance.task = tarea no funciona con las entradas basadas en señales: son de solo lectura desde fuera y el compilador te lo dirá, pero si el proyecto no está en modo estricto puede colarse. Usa siempre fixture.componentRef.setInput('nombre', valor), que además dispara correctamente ngOnChanges y marca el componente para revisión aunque sea OnPush.

8.8.2 Componentes con hijos: superficial o profundo

EnfoqueCómoA favorEn contra
Profundo (recomendado por defecto)Importar los hijos realesPrueba la integración real; detecta contratos rotos entre padre e hijoMás lento; un fallo del hijo rompe el test del padre
SuperficialSustituir los hijos por dobles con el mismo selectorRápido y aislado; útil si el hijo es muy pesado (un mapa, un editor de texto enriquecido)No detecta si cambias el nombre de una entrada del hijo
NO_ERRORS_SCHEMASilenciar los elementos desconocidosRápido de escribirDesaconsejado: oculta erratas en los nombres de las etiquetas y de las entradas. Un test que pasa con el componente hijo mal escrito no vale nada
doble de un hijo pesado
@Component({
  selector: 'app-task-card',          // MISMO selector que el real
  standalone: true,
  template: `<div data-testid="card-falsa">{{ task().title }}</div>`,
})
class TaskCardStubComponent {
  readonly task = input.required<Task>();   // MISMO contrato de entradas
  readonly completar = output<number>();
}

TestBed.configureTestingModule({ imports: [TaskListComponent] })
  .overrideComponent(TaskListComponent, {
    remove: { imports: [TaskCardComponent] },
    add:    { imports: [TaskCardStubComponent] },
  });

8.8.3 Contenido proyectado y componente anfitrión

Un componente que usa ng-content no se puede probar aislado: hay que darle contenido. La solución es un componente anfitrión escrito dentro del propio archivo de test.

panel.component.spec.ts
@Component({
  standalone: true,
  imports: [PanelComponent],
  template: `
    <app-panel [titulo]="titulo">
      <p data-testid="contenido">Texto proyectado</p>
      <button panel-acciones>Aceptar</button>
    </app-panel>
  `,
})
class AnfitrionComponent {
  titulo = 'Detalles';
}

it('proyecta el contenido y las acciones en sus ranuras', () => {
  const fixture = TestBed.createComponent(AnfitrionComponent);
  fixture.detectChanges();

  const panel = fixture.nativeElement.querySelector('app-panel');
  expect(panel.querySelector('[data-testid="contenido"]')).toBeTruthy();
  expect(panel.querySelector('.panel-acciones button')?.textContent)
    .toContain('Aceptar');
});

8.8.4 Componentes OnPush

Con OnPush, cambiar una propiedad interna desde el test no provoca ningún redibujado: Angular solo revisa el componente si cambia una entrada, se emite un evento desde su plantilla o se marca explícitamente. En un test eso se traduce en asignaciones que «no hacen nada». La solución correcta no es poner markForCheck por todas partes, sino provocar el cambio por la vía real, es decir, con setInput o disparando el evento en el DOM.

8.9 Testear señales y RxJS

8.9.1 Señales

señales en tests
it('el total filtrado se recalcula al cambiar el filtro', () => {
  const store = TestBed.inject(TaskStore);

  store.cargarTareas([tarea({ done: true }), tarea({ done: false })]);

  // Leer una señal es simplemente invocarla. No hace falta detectChanges:
  // el grafo reactivo es síncrono.
  expect(store.pendientes().length).toBe(1);

  store.filtro.set('todas');
  expect(store.visibles().length).toBe(2);
});

// Testear un EFECTO requiere contexto de inyección y una detección de cambios,
// porque los efectos se planifican, no se ejecutan al instante.
it('guarda el filtro en localStorage', () => {
  TestBed.runInInjectionContext(() => {
    const store = TestBed.inject(TaskStore);
    store.filtro.set('hechas');
    TestBed.flushEffects();      // fuerza la ejecución de los efectos pendientes
    expect(localStorage.getItem('filtro')).toBe('hechas');
  });
});
Si un efecto es difícil de testear, probablemente sobra Los efectos existen para sincronizar con el mundo exterior (almacenamiento, título del documento, una librería de terceros). Cuando te cueste probar uno, mira si en realidad estaba calculando un valor derivado: eso es un computed, que se prueba con una simple llamada y sin ceremonia. El nombre exacto de la utilidad para vaciar la cola de efectos ha variado entre versiones de Angular; comprueba cuál expone la tuya antes de copiar este fragmento.

8.9.2 Asincronía: fakeAsync frente a whenStable

TécnicaQué controlaCuándo usarla
fakeAsync + tick(ms)Reloj virtual: temporizadores y microtareassetTimeout, debounceTime, delay. El test es instantáneo y determinista
fakeAsync + flush()Vacía toda la cola de temporizadoresCuando no quieres calcular los milisegundos exactos
await fixture.whenStable()Espera a que se resuelvan las promesas pendientesCódigo basado en promesas y async/await
waitForAsyncEnvuelve el test en una zona de prueba asíncronaEstilo antiguo; hoy suele bastar con async/await
TestScheduler (marbles)Tiempo virtual de RxJSOperadores complejos con concurrencia y tiempo
espera fijaINCORRECTO
it('busca al escribir', (done) => {
  componente.query.set('info');

  // Espera "a ver si da tiempo".
  // Lento siempre e intermitente en CI cargado.
  setTimeout(() => {
    expect(componente.resultados().length).toBe(3);
    done();
  }, 500);
});
tiempo virtualCORRECTO
it('busca 300 ms después de dejar de escribir', fakeAsync(() => {
  componente.query.set('info');

  tick(299);
  expect(servicio.buscar).not.toHaveBeenCalled();   // aún no

  tick(1);                                          // ahora sí
  expect(servicio.buscar).toHaveBeenCalledOnceWith('info');

  flush();          // vacía lo que quede pendiente:
                    // si no, el test falla con "N timer(s) still in the queue"
}));
marble testing de un flujo de búsqueda
import { TestScheduler } from 'rxjs/testing';

describe('flujo de búsqueda', () => {
  let scheduler: TestScheduler;

  beforeEach(() => {
    scheduler = new TestScheduler((real, esperado) => {
      expect(real).toEqual(esperado);
    });
  });

  it('descarta las pulsaciones rápidas y cancela la petición anterior', () => {
    scheduler.run(({ cold, expectObservable }) => {
      //                     tiempo →
      const teclas   = cold('a-b---------c|');   // 'a' y 'b' muy seguidas
      const respuesta = (t: string) => cold('--(r|)', { r: 'res-' + t });

      const resultado = teclas.pipe(
        debounceTime(3, scheduler),
        switchMap(t => respuesta(t)),
      );

      // 'a' se descarta por el debounce; solo sobreviven 'b' y 'c'.
      expectObservable(resultado).toBe('------r-----(s|)', {
        r: 'res-b',
        s: 'res-c',
      });
    });
  });
});

8.10 Servicios y HTTP

task.service.spec.ts
describe('TaskService', () => {
  let servicio: TaskService;
  let http: HttpTestingController;

  beforeEach(() => {
    TestBed.configureTestingModule({
      providers: [TaskService, provideHttpClient(), provideHttpClientTesting()],
    });
    servicio = TestBed.inject(TaskService);
    http = TestBed.inject(HttpTestingController);
  });

  // Verifica que no quedaron peticiones sin atender: detecta llamadas
  // duplicadas o inesperadas que de otro modo pasarían desapercibidas.
  afterEach(() => http.verify());

  it('pide las tareas del proyecto con los parámetros correctos', () => {
    let recibidas: Task[] | undefined;
    servicio.getByProject(7, { page: 2 }).subscribe(r => (recibidas = r));

    const req = http.expectOne(
      r => r.url === '/api/projects/7/tasks' && r.params.get('page') === '2',
    );
    expect(req.request.method).toBe('GET');

    req.flush([{ id: 1, title: 'Revisar informe' }]);   // respuesta simulada

    expect(recibidas?.length).toBe(1);
  });

  it('convierte un 404 en un error de dominio', () => {
    let error: unknown;
    servicio.getById(99).subscribe({ error: e => (error = e) });

    http.expectOne('/api/tasks/99').flush(
      { message: 'No existe' },
      { status: 404, statusText: 'Not Found' },
    );

    expect(error).toBeInstanceOf(TaskNotFoundError);
  });

  it('no repite la petición si ya hay una en curso', () => {
    servicio.getById(1).subscribe();
    servicio.getById(1).subscribe();
    http.expectOne('/api/tasks/1');    // falla si hubiera dos
  });
});

8.10.1 Interceptores funcionales

auth.interceptor.spec.ts
it('añade la cabecera Authorization cuando hay sesión', () => {
  TestBed.configureTestingModule({
    providers: [
      provideHttpClient(withInterceptors([authInterceptor])),
      provideHttpClientTesting(),
      { provide: AuthStore, useValue: { token: () => 'abc123' } },
    ],
  });

  const cliente = TestBed.inject(HttpClient);
  const http = TestBed.inject(HttpTestingController);

  cliente.get('/api/tasks').subscribe();

  const req = http.expectOne('/api/tasks');
  expect(req.request.headers.get('Authorization')).toBe('Bearer abc123');
  req.flush([]);
});

8.11 Router, guards y resolvers

rutas y guards en tests
// --- Guard funcional AISLADO: rápido y sin renderizar nada ---
it('redirige al login si no hay sesión', () => {
  TestBed.configureTestingModule({
    providers: [
      { provide: AuthStore, useValue: { autenticado: () => false } },
      provideRouter([]),
    ],
  });

  const resultado = TestBed.runInInjectionContext(() =>
    authGuard({} as ActivatedRouteSnapshot, {} as RouterStateSnapshot),
  );

  // El guard devuelve un UrlTree cuando quiere redirigir.
  expect(resultado).toBeInstanceOf(UrlTree);
  expect(String(resultado)).toBe('/login');
});

// --- Navegación real con RouterTestingHarness ---
it('muestra el detalle de la tarea al navegar', async () => {
  TestBed.configureTestingModule({
    providers: [
      provideRouter([
        { path: 'tasks/:id', component: TaskDetailComponent },
      ]),
      provideHttpClient(),
      provideHttpClientTesting(),
    ],
  });

  const harness = await RouterTestingHarness.create();
  const componente = await harness.navigateByUrl('/tasks/42', TaskDetailComponent);

  expect(componente.id()).toBe(42);
  expect(harness.routeNativeElement?.textContent).toContain('Tarea 42');
});

8.12 Formularios

task-form.component.spec.ts
it('muestra el error de obligatorio al tocar y vaciar el título', () => {
  fixture.detectChanges();

  const control = componente.form.controls.title;
  control.setValue('');
  control.markAsTouched();
  fixture.detectChanges();

  expect(control.hasError('required')).toBe(true);
  const mensaje = fixture.nativeElement
    .querySelector('[data-testid="error-title"]');
  expect(mensaje.textContent).toContain('El título es obligatorio');
});

it('deshabilita el envío mientras el formulario es inválido', () => {
  fixture.detectChanges();
  const boton: HTMLButtonElement = fixture.nativeElement
    .querySelector('[data-testid="enviar"]');

  expect(boton.disabled).toBe(true);

  componente.form.setValue({ title: 'Nueva tarea', priority: 3 });
  fixture.detectChanges();
  expect(boton.disabled).toBe(false);
});

// Validador asíncrono: el reloj virtual evita esperas reales
it('marca el nombre como duplicado', fakeAsync(() => {
  const control = componente.form.controls.title;
  control.setValue('Existente');
  tick(300);                       // debounce del validador
  const req = http.expectOne(r => r.url.includes('/api/tasks/exists'));
  req.flush({ existe: true });
  tick();
  expect(control.hasError('duplicado')).toBe(true);
}));

8.13 Component harnesses del CDK

Un harness es una capa de API sobre un componente que oculta su estructura interna. En lugar de buscar el input dentro del div con cierta clase, le pides al harness del campo de texto que escriba un valor. Si mañana la librería cambia su plantilla, tu test sigue funcionando: el mantenedor actualiza el harness.

uso y creación de harnesses
// --- Uso de un harness existente ---
const loader = TestbedHarnessEnvironment.loader(fixture);
const campo = await loader.getHarness(MatInputHarness.with({
  selector: '[data-testid="titulo"]',
}));
await campo.setValue('Revisar informe');

const botones = await loader.getAllHarnesses(MatButtonHarness);
await botones[0].click();

// --- Harness propio para un componente del proyecto ---
export class TaskCardHarness extends ComponentHarness {
  static hostSelector = 'app-task-card';

  private readonly titulo = this.locatorFor('[data-testid="titulo"]');
  private readonly btnCompletar = this.locatorFor('[data-testid="completar"]');

  async obtenerTitulo(): Promise<string> {
    return (await this.titulo()).text();
  }
  async completar(): Promise<void> {
    return (await this.btnCompletar()).click();
  }
  async estaCompletada(): Promise<boolean> {
    return (await this.host()).hasClass('completada');
  }
}

// El test resultante se lee como una descripción de comportamiento:
it('marca la tarjeta como completada', async () => {
  const tarjeta = await loader.getHarness(TaskCardHarness);
  await tarjeta.completar();
  expect(await tarjeta.estaCompletada()).toBe(true);
});

8.14 Tests de extremo a extremo con Playwright

Un test de extremo a extremo arranca la aplicación de verdad en un navegador de verdad y la maneja como lo haría una persona. Es el único nivel que comprueba que las piezas encajan: el frontend compilado, la API, la base de datos, la autenticación y el enrutado. También es el más lento y el más frágil, así que la disciplina consiste en tener pocos y buenos.

Automatiza con e2eNo automatices con e2e
Registro y accesoValidación de cada campo de un formulario (eso es un test de componente)
El recorrido que genera ingresos o valor principalTodos los mensajes de error posibles
Crear, editar y borrar la entidad centralReglas de negocio con muchas ramas (eso es un test unitario)
Permisos: que un usuario no vea lo que no debeDetalles visuales (para eso hay regresión visual)
Un pago o una operación irreversibleCualquier cosa ya cubierta en un nivel inferior
e2e/tasks.spec.ts
import { test, expect } from '@playwright/test';

test.describe('gestión de tareas', () => {
  test.beforeEach(async ({ page }) => {
    // Cada test parte de datos propios y aislados: nada de depender
    // de lo que dejó el test anterior ni de la base de datos compartida.
    await page.request.post('/api/test/seed', {
      data: { proyecto: 'Proyecto de prueba', tareas: [] },
    });
    await page.goto('/projects/prueba/tasks');
  });

  test('crea una tarea y la ve en la lista', async ({ page }) => {
    // getByRole prueba también la accesibilidad: si el botón no tiene
    // nombre accesible, este test falla, y eso es exactamente lo que quieres.
    await page.getByRole('button', { name: 'Nueva tarea' }).click();

    await page.getByLabel('Título').fill('Revisar el informe mensual');
    await page.getByLabel('Prioridad').selectOption('1');
    await page.getByRole('button', { name: 'Guardar' }).click();

    // Aserción con espera automática: Playwright reintenta hasta el timeout.
    // No hace falta ningún waitForTimeout.
    await expect(
      page.getByRole('listitem').filter({ hasText: 'Revisar el informe mensual' }),
    ).toBeVisible();

    await expect(page.getByTestId('contador-tareas')).toHaveText('1 tarea');
  });

  test('un usuario sin permiso no ve el botón de borrar', async ({ page }) => {
    await iniciarSesionComo(page, 'invitado@taskflow.dev');
    await expect(page.getByRole('button', { name: 'Borrar' })).toHaveCount(0);
  });
});
e2e/pages/task-list.page.ts · patrón page object
// Encapsula los selectores en un solo sitio: si cambia la interfaz,
// se toca un archivo y no cuarenta tests.
export class TaskListPage {
  constructor(private readonly page: Page) {}

  readonly botonNueva = this.page.getByRole('button', { name: 'Nueva tarea' });
  readonly lista      = this.page.getByRole('list', { name: 'Tareas' });

  async ir(proyecto: string) {
    await this.page.goto(`/projects/${proyecto}/tasks`);
  }

  async crear(titulo: string) {
    await this.botonNueva.click();
    await this.page.getByLabel('Título').fill(titulo);
    await this.page.getByRole('button', { name: 'Guardar' }).click();
  }

  tarea(titulo: string) {
    return this.lista.getByRole('listitem').filter({ hasText: titulo });
  }
}
e2e frágilINCORRECTO
// Pausa fija: lenta si sobra, intermitente si falta.
await page.waitForTimeout(2000);

// Selector acoplado a la estructura del DOM.
await page.click('div > div:nth-child(3) > button.primary');

// Depende de datos que "ya estaban" en el entorno.
await expect(page.getByText('Tarea de Ana')).toBeVisible();

// Los tests se ejecutan en orden y comparten sesión.
// Si el primero falla, caen los diez siguientes.
e2e robustoCORRECTO
// Espera a la condición, no al reloj.
await expect(page.getByTestId('lista')).toBeVisible();

// Selector semántico y estable.
await page.getByRole('button', { name: 'Guardar' }).click();

// Datos creados por el propio test mediante la API.
const { id } = await crearTareaViaApi(page, 'Tarea de Ana');

// Sesión reutilizable mediante storageState: cada test es
// independiente y no repite el login por interfaz.
test.use({ storageState: 'e2e/.auth/usuario.json' });
Qué configurar en integración continua Activa las trazas solo cuando el test falle (trace: 'on-first-retry'): obtienes una grabación navegable con capturas, red y consola, que convierte el «en mi máquina funciona» en un diagnóstico de dos minutos. Permite un reintento, pero vigila los tests que solo pasan al reintentar: son intermitentes, y un test intermitente que se ignora acaba entrenando al equipo para ignorar los fallos reales. O se arregla o se borra.

8.14.1 Regresión visual y accesibilidad automática

e2e/visual.spec.ts
test('la lista de tareas mantiene su aspecto', async ({ page }) => {
  await page.goto('/projects/demo/tasks');
  // Compara con una captura de referencia versionada en el repositorio.
  await expect(page).toHaveScreenshot('lista-tareas.png', {
    maxDiffPixelRatio: 0.01,        // tolera el antialiasing
    mask: [page.getByTestId('fecha-relativa')],  // oculta lo que cambia siempre
  });
});

test('no hay violaciones de accesibilidad graves', async ({ page }) => {
  await page.goto('/projects/demo/tasks');
  const resultado = await new AxeBuilder({ page })
    .withTags(['wcag2a', 'wcag2aa'])
    .analyze();
  expect(resultado.violations).toEqual([]);
});

Conviene entender el límite de estas dos técnicas. La comprobación automática con axe detecta entre un tercio y la mitad de los problemas reales de accesibilidad: encuentra el contraste insuficiente, la imagen sin texto alternativo o el campo sin etiqueta, pero no puede saber si el orden de tabulación tiene sentido ni si tu texto alternativo describe realmente la imagen. Es un buen suelo, no un techo. Y la regresión visual es potentísima para detectar roturas de estilo, pero genera falsos positivos con las fuentes y el antialiasing: hay que ejecutarla en un contenedor con el mismo sistema y las mismas fuentes que la referencia, o pasarás más tiempo aprobando capturas que programando.

8.15 Debugging

HerramientaPara qué
Angular DevTools · pestaña ComponentsInspeccionar el árbol, ver y modificar en caliente el estado de un componente y sus entradas
Angular DevTools · ProfilerGrabar ciclos de detección de cambios y ver qué componente consume el tiempo y con qué frecuencia se revisa
ng.getComponent($0)En la consola del navegador, obtener la instancia del elemento seleccionado en el inspector
ng.applyChanges($0)Forzar la detección de cambios tras modificar algo a mano desde la consola
Puntos de interrupción condicionalesParar solo cuando task.id === 42, en lugar de pulsar continuar cincuenta veces
Source mapsDepurar el código original en producción; genéralos y súbelos a tu sistema de errores aunque no los sirvas públicamente
node --inspect-brkDepurar el proceso de renderizado en servidor, donde no hay navegador que valga
Método sistemático para un fallo que no entiendes

La diferencia entre depurar durante veinte minutos y durante dos días casi nunca está en el conocimiento del framework, sino en el método. El orden que funciona es siempre el mismo:

1. Reproducir de forma fiable. Un fallo que no sabes provocar no lo puedes arreglar, solo puedes creer que lo arreglaste. 2. Reducir. Quita todo lo que puedas hasta quedarte con el caso mínimo que todavía falla. 3. Bisecar. Con git bisect, encuentra en qué commit apareció; con frecuencia el diagnóstico es inmediato al ver el cambio. 4. Formular una hipótesis concreta y falsable, del tipo «creo que el interceptor se ejecuta dos veces». 5. Verificarla con una medición, no con una intuición. 6. Escribir un test que falle antes de corregir, para que el fallo no pueda volver en silencio.

8.16 Calidad estática: lo que caza errores sin ejecutar nada

tsconfig.json · las opciones que de verdad importan
{
  "compilerOptions": {
    "strict": true,
    "noUncheckedIndexedAccess": true,
    "noImplicitOverride": true,
    "noFallthroughCasesInSwitch": true,
    "noUnusedLocals": true
  },
  "angularCompilerOptions": {
    "strictTemplates": true,
    "strictInjectionParameters": true,
    "strictInputAccessModifiers": true,
    "typeCheckHostBindings": true
  }
}

strictTemplates merece un párrafo propio porque es la opción con mejor relación entre esfuerzo y beneficio de toda la configuración de Angular. Sin ella, la plantilla es tierra de nadie: puedes pasar un string a una entrada que espera un number, invocar un método que no existe o escribir mal el nombre de una propiedad, y no te enterarás hasta que un usuario vea la pantalla en blanco. Con ella activada, el compilador comprueba los tipos dentro del HTML igual que en el TypeScript. Activarla en un proyecto grande genera decenas de errores el primer día, y esa es precisamente la demostración de que hacía falta.

eslint.config.js · reglas con criterio
export default [
  {
    files: ['**/*.ts'],
    rules: {
      // Convenciones de Angular: detectan errores de diseño reales
      '@angular-eslint/no-empty-lifecycle-method': 'error',
      '@angular-eslint/use-lifecycle-interface': 'error',
      '@angular-eslint/no-output-native': 'error',
      '@angular-eslint/prefer-standalone': 'error',

      // Los tres que más fallos previenen en producción
      '@typescript-eslint/no-floating-promises': 'error',  // promesa sin await
      '@typescript-eslint/no-misused-promises': 'error',   // async donde no toca
      '@typescript-eslint/no-explicit-any': 'error',
    },
  },
  {
    files: ['**/*.html'],
    rules: {
      // Accesibilidad comprobada en cada guardado, gratis
      '@angular-eslint/template/alt-text': 'error',
      '@angular-eslint/template/label-has-associated-control': 'error',
      '@angular-eslint/template/click-events-have-key-events': 'error',
      '@angular-eslint/template/interactive-supports-focus': 'error',
    },
  },
];

Estas reglas se ejecutan sobre lo que vas a subir mediante husky y lint-staged, de modo que el formato y los errores evidentes nunca llegan a la revisión de código. Es una decisión de equipo más que técnica: discutir sobre comillas simples o dobles en una revisión es tiempo tirado, y automatizar el formato elimina la discusión por completo. La revisión queda entonces libre para lo único que un humano puede aportar, que es el diseño y la corrección de la solución.

8.17 Cobertura: qué mide y qué no

Tipo de códigoUmbral razonableMotivo
Lógica de negocio y servicios con estado90 % o másEs donde está el riesgo real y donde los tests son baratos
Componentes con lógica70-80 %Prueba el comportamiento visible, no cada rama
Componentes de presentación purosBajo o nuloTestear que una plantilla pinta una entrada aporta poco frente a lo que cuesta
Configuración, constantes, tiposExcluirInflan el porcentaje sin significar nada
Global del proyecto70-80 %Por encima suele indicar tests escritos para el informe

Una advertencia sobre los umbrales automáticos en integración continua: son útiles para impedir que la cobertura caiga sin que nadie se dé cuenta, pero en cuanto el número se convierte en un objetivo del equipo, deja de medir calidad. Es la ley de Goodhart aplicada al software. Mejor criterio: exigir que todo cambio que corrija un fallo venga acompañado del test que lo reproduce. Eso sí correlaciona con la calidad, y además hace que la cobertura crezca justo por donde importa.

8.18 Datos de prueba: factorías y constructores

Los datos de prueba son el detalle que decide si una suite envejece bien. Escribir el objeto completo a mano en cada test parece inofensivo al principio y se convierte en el mayor freno del proyecto: el día que añades un campo obligatorio a Task, tienes que tocar doscientos ficheros.

objetos a manoINCORRECTO
it('muestra la tarea', () => {
  const tarea = {
    id: 1, title: 'X', description: '', done: false,
    priority: 3, dueDate: null, assigneeId: null,
    projectId: 7, createdAt: new Date(), updatedAt: null,
    version: 1, tags: [],
  };
  // 13 líneas de ruido para un test que solo comprueba el título.
  // Y este bloque está repetido en 40 ficheros.
});
factoría con valores por defectoCORRECTO
// testing/factories.ts
let secuencia = 0;

export function crearTarea(parcial: Partial<Task> = {}): Task {
  secuencia++;
  return {
    id: secuencia,
    title: `Tarea ${secuencia}`,
    description: '',
    done: false,
    priority: 3,
    dueDate: null,
    assigneeId: null,
    projectId: 1,
    createdAt: new Date('2026-01-01T10:00:00Z'),
    updatedAt: null,
    version: 1,
    tags: [],
    ...parcial,          // el test solo declara lo que le importa
  };
}

it('muestra la tarea', () => {
  const tarea = crearTarea({ title: 'X' });   // el resto es irrelevante aquí
});

Tres detalles marcan la diferencia en una factoría bien hecha. El primero es que el tipo de retorno sea el tipo real y no any: así, cuando el modelo cambie, la compilación fallará en un único sitio y sabrás exactamente qué actualizar. El segundo es que los valores por defecto sean deterministas: una fecha fija en lugar de new Date(), porque un test que depende de la hora actual acabará fallando el día que se ejecute a medianoche o en otra zona horaria. Y el tercero es que el test declare únicamente los campos que afectan a lo que está comprobando, porque eso convierte la propia llamada en documentación: quien lea crearTarea({ dueDate: ayer }) entiende al instante que el test va de tareas vencidas.

testing/builders.ts · para grafos de objetos
// Cuando el escenario tiene varias entidades relacionadas, un constructor
// encadenable se lee mucho mejor que cinco factorías sueltas.
export class EscenarioProyecto {
  private proyecto = crearProyecto();
  private tareas: Task[] = [];

  conNombre(nombre: string) {
    this.proyecto = { ...this.proyecto, name: nombre };
    return this;
  }
  conTareasPendientes(n: number) {
    for (let i = 0; i < n; i++) {
      this.tareas.push(crearTarea({ projectId: this.proyecto.id, done: false }));
    }
    return this;
  }
  conTareaVencida() {
    this.tareas.push(crearTarea({
      projectId: this.proyecto.id,
      dueDate: new Date('2020-01-01'),
      done: false,
    }));
    return this;
  }
  construir() {
    return { proyecto: this.proyecto, tareas: this.tareas };
  }
}

// El test se lee como el enunciado de la historia de usuario:
const { proyecto, tareas } = new EscenarioProyecto()
  .conNombre('Migración')
  .conTareasPendientes(3)
  .conTareaVencida()
  .construir();

8.19 Directivas, pipes y utilidades

Los pipes y las funciones puras son el código más barato de probar de toda la aplicación: entradas conocidas, salida esperada, sin renderizado ni inyección. Son también donde más rentable resulta cubrir los casos límite, porque cuestan segundos.

pipe y directiva
// --- Pipe: instancia directa, sin TestBed ---
describe('TiempoRelativoPipe', () => {
  const ahora = new Date('2026-05-10T12:00:00Z');
  const pipe = new TiempoRelativoPipe(() => ahora);   // reloj inyectado

  it.each([
    [new Date('2026-05-10T11:59:30Z'), 'hace unos segundos'],
    [new Date('2026-05-10T11:30:00Z'), 'hace 30 minutos'],
    [new Date('2026-05-09T12:00:00Z'), 'ayer'],
    [new Date('2026-05-01T12:00:00Z'), 'hace 9 días'],
    [new Date('2026-05-11T12:00:00Z'), 'mañana'],       // futuro: caso olvidado
  ])('convierte %s en "%s"', (fecha, esperado) => {
    expect(pipe.transform(fecha)).toBe(esperado);
  });
});

// --- Directiva: necesita un componente anfitrión ---
@Component({
  standalone: true,
  imports: [ResaltarDirective],
  template: `<p appResaltar="amarillo" data-testid="parrafo">Texto</p>`,
})
class AnfitrionDirectivaComponent {}

it('aplica el color de fondo al pasar el ratón', () => {
  const fixture = TestBed.createComponent(AnfitrionDirectivaComponent);
  fixture.detectChanges();

  const p: HTMLElement = fixture.nativeElement.querySelector('[data-testid="parrafo"]');
  p.dispatchEvent(new MouseEvent('mouseenter'));
  fixture.detectChanges();

  expect(p.style.backgroundColor).toBe('amarillo');
});
El código más fácil de probar es el que no depende de Angular Cuando una regla de negocio vive en una función pura (recibe datos, devuelve datos, sin inyectar nada), se prueba en dos líneas y sin ninguna infraestructura. Cuando vive dentro de un componente, mezclada con la plantilla y con tres servicios inyectados, hace falta montar medio framework para comprobarla. Si un test te está costando mucho, casi siempre es el diseño el que te está hablando: extrae la lógica y verás cómo el test se escribe solo. Esa es la razón, muy práctica, por la que se dice que los tests mejoran el diseño.

8.20 La suite en integración continua

.github/workflows/ci.yml · fragmento relevante
jobs:
  test:
    runs-on: ubuntu-latest
    strategy:
      fail-fast: false          # no cortes el resto al fallar un fragmento
      matrix:
        shard: [1, 2, 3, 4]     # reparte la suite en 4 trabajos paralelos
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: npm
      - run: npm ci
      - run: npx tsc --noEmit                    # tipos primero: es lo más rápido
      - run: npm run lint
      - run: npm test -- --shard=${{ matrix.shard }}/4
        env:
          TZ: Europe/Madrid                      # zona fija: evita fallos por horario
          LANG: es_ES.UTF-8

  e2e:
    runs-on: ubuntu-latest
    needs: test                                  # solo si lo barato ya pasó
    steps:
      - uses: actions/checkout@v4
      - run: npx playwright install --with-deps chromium
      - run: npm run e2e
      - uses: actions/upload-artifact@v4
        if: failure()
        with:
          name: playwright-trace
          path: test-results/                    # trazas para diagnosticar el fallo

Hay tres decisiones en ese fichero que merecen explicación. La primera es el orden: comprobación de tipos, luego lint, luego tests unitarios y por último los de extremo a extremo. Cada etapa es más lenta que la anterior, así que fallar pronto ahorra minutos en cada iteración y, multiplicado por todo el equipo y todos los días, ahorra horas. La segunda es fijar la zona horaria y el idioma: la mayoría de los fallos que solo ocurren en integración continua vienen de que el servidor está en UTC y tu portátil no, o de que un formato de fecha o de número cambia con la configuración regional. Y la tercera es subir las trazas cuando algo falla, porque la alternativa es intentar reproducir en local un fallo que solo pasa allí, que es de las formas más frustrantes de perder una tarde.

8.21 Errores comunes y cómo solucionarlos

SíntomaCausaSolución
NullInjectorError: No provider for HttpClientFalta configurar el cliente HTTP en el TestBedAñadir provideHttpClient() y provideHttpClientTesting(), en ese orden
NullInjectorError de un servicio propioEl componente depende de algo que no has provistoRegistrar un doble; y plantearte si esa dependencia debería estar ahí
La plantilla no muestra nadaFalta fixture.detectChanges()Llamarlo tras cada cambio de estado, o usar autoDetectChanges()
Cambiar una propiedad no actualiza la vistaEl componente es OnPushProvocar el cambio por la vía real: setInput o evento del DOM
N timer(s) still in the queueQuedaron temporizadores pendientes dentro de fakeAsyncTerminar con flush() o discardPeriodicTasks()
ExpressionChangedAfterItHasBeenCheckedErrorSe modifica estado durante el ciclo de detección, típicamente en ngAfterViewInitMover la escritura a un momento anterior o diferirla; si es inevitable, replantear el diseño
El test pasa solo si se ejecuta el archivo aisladoEstado compartido entre tests: singleton, localStorage, espías sin restaurarLimpiar en afterEach; ejecutar la suite en orden aleatorio para detectarlo
Falla solo en integración continuaZona horaria, idioma, tamaño de ventana o máquina más lentaFijar zona e idioma en la configuración; eliminar esperas fijas; ejecutar en contenedor
expectOne falla diciendo que hubo dos peticionesSuscripción duplicada o efecto que se dispara dos vecesEs un fallo real de la aplicación, no del test: normalmente falta shareReplay o sobra una suscripción
La suite tarda varios minutosTestBed demasiado pesado repetido en cada testConfiguración compartida en beforeAll cuando sea posible; dobles para los hijos costosos; menos e2e y más integración
El test comprueba toHaveBeenCalled y nada másTest de interacción sin valorVerificar el resultado observable en el DOM o en el estado
Cannot read properties of undefined al leer una consulta de vistaSe accedió antes de que la vista existieraLeerla después del primer detectChanges(); revisar si necesitas static: true

8.22 Buenas y malas prácticas

Haz esto

  • Prueba comportamiento, no implementación: lo que el usuario ve y hace, no qué método interno se llamó.
  • Selecciona por rol accesible o por data-testid, nunca por clases de estilo.
  • Un test, un motivo de fallo, con un nombre que explique el comportamiento esperado.
  • Factorías tipadas para los datos de prueba, de modo que un cambio de modelo rompa la compilación.
  • Escribe primero el test que reproduce el fallo y luego corrígelo.
  • Tiempo virtual con fakeAsync y marbles en lugar de esperas reales.
  • http.verify() en afterEach: caza peticiones duplicadas gratis.
  • Trata los tests como código de producción: se refactorizan, se revisan y se borran cuando sobran.

Evita esto

  • waitForTimeout y esperas fijas: lentas cuando sobran, intermitentes cuando faltan.
  • NO_ERRORS_SCHEMA para silenciar componentes desconocidos: oculta erratas reales.
  • Sustituir todo con dobles hasta que el test no pruebe nada de verdad.
  • Tests que dependen del orden o de datos que dejó otro test.
  • Perseguir un porcentaje de cobertura en lugar de cubrir el riesgo.
  • Convivir con tests intermitentes: arréglalos o bórralos, pero no los reintentes en silencio.
  • Testear detalles privados: si necesitas hacer público un método solo para el test, revisa el diseño.
  • Suites de extremo a extremo enormes que tardan una hora y nadie mira.

8.23 Preguntas frecuentes

¿Debo testear los componentes de presentación?
Si solo reciben entradas y pintan HTML sin lógica, el valor es bajo y el coste de mantener esos tests es real: cualquier retoque de plantilla los rompe. Concentra el esfuerzo en los componentes con lógica (condiciones, estado, interacción) y en los servicios. Un componente de presentación queda cubierto de forma natural por el test de integración del contenedor que lo usa.
¿Cuál es la diferencia real entre fakeAsync y waitForAsync?
fakeAsync instala un reloj virtual: tú decides cuánto tiempo pasa con tick() y el test se ejecuta al instante, de forma completamente determinista. Es lo que quieres para temporizadores, debounceTime y delay. waitForAsync simplemente envuelve el test para esperar a que se completen las tareas asíncronas reales, sin control fino sobre el tiempo. Hoy, para promesas, suele bastar con marcar el test como async y usar await fixture.whenStable(). Un aviso: tick() no avanza las peticiones HTTP reales; para eso está HttpTestingController.
¿Cómo pruebo un componente que usa inject() en el cuerpo de la clase?
Igual que cualquier otro: TestBed.createComponent crea el componente dentro de un contexto de inyección válido, así que inject() funciona con normalidad. El problema aparece al probar funciones sueltas que llaman a inject() fuera de un componente, como los guards y los interceptores funcionales: ahí necesitas TestBed.runInInjectionContext(() => miFuncion()). Si olvidas envolverlo, el error es inject() must be called from an injection context.
¿Hay que testear los efectos de las señales?
Solo los que producen un efecto observable hacia fuera: escribir en el almacenamiento, cambiar el título del documento o llamar a una librería externa. Y son incómodos de probar porque se planifican en lugar de ejecutarse al momento. Si un efecto te resulta difícil de testear, casi siempre es porque estaba calculando un valor derivado, y eso debería ser un computed, que se prueba llamándolo y comparando el resultado.
Mi suite tarda cinco minutos. ¿Por dónde empiezo?
Mide antes de tocar nada: casi todos los ejecutores pueden listar los archivos más lentos. Las causas habituales, por frecuencia: un TestBed muy pesado que se reconstruye en cada test (comparte configuración y sustituye los hijos costosos), tests de extremo a extremo colados en la suite unitaria, esperas reales en lugar de tiempo virtual, y no aprovechar la ejecución en paralelo. Cambiar de Karma a un ejecutor sobre Node suele dar la mayor mejora de golpe, pero revisa antes que no dependas del layout real del navegador.
¿Test-Driven Development, sí o no?
Como dogma, no; como herramienta, muchísimo. Escribir el test primero es especialmente valioso en dos situaciones: al corregir un fallo, porque te obliga a reproducirlo antes de tocar nada y te garantiza que no volverá, y al diseñar lógica de negocio con reglas claras, porque el test te fuerza a definir la interfaz desde fuera. En cambio, para explorar una interfaz de usuario que aún no sabes cómo quieres, escribir los tests primero suele ser tirar trabajo a la basura.
¿Qué hago con un test intermitente?
Tratarlo como un fallo prioritario, no como una molestia. Un test que falla una de cada diez veces está diciéndote algo real: casi siempre una dependencia temporal (esperas fijas, orden de promesas), estado compartido entre tests o una condición de carrera que también existe en producción. Reintentar en silencio es la peor opción, porque entrena al equipo para ignorar los rojos. Si no puedes arreglarlo ahora, márcalo como omitido con un enlace a la incidencia, para que sea visible.
¿Cypress o Playwright?
Ambos son excelentes y muy superiores a lo que había hace unos años. Playwright destaca en soporte multinavegador real, ejecución en paralelo, contextos aislados, la espera automática en las aserciones y el visor de trazas, y su API es plenamente asíncrona con async/await. Cypress tiene un modo interactivo muy pulido para depurar visualmente y una comunidad enorme. Para un proyecto nuevo en 2026 yo elegiría Playwright, sobre todo por el aislamiento entre tests y por lo bien que se comporta en integración continua.
¿Cómo pruebo algo que depende de la fecha actual?
No llames a new Date() directamente en la lógica: inyecta un reloj, aunque sea un InjectionToken con una función que devuelva la fecha. En el test proporcionas una fecha fija y todo se vuelve determinista. La alternativa es congelar el tiempo global con las utilidades del ejecutor (jasmine.clock() o jest.useFakeTimers()), que funciona pero acopla el test al ejecutor y afecta a todo el proceso. La primera opción, además de facilitar el test, mejora el diseño.
¿Tiene sentido testear las plantillas con snapshots?
Con moderación. Una captura del HTML completo de un componente se rompe con cualquier cambio de maquetación y acaba actualizándose a ciegas, que es la muerte del test: pasa a documentar lo que hay en lugar de comprobar lo que debería haber. Si usas snapshots, hazlos pequeños y centrados en un fragmento con significado. Para lo visual, la regresión de imágenes en el navegador aporta bastante más.
¿Cómo se prueba un ControlValueAccessor propio?
Con un componente anfitrión que lo use dentro de un formulario reactivo real. Compruebas las dos direcciones del contrato: que al escribir en el control del formulario se refleja en la interfaz (writeValue) y que al interactuar con la interfaz se actualiza el valor del formulario (registerOnChange). Además, verifica que el estado deshabilitado se propaga y que registerOnTouched se dispara al perder el foco, que es lo que casi todo el mundo olvida implementar.
¿Merece la pena testear el renderizado en servidor?
Sí, y con un test muy barato: pedir la página al servidor y comprobar que el HTML devuelto ya contiene el contenido esperado. Eso detecta de una vez los tres fallos clásicos del SSR: acceso a window o document en el servidor, contenido que no se renderiza porque depende de una llamada del cliente, y errores de hidratación. Un puñado de estos tests sobre las rutas públicas principales cubre el riesgo real de SEO.

8.24 Ejercicios

Nivel 1 · fundamentos

TEST-01 Escribe los tests de un pipe tiempoRelativo que convierta una fecha en «hace 3 días». Cubre el caso de hoy, el de ayer, el de hace meses y el de una fecha futura. Inyecta el reloj para que el test sea determinista.

TEST-02 Testea un componente TaskBadgeComponent que recibe el estado como entrada de señal y aplica una clase CSS distinta a cada uno. Usa setInput.

TEST-03 Escribe un test que verifique que TaskCardComponent emite el evento completar con el identificador correcto al pulsar el botón.

TEST-04 Coge un test existente que use querySelector('.clase') y conviértelo para que use roles accesibles. Explica qué problema adicional detecta ahora.

Nivel 2 · asincronía y servicios

TEST-05 Testea un servicio de búsqueda con debounceTime(300) y switchMap: comprueba que no busca antes de tiempo, que descarta las pulsaciones intermedias y que cancela la petición anterior. Hazlo con fakeAsync.

TEST-06 Con HttpTestingController, prueba que tu servicio reintenta dos veces ante un error 500 y se rinde a la tercera, propagando un error de dominio.

TEST-07 Escribe el test de un interceptor que refresca el token al recibir un 401 y reintenta la petición original, verificando que la segunda petición lleva el token nuevo.

TEST-08 Prueba un guard de roles funcional de forma aislada, en sus tres casos: usuario con permiso, usuario sin permiso y usuario sin sesión.

TEST-09 Rehaz el ejercicio TEST-05 con marbles y TestScheduler. Compara la legibilidad de ambas versiones.

Nivel 3 · integración y diagnóstico

TEST-10 Escribe un harness propio para el formulario de tareas, con métodos rellenar(), enviar() y erroresVisibles(), y reescribe con él tres tests existentes.

TEST-11 Monta un test de extremo a extremo con Playwright del recorrido completo: iniciar sesión, crear un proyecto, añadir dos tareas, completar una y comprobar el contador. Usa datos creados por API y sesión reutilizable.

TEST-12 Te dan un test que falla una de cada cinco ejecuciones. Diseña un procedimiento para encontrar la causa (ejecución repetida, orden aleatorio, aislamiento de estado) y documenta el diagnóstico.

TEST-13 Usa el Profiler de Angular DevTools sobre una lista de 500 tareas, identifica por qué se revisan componentes que no cambian y demuestra con una medición antes y después el efecto de aplicar OnPush y un track correcto.

Soluciones comentadas (TEST-05, TEST-07 y TEST-12)
// ---------- TEST-05 ----------
it('descarta las pulsaciones intermedias y cancela la anterior', fakeAsync(() => {
  const api = jasmine.createSpyObj<SearchApi>('SearchApi', ['buscar']);
  api.buscar.and.returnValue(of(['resultado']));
  const servicio = new SearchService(api);

  servicio.escribir('i');
  servicio.escribir('in');
  tick(100);
  servicio.escribir('inf');      // reinicia el contador del debounce

  tick(299);
  expect(api.buscar).not.toHaveBeenCalled();   // aún no han pasado los 300

  tick(1);
  // Clave: UNA sola llamada, y con el último valor.
  expect(api.buscar).toHaveBeenCalledOnceWith('inf');

  flush();
}));

// ---------- TEST-07 ----------
it('refresca el token ante un 401 y reintenta con el nuevo', () => {
  const auth = { token: signal('viejo'), refrescar: jasmine.createSpy() };
  auth.refrescar.and.callFake(() => { auth.token.set('nuevo'); return of(true); });

  TestBed.configureTestingModule({
    providers: [
      provideHttpClient(withInterceptors([refreshInterceptor])),
      provideHttpClientTesting(),
      { provide: AuthStore, useValue: auth },
    ],
  });
  const cliente = TestBed.inject(HttpClient);
  const http = TestBed.inject(HttpTestingController);

  let respuesta: unknown;
  cliente.get('/api/tasks').subscribe(r => (respuesta = r));

  // 1) Primera petición: token viejo, el servidor responde 401
  const primera = http.expectOne('/api/tasks');
  expect(primera.request.headers.get('Authorization')).toBe('Bearer viejo');
  primera.flush({}, { status: 401, statusText: 'Unauthorized' });

  // 2) El interceptor refresca y REINTENTA con el token nuevo
  const segunda = http.expectOne('/api/tasks');
  expect(segunda.request.headers.get('Authorization')).toBe('Bearer nuevo');
  segunda.flush([{ id: 1 }]);

  expect(auth.refrescar).toHaveBeenCalledTimes(1);   // solo una vez
  expect(respuesta).toEqual([{ id: 1 }]);
  http.verify();
});

// ---------- TEST-12: procedimiento, no código ----------
// 1. Confirmar la intermitencia: ejecutar SOLO ese test 50 veces seguidas.
//      · Si nunca falla aislado → el problema es contaminación entre tests.
//      · Si falla aislado       → hay asincronía o dependencia temporal dentro.
// 2. Contaminación: ejecutar la suite con orden aleatorio y semilla fija.
//    Al reproducirlo, bisecar el conjunto de archivos hasta hallar la pareja
//    culpable. Sospechosos habituales: servicios singleton con estado,
//    localStorage, espías globales sin restaurar, temporizadores no vaciados.
// 3. Asincronía interna: buscar esperas fijas, dependencias de Date.now(),
//    promesas sin await y suscripciones que no se cierran.
// 4. Escribir la corrección junto con una limpieza explícita en afterEach.
// 5. Volver a ejecutar 50 veces para demostrar que quedó estable.

8.25 Resumen del capítulo

  • Los tests existen para poder cambiar el código sin miedo, no para subir un porcentaje.
  • El nivel más rentable es el de integración: componente real con su plantilla real, sustituyendo solo la frontera HTTP.
  • El análisis estático es gratis y caza muchísimo: TypeScript estricto, strictTemplates y ESLint antes que cualquier test.
  • Con entradas de señal se usa setInput, nunca la asignación directa.
  • Tiempo virtual siempre: fakeAsync con tick y marbles; jamás esperas fijas.
  • HttpTestingController con verify() detecta peticiones duplicadas que ni sabías que tenías.
  • Guards, resolvers e interceptores funcionales se prueban aislados con runInInjectionContext.
  • Selecciona por rol accesible o data-testid: los selectores de estilo convierten cada retoque de CSS en una suite roja.
  • Pocos tests de extremo a extremo, pero impecables: datos propios, sesión reutilizable, esperas por condición y trazas al fallar.
  • Un test intermitente es un fallo, no una molestia: arréglalo o bórralo.

8.26 Recursos adicionales

Siguiente paso Con esto cierras la parte de Angular. El capítulo 9 cruza al backend y empieza por la arquitectura de NestJS, sus módulos y su inyección de dependencias, donde reconocerás muchas ideas de este lado del stack.