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.
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
TestBedde 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,whenStabley 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
strictTemplatespara 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.
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:
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
8.3 Anatomía de un buen test
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.
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:
- Arrange, Act, Assert. Prepara el escenario, ejecuta una acción, comprueba el resultado. Si tu test tiene dos bloques de «act», son dos tests.
- El nombre describe el comportamiento, no la implementación. «devuelve 404 si el proyecto no existe» envejece bien; «llama a findOne y lanza» se rompe en cuanto renombras el método.
- Un solo motivo de fallo. Cuando la suite se pone roja, el nombre del test debería bastar para saber qué se rompió, sin abrir el archivo.
- Independiente del orden. Ningún test puede depender de que otro se haya ejecutado antes. Si tu suite falla al ejecutarla en orden aleatorio, tienes estado compartido.
8.4 El ecosistema de herramientas
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.
| Herramienta | Cómo ejecuta | A favor | En contra |
|---|---|---|---|
| Karma + Jasmine | Navegador real | Fidelidad total del DOM y del CSS; fue el estándar de Angular durante años, así que hay ejemplos para todo | Lento al arrancar; Karma está descontinuado |
| Jest | Node con jsdom | Muy rápido, aislamiento por archivo, dobles y snapshots integrados, enorme ecosistema | jsdom no es un navegador: layout, medidas y algunas API web no existen o se comportan distinto |
| Vitest | Node con jsdom, sobre Vite | Arranque casi instantáneo, API compatible con Jest, encaja con el sistema de compilación moderno de Angular | Ecosistema Angular más joven |
| Ejecutor en navegador (Playwright/WebDriver como entorno) | Navegador real, sin Karma | Fidelidad de navegador con herramientas modernas | Má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.
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();
});
});
| API | Para qué sirve | Cuándo la necesitas |
|---|---|---|
configureTestingModule | Declara imports y providers del entorno de prueba | Siempre |
compileComponents() | Resuelve plantillas y estilos externos | Con templateUrl; con compilación previa suele ser innecesario, pero ponerlo no molesta |
createComponent() | Instancia el componente y devuelve la fixture | Siempre que pruebes un componente |
TestBed.inject(Token) | Recupera una dependencia del inyector de prueba | Para servicios, o para el doble que has registrado |
overrideComponent | Sustituye metadatos de un componente ya importado | Reemplazar un hijo pesado por uno falso |
overrideProvider | Cambia un proveedor concreto | Un caso puntual dentro de un describe anidado |
runInInjectionContext | Ejecuta una función como si estuviera dentro de un contexto de inyección | Guards, resolvers e interceptores funcionales |
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
// 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();
// 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');
// 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' })
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
| Tipo | Definición | Ejemplo en TaskFlow |
|---|---|---|
| Dummy | Se pasa para rellenar un parámetro, nunca se usa | Un objeto vacío como segundo argumento obligatorio |
| Stub | Devuelve respuestas predefinidas | { getTasks: () => of([tarea1]) } |
| Spy | Registra cómo se le llamó; puede envolver la implementación real | Verificar que se llamó a analytics.track() |
| Mock | Stub con expectativas de interacción incorporadas | «Se debe llamar a save exactamente una vez» |
| Fake | Implementación real pero simplificada | Un repositorio en memoria con un Map |
// --- 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.
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();
});
// 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']);
});
8.8 Testear componentes
8.8.1 Entradas y salidas
// 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]);
});
});
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
| Enfoque | Cómo | A favor | En contra |
|---|---|---|---|
| Profundo (recomendado por defecto) | Importar los hijos reales | Prueba la integración real; detecta contratos rotos entre padre e hijo | Más lento; un fallo del hijo rompe el test del padre |
| Superficial | Sustituir los hijos por dobles con el mismo selector | Rá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_SCHEMA | Silenciar los elementos desconocidos | Rápido de escribir | Desaconsejado: 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 |
@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.
@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
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');
});
});
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écnica | Qué controla | Cuándo usarla |
|---|---|---|
fakeAsync + tick(ms) | Reloj virtual: temporizadores y microtareas | setTimeout, debounceTime, delay. El test es instantáneo y determinista |
fakeAsync + flush() | Vacía toda la cola de temporizadores | Cuando no quieres calcular los milisegundos exactos |
await fixture.whenStable() | Espera a que se resuelvan las promesas pendientes | Código basado en promesas y async/await |
waitForAsync | Envuelve el test en una zona de prueba asíncrona | Estilo antiguo; hoy suele bastar con async/await |
TestScheduler (marbles) | Tiempo virtual de RxJS | Operadores complejos con concurrencia y tiempo |
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);
});
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"
}));
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
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
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
// --- 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
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 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 e2e | No automatices con e2e |
|---|---|
| Registro y acceso | Validación de cada campo de un formulario (eso es un test de componente) |
| El recorrido que genera ingresos o valor principal | Todos los mensajes de error posibles |
| Crear, editar y borrar la entidad central | Reglas de negocio con muchas ramas (eso es un test unitario) |
| Permisos: que un usuario no vea lo que no debe | Detalles visuales (para eso hay regresión visual) |
| Un pago o una operación irreversible | Cualquier cosa ya cubierta en un nivel inferior |
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);
});
});
// 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 });
}
}
// 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.
// 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' });
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
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
| Herramienta | Para qué |
|---|---|
| Angular DevTools · pestaña Components | Inspeccionar el árbol, ver y modificar en caliente el estado de un componente y sus entradas |
| Angular DevTools · Profiler | Grabar 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 condicionales | Parar solo cuando task.id === 42, en lugar de pulsar continuar cincuenta veces |
| Source maps | Depurar 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-brk | Depurar el proceso de renderizado en servidor, donde no hay navegador que valga |
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
{
"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.
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ódigo | Umbral razonable | Motivo |
|---|---|---|
| Lógica de negocio y servicios con estado | 90 % o más | Es donde está el riesgo real y donde los tests son baratos |
| Componentes con lógica | 70-80 % | Prueba el comportamiento visible, no cada rama |
| Componentes de presentación puros | Bajo o nulo | Testear que una plantilla pinta una entrada aporta poco frente a lo que cuesta |
| Configuración, constantes, tipos | Excluir | Inflan el porcentaje sin significar nada |
| Global del proyecto | 70-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.
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.
});
// 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.
// 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: 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');
});
8.20 La suite en integración continua
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íntoma | Causa | Solución |
|---|---|---|
NullInjectorError: No provider for HttpClient | Falta configurar el cliente HTTP en el TestBed | Añadir provideHttpClient() y provideHttpClientTesting(), en ese orden |
NullInjectorError de un servicio propio | El componente depende de algo que no has provisto | Registrar un doble; y plantearte si esa dependencia debería estar ahí |
| La plantilla no muestra nada | Falta fixture.detectChanges() | Llamarlo tras cada cambio de estado, o usar autoDetectChanges() |
| Cambiar una propiedad no actualiza la vista | El componente es OnPush | Provocar el cambio por la vía real: setInput o evento del DOM |
N timer(s) still in the queue | Quedaron temporizadores pendientes dentro de fakeAsync | Terminar con flush() o discardPeriodicTasks() |
ExpressionChangedAfterItHasBeenCheckedError | Se modifica estado durante el ciclo de detección, típicamente en ngAfterViewInit | Mover la escritura a un momento anterior o diferirla; si es inevitable, replantear el diseño |
| El test pasa solo si se ejecuta el archivo aislado | Estado compartido entre tests: singleton, localStorage, espías sin restaurar | Limpiar en afterEach; ejecutar la suite en orden aleatorio para detectarlo |
| Falla solo en integración continua | Zona horaria, idioma, tamaño de ventana o máquina más lenta | Fijar zona e idioma en la configuración; eliminar esperas fijas; ejecutar en contenedor |
expectOne falla diciendo que hubo dos peticiones | Suscripción duplicada o efecto que se dispara dos veces | Es un fallo real de la aplicación, no del test: normalmente falta shareReplay o sobra una suscripción |
| La suite tarda varios minutos | TestBed demasiado pesado repetido en cada test | Configuració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ás | Test de interacción sin valor | Verificar el resultado observable en el DOM o en el estado |
Cannot read properties of undefined al leer una consulta de vista | Se accedió antes de que la vista existiera | Leerla 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
fakeAsyncy marbles en lugar de esperas reales. http.verify()enafterEach: caza peticiones duplicadas gratis.- Trata los tests como código de producción: se refactorizan, se revisan y se borran cuando sobran.
Evita esto
waitForTimeouty esperas fijas: lentas cuando sobran, intermitentes cuando faltan.NO_ERRORS_SCHEMApara 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?
¿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?
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?
computed, que se prueba
llamándolo y comparando el resultado.Mi suite tarda cinco minutos. ¿Por dónde empiezo?
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?
¿Qué hago con un test intermitente?
¿Cypress o Playwright?
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?
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?
¿Cómo se prueba un ControlValueAccessor propio?
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?
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
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.
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.
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,
strictTemplatesy ESLint antes que cualquier test. - Con entradas de señal se usa
setInput, nunca la asignación directa. - Tiempo virtual siempre:
fakeAsyncconticky marbles; jamás esperas fijas. HttpTestingControllerconverify()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
- Guía oficial de testing de Angular — la referencia de
TestBed,fakeAsyncy las utilidades de prueba. - Component Harnesses del CDK — cómo usarlos y cómo escribir el tuyo.
- Playwright · Buenas prácticas — selectores, aislamiento y ejecución en integración continua.
- Testing Library · Principios — «cuanto más se parezcan tus tests a cómo se usa el software, más confianza dan».
- RxJS · Marble testing — sintaxis de los diagramas y uso de
TestScheduler. - axe-core — motor de comprobación automática de accesibilidad.