Módulo 26: Testing
Objetivos del módulo
- Decidir qué merece una prueba y de qué tipo: unitaria, de integración o de extremo a extremo
- Ejecutar pruebas con
ng test, filtrarlas y configurar Vitest desde Angular - Conocer lo que cambia entre Vitest 4 (LTS) y Vitest 5 (Angular 22)
- Probar funciones y pipes sin Angular, y servicios con
TestBed - Probar componentes: entradas, salidas,
model(), plantilla, eventos y enlaces conbindings - Entender cuándo se actualiza la vista en una prueba zoneless:
whenStable,TestBed.tickydetectChanges - Probar señales,
computedyeffect - Simular la red con
provideHttpClientTestingy evitar el interbloqueo conhttpResource - Probar rutas, guardas y navegación con
RouterTestingHarness - Probar formularios reactivos y Signal Forms
- Probar directivas con
TestBed.createDirectivey con un componente anfitrión - Sustituir
fakeAsyncpor los temporizadores de Vitest, a mano y con la migración automática - Sustituir hijos pesados y controlar bloques
@defer - Escribir pruebas de extremo a extremo con Playwright
- Medir la cobertura y exigir un mínimo
1. Para qué sirven las pruebas
Una prueba automática es un programa que usa tu programa y comprueba que hace lo que debe. Escribirla cuesta tiempo, así que conviene tener claro qué se compra con ese tiempo:
- Cambiar sin miedo. Cuando el proyecto final tenga treinta componentes, renombrar un campo de
Empleadopuede romper algo que no estás mirando. Las pruebas te lo dicen en un minuto. - Actualizar Angular. Pasar de la 21 a la 22 cambia varios valores por defecto. Sin pruebas, solo descubres los efectos en producción.
- Documentar el comportamiento.
it('redirige a los demás')explica la guarda mejor que un comentario, y además no se queda desfasado: si deja de ser verdad, la prueba falla. - Diseñar mejor. Lo que es difícil de probar suele estar demasiado acoplado.
Las pruebas no demuestran que no haya errores. Demuestran que los casos que has escrito funcionan. Por eso importa más elegir bien los casos que acumular muchos.
La pirámide de pruebas
Figura 1. Cuanto más arriba, más confianza da cada prueba, pero también es más lenta y más frágil.
| Nivel | Qué ejercita | Herramienta | Ejemplo en EMPRESA |
|---|---|---|---|
| Unitaria | Una pieza aislada | Vitest, con o sin TestBed | salarioNeto, InicialesPipe, Seleccion |
| Integración | Varias piezas juntas, sin red real | Vitest + TestBed | ListaEmpleados con sus tarjetas y un HTTP falso |
| Extremo a extremo (E2E) | La aplicación entera en un navegador | Playwright | Entrar, filtrar y seleccionar contra la API |
En Angular la frontera entre unitaria e integración es difusa, y no merece la pena discutirla. Una prueba de componente con TestBed monta plantilla, pipes e hijos reales, y eso está bien: lo que el usuario recibe es el conjunto, no la clase.
💡 Consejo: Prueba el comportamiento que se ve desde fuera, no la implementación. La pregunta útil es «si pulso este botón, ¿aparece este texto?». Si la prueba lee métodos privados o el nombre de una señal interna, se romperá cada vez que reorganices el código, aunque todo siga funcionando.
Qué no hace falta probar
- Código del propio Angular: no compruebes que
@iffunciona. - Plantillas sin lógica: un
<h2>Empleados</h2>fijo no necesita prueba. - Detalles de estilo: el color exacto de un botón se revisa de otra forma.
- Librerías de terceros: pruebas cómo las usas, no cómo funcionan por dentro.
2. Vitest y ng test
Un proyecto creado con ng new ya viene preparado para las pruebas. En angular.json aparece el constructor (builder) de pruebas:
"test": {
"builder": "@angular/build:unit-test"
}
Y tsconfig.spec.json activa los tipos globales, por eso puedes escribir describe, it, expect y vi sin importarlos:
{
"extends": "./tsconfig.json",
"compilerOptions": {
"types": ["vitest/globals"]
},
"include": ["src/**/*.d.ts", "src/**/*.spec.ts"]
}
Qué hace ng test por dentro, en este orden:
- Compila tu código y tus
*.spec.tscon el mismo compilador de Angular queng build. Por eso un error de tipos en una prueba para la ejecución antes de empezar. - Lanza Vitest sobre lo compilado. Las pruebas se ejecutan en Node, no en un navegador.
- Simula el navegador con jsdom, una implementación de
documenty de los elementos HTML escrita en JavaScript. Hay DOM y eventos, pero no hay diseño: nada tiene tamaño ni posición, ygetBoundingClientRectno da medidas reales.
📘 Concepto: Vitest es el ejecutor de pruebas (test runner): encuentra las pruebas, las lanza y te da el informe. jsdom es el DOM simulado. TestBed es la pieza de Angular que monta componentes y servicios para probarlos. Son tres cosas distintas, y cada error suele venir de una.
Órdenes de uso diario
ng test # modo vigilancia: repite al guardar
ng test --watch=false # una pasada, para CI
ng test --include='src/app/servicios/**/*.spec.ts' # solo esos ficheros
ng test --filter='rechaza' # solo las pruebas cuyo nombre encaje
ng test --list-tests # lista los ficheros, sin ejecutar
ng test --reporters=verbose # una línea por prueba
ng test --coverage # con cobertura (sección 15)
--filter selecciona por el nombre de la prueba, y lo que no encaja aparece como omitido. Esta es la salida real con --include y --filter='rechaza' sobre el fichero de la sección siguiente:
↓ lab-angular src/app/utilidades/salario.spec.ts > salarioNeto > redondea a dos decimales
↓ lab-angular src/app/utilidades/salario.spec.ts > salarioNeto > con bruto 1000 y retención 0 da 1000
↓ lab-angular src/app/utilidades/salario.spec.ts > salarioNeto > con bruto 1000 y retención 100 da 0
↓ lab-angular src/app/utilidades/salario.spec.ts > salarioNeto > con bruto 0 y retención 15 da 0
✓ lab-angular src/app/utilidades/salario.spec.ts > salarioNeto > rechaza un bruto negativo 13ms
✓ lab-angular src/app/utilidades/salario.spec.ts > salarioNeto > rechaza una retención fuera de rango 5ms
Test Files 1 passed (1)
Tests 2 passed | 5 skipped (7)
Los ficheros de pruebas se llaman *.spec.ts o *.test.ts y se dejan junto al código que prueban. Es lo que espera el valor por defecto de include.
Configurar Vitest
Casi todo se configura con opciones de ng test, pero puedes añadir un fichero propio de Vitest:
ng generate config vitest
Crea vitest-base.config.mts y escribe "runnerConfig": true en las opciones de test de angular.json:
// vitest-base.config.mts
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
// ...
},
});
Si quieres usar otro fichero en una sola ejecución, pásalo con --runner-config. Por ejemplo, en un ordenador cargado o en un CI modesto puede fallar el arranque con Timeout waiting for worker to respond. Hemos comprobado que esto lo evita, junto con --include para lanzar menos ficheros:
// vitest-hilos.config.mts
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
pool: 'threads',
maxWorkers: 2,
},
});
ng test --watch=false --runner-config=vitest-hilos.config.mts --include='src/app/servicios/**/*.spec.ts'
⚠️ Importante: Angular construye la configuración final a partir de la tuya y sobrescribe parte de ella, como
test.includeytest.projects. Además, el equipo de Angular no da soporte a los ficheros propios ni a los plugins de terceros. Usa la extensión.mts: con un.tsnormal Vite avisa de que usa una función que dejará de admitir.
Opciones isolate y quiet de ng test 🆕 v22.0
🆕 Desde Angular 22.0. No existen en la LTS (Angular 21). Si usas la LTS: no hay alternativa: el constructor fija
isolate: false, así que cuida de no dejar estado global entre ficheros, y la salida de compilación se muestra siempre.
isolate: true ejecuta cada fichero de pruebas en su propio entorno. Por defecto vale false, para parecerse a la experiencia de Karma: los ficheros comparten entorno, y por eso no debes dejar estado global sucio, como un temporizador falso sin restaurar. quiet oculta la tabla de compilación, y por defecto vale true en local y false en CI.
Otra opción, splitting, está obsoleta desde la 22.2: la documentación indica que ya no hace falta con Vitest 5.
Lo que cambia entre Vitest 4 y Vitest 5 🔄 v22.0
🔄 Cambia en Angular 22.0.
ng newinstala Vitest 5 y jsdom 30. En la LTS (Angular 21) son Vitest 4 y jsdom 28. Si usas la LTS: casi todo es igual, pero limpia tú los espías entre pruebas convi.clearAllMocks()enbeforeEacho conclearMocks: true, y pon siempreawaiten.resolvesy.rejects, porque Vitest 4 no te avisa.
Hemos ejecutado la misma prueba en los dos entornos. Angular 21 admite solo vitest ^4.0.8; Angular 22 admite ^4.0.8 || ^5.0.0, así que al actualizar Angular puedes dejar Vitest para después. Las novedades de Vitest 5 afectan así a un proyecto Angular:
| Qué se prueba | Vitest 4.1.12 (21.2) | Vitest 5.0.5 (22.2) |
|---|---|---|
Espía creado fuera de it: llamadas heredadas en la siguiente prueba | 1, las conserva | 0, clearMocks vale true |
expect(promesa).rejects... sin await | La prueba pasa | La prueba falla |
vi.fn(Perro): instanceof Perro | false | true |
vi.fn(Perro): método ladrar en la instancia | undefined | function |
toThrow('') | Falla | Pasa: la cadena vacía encaja con todo |
navigator.userAgent | jsdom/28.1.0 | jsdom/30.1.3 |
Esta es la prueba que cambia de resultado por falta de await:
it('rejects sin await', () => {
expect(Promise.reject(new Error('x'))).rejects.toThrow('x');
});
En Vitest 4 pasa en silencio, aunque no comprueba nada antes de que termine la prueba. En Vitest 5 falla con un mensaje claro:
Promise returned by `expect(actual).rejects.toThrow(expected)` was not awaited. This assertion is asynchronous and must be awaited; otherwise, it is not guaranteed to complete before the test finishes
Vitest 5 también deja de buscar ficheros de configuración en carpetas superiores y simula Temporal junto con Date en los temporizadores falsos. Lo segundo no pudimos observarlo, porque Node 24 no trae Temporal activado. La lista completa está en la guía de migración de Vitest.
💡 Consejo: Si te llega un proyecto con pruebas que dependían de que un espía acumulara llamadas entre
it, ese es un olor a prueba acoplada. No fuercesclearMocks: false: arregla la prueba.
3. Anatomía de una prueba: funciones puras
Empieza por lo más fácil: una función sin dependencias. No necesita Angular.
// src/app/utilidades/salario.ts
export function salarioNeto(bruto: number, retencion: number): number {
if (bruto < 0) {
throw new RangeError('El salario no puede ser negativo');
}
if (retencion < 0 || retencion > 100) {
throw new RangeError('La retención debe estar entre 0 y 100');
}
return Math.round(bruto * (100 - retencion)) / 100;
}
// src/app/utilidades/salario.spec.ts
import { salarioNeto } from './salario';
describe('salarioNeto', () => {
it('descuenta la retención del bruto', () => {
// Preparar
const bruto = 2000;
const retencion = 15;
// Actuar
const neto = salarioNeto(bruto, retencion);
// Comprobar
expect(neto).toBe(1700);
});
it('redondea a dos decimales', () => {
expect(salarioNeto(1234.56, 21)).toBe(975.3);
});
it.each([
[1000, 0, 1000],
[1000, 100, 0],
[0, 15, 0],
])('con bruto %d y retención %d da %d', (bruto, retencion, esperado) => {
expect(salarioNeto(bruto, retencion)).toBe(esperado);
});
it('rechaza un bruto negativo', () => {
expect(() => salarioNeto(-1, 15)).toThrow(RangeError);
});
it('rechaza una retención fuera de rango', () => {
expect(() => salarioNeto(1000, 101)).toThrow('entre 0 y 100');
});
});
Cada pieza tiene su papel:
describeagrupa pruebas relacionadas y puede anidarse.ites una prueba. Su texto debería leerse como una frase: «salarioNeto descuenta la retención del bruto».expect(valor)crea la comprobación, y el comparador (matcher) decide:toBe,toEqual…it.eachrepite la prueba con una tabla de datos. Es la forma limpia de cubrir los casos límite: cero, el máximo y justo por encima.
El patrón Preparar, Actuar, Comprobar (en inglés Arrange, Act, Assert) separa las tres fases. En una prueba corta no hacen falta los comentarios, pero el orden sí se mantiene.
⚠️ Importante: Para comprobar que algo lanza un error, pasa una función:
expect(() => salarioNeto(-1, 15)).toThrow(). Si escribesexpect(salarioNeto(-1, 15)).toThrow(), la excepción salta antes de llegar aexpecty la prueba falla sin haber comprobado nada.
Comparadores más usados
| Comparador | Uso | Ojo con |
|---|---|---|
toBe(x) | Igualdad estricta, como Object.is | No sirve para comparar objetos por contenido |
toEqual(x) | Igualdad por contenido | Ignora las propiedades con valor undefined |
toStrictEqual(x) | Por contenido y también tipos | Más exigente que toEqual |
toBeNull(), toBeTruthy() | Nulos y valores de verdad | toBeTruthy acepta casi todo: prefiere algo más concreto |
toContain(x) | Texto o lista que contiene x | |
toHaveLength(n) | Longitud | |
toMatchObject(x) | El objeto contiene esas propiedades | Útil con objetos de error grandes |
toThrow(x) | Lanza un error, por clase o texto | Requiere una función |
toHaveBeenCalledWith(...) | El espía se llamó con esos argumentos | |
toHaveBeenCalledExactlyOnceWith(...) | Una sola vez y con esos argumentos | |
resolves, rejects | Comprueba una promesa | Siempre con await |
Espías con vi.fn y vi.spyOn
Un espía (spy o mock) es una función falsa que recuerda cómo la han llamado y puede devolver lo que le digas:
const guardar = vi.fn();
guardar.mockReturnValue(true);
guardar('Ana');
expect(guardar).toHaveBeenCalledWith('Ana');
vi.spyOn(objeto, 'metodo') hace lo mismo sobre un método que ya existe. mockReturnValue fija el resultado, mockResolvedValue una promesa resuelta, y mockReset lo deja como nuevo.
📘 Concepto: A los sustitutos de una dependencia se les llama en general dobles de prueba. En Angular casi nunca hace falta reemplazar módulos con
vi.mock: la inyección de dependencias ya te permite entregar un doble por la puerta principal, como verás enseguida.
4. Pipes y lógica sin TestBed
Un pipe es una clase normal. Si no inyecta nada, se crea con new:
// src/app/pipes/iniciales.pipe.ts
import { Pipe, PipeTransform } from '@angular/core';
@Pipe({ name: 'iniciales' })
export class InicialesPipe implements PipeTransform {
transform(nombreCompleto: string | null | undefined, maximo = 2): string {
if (!nombreCompleto) {
return '';
}
return nombreCompleto
.trim()
.split(/\s+/)
.slice(0, maximo)
.map((palabra) => palabra[0].toUpperCase())
.join('');
}
}
// src/app/pipes/iniciales.pipe.spec.ts
import { InicialesPipe } from './iniciales.pipe';
describe('InicialesPipe', () => {
const pipe = new InicialesPipe();
it('toma la primera letra de las dos primeras palabras', () => {
expect(pipe.transform('Ana García López')).toBe('AG');
});
it('admite un máximo distinto', () => {
expect(pipe.transform('Ana García López', 3)).toBe('AGL');
});
it('ignora los espacios sobrantes', () => {
expect(pipe.transform(' luis martínez ')).toBe('LM');
});
it.each([null, undefined, ''])('devuelve cadena vacía con %s', (valor) => {
expect(pipe.transform(valor)).toBe('');
});
});
Son las pruebas más rápidas que escribirás. Saca la lógica de los componentes a funciones y pipes siempre que puedas: lo que queda en el componente se prueba mejor, y lo extraído se prueba sin montar nada.
5. TestBed y servicios
En cuanto una pieza usa inject(), ya no basta con new: inject necesita un contexto de inyección. TestBed crea una aplicación mínima para cada prueba, con su inyector, su ApplicationRef y su planificador de detección de cambios.
Figura 2. TestBed es una pequeña aplicación. El *fixture es el mando con el que la manejas.*
El dato clave: después de cada prueba, Angular destruye esa aplicación y crea otra. Un servicio con providedIn: 'root' es único dentro de una prueba, pero no se comparte entre pruebas.
Servicios con @Service 🆕 v22.0
🆕 Desde Angular 22.0. No existe en la LTS (Angular 21). Si usas la LTS: escribe
@Injectable({ providedIn: 'root' })en cada servicio de este módulo. Las pruebas no cambian: las hemos ejecutado también en la 21 con ese único cambio.
// src/app/servicios/seleccion.ts
import { computed, Service, signal } from '@angular/core';
import { Empleado } from '../modelos';
@Service()
export class Seleccion {
private readonly _empleados = signal<Empleado[]>([]);
readonly empleados = this._empleados.asReadonly();
readonly cuantos = computed(() => this._empleados().length);
readonly masaSalarial = computed(() =>
this._empleados().reduce((total, e) => total + e.salario, 0),
);
alternar(empleado: Empleado): void {
this._empleados.update((lista) =>
lista.some((e) => e.empId === empleado.empId)
? lista.filter((e) => e.empId !== empleado.empId)
: [...lista, empleado],
);
}
vaciar(): void {
this._empleados.set([]);
}
}
// src/app/servicios/seleccion.spec.ts
import { TestBed } from '@angular/core/testing';
import { Empleado } from '../modelos';
import { Seleccion } from './seleccion';
const ana: Empleado = {
empId: 100, nombre: 'Ana', apellidos: 'García López', email: 'ana.garcia@empresa.es',
oficio: 'DIRECTOR GENERAL', salario: 6500, deptId: 10,
};
const luis: Empleado = { ...ana, empId: 101, nombre: 'Luis', salario: 4800 };
describe('Seleccion', () => {
let seleccion: Seleccion;
beforeEach(() => {
seleccion = TestBed.inject(Seleccion);
});
it('empieza vacía', () => {
expect(seleccion.cuantos()).toBe(0);
expect(seleccion.masaSalarial()).toBe(0);
});
it('recalcula los computed al añadir empleados', () => {
seleccion.alternar(ana);
seleccion.alternar(luis);
expect(seleccion.cuantos()).toBe(2);
expect(seleccion.masaSalarial()).toBe(11300);
});
it('quita al empleado si ya estaba', () => {
seleccion.alternar(ana);
seleccion.alternar(ana);
expect(seleccion.empleados()).toEqual([]);
});
it('no comparte estado entre pruebas', () => {
expect(seleccion.cuantos()).toBe(0);
});
});
La última prueba demuestra la limpieza de TestBed: el servicio se ha creado de nuevo, así que las pruebas pueden ejecutarse en cualquier orden.
Para probar un computed no hace falta nada especial: es síncrono. Cambias la señal de origen y lees el resultado, sin esperas. Las señales son muy agradables de probar por esta razón.
📘 Concepto:
TestBed.inject(X)pideXal inyector de la prueba, igual queinject(X)dentro de un componente. Si necesitas ejecutar una función que usainject()y no es una clase, usaTestBed.runInInjectionContext(() => ...), como verás con las guardas.
Dependencias falsas: provide y useValue
Si un servicio depende de algo externo, lo sustituyes en la configuración de la prueba. Este servicio guarda el tema en un almacén, que se expone como token para poder cambiarlo:
// src/app/servicios/tema.ts
import { effect, InjectionToken, inject, Service, signal } from '@angular/core';
export const ALMACEN = new InjectionToken<Pick<Storage, 'getItem' | 'setItem'>>('ALMACEN', {
factory: () => localStorage,
});
@Service()
export class Tema {
private readonly almacen = inject(ALMACEN);
readonly oscuro = signal(this.almacen.getItem('tema') === 'oscuro');
constructor() {
effect(() => {
this.almacen.setItem('tema', this.oscuro() ? 'oscuro' : 'claro');
});
}
alternar(): void {
this.oscuro.update((valor) => !valor);
}
}
Probar un effect con TestBed.tick
Un effect no se ejecuta en el momento en que cambia la señal: queda programado y Angular lo lanza al sincronizar. En una prueba, lo fuerzas con TestBed.tick().
// src/app/servicios/tema.spec.ts
import { TestBed } from '@angular/core/testing';
import { ALMACEN, Tema } from './tema';
describe('Tema', () => {
const almacen = { getItem: vi.fn(), setItem: vi.fn() };
beforeEach(() => {
almacen.getItem.mockReset();
almacen.setItem.mockReset();
TestBed.configureTestingModule({
providers: [{ provide: ALMACEN, useValue: almacen }],
});
});
it('lee el valor guardado al crearse', () => {
almacen.getItem.mockReturnValue('oscuro');
const tema = TestBed.inject(Tema);
expect(almacen.getItem).toHaveBeenCalledWith('tema');
expect(tema.oscuro()).toBe(true);
});
it('el efecto no se ejecuta hasta que Angular sincroniza', () => {
const tema = TestBed.inject(Tema);
tema.alternar();
expect(almacen.setItem).not.toHaveBeenCalled();
TestBed.tick();
expect(almacen.setItem).toHaveBeenCalledTimes(1);
expect(almacen.setItem).toHaveBeenCalledWith('tema', 'oscuro');
});
});
Fíjate en que el efecto se ejecutó una sola vez, aunque se creó con false y luego pasó a true: Angular agrupa los cambios y usa el último valor.
configureTestingModule debe llamarse antes de que nada use el inyector. Si inyectas algo primero, TestBed queda fijado y la configuración posterior lanza un error.
⚠️ Importante:
TestBed.flushEffects()sigue existiendo, pero está obsoleto: los tipos indican que usesTestBed.tick(). Lo verás en código de hace unas versiones.
6. Componentes
Este es el componente de presentación que usaremos. Tiene entradas, una salida y un model():
// src/app/tarjeta-empleado/tarjeta-empleado.ts
import { CurrencyPipe } from '@angular/common';
import { Component, computed, input, model, output } from '@angular/core';
import { Empleado } from '../modelos';
import { InicialesPipe } from '../pipes/iniciales.pipe';
@Component({
selector: 'app-tarjeta-empleado',
imports: [CurrencyPipe, InicialesPipe],
template: `
<article [class.destacada]="destacada()">
<span class="avatar">{{ nombreCompleto() | iniciales }}</span>
<h3>{{ nombreCompleto() }}</h3>
<p class="oficio">{{ empleado().oficio }}</p>
@if (mostrarSalario()) {
<p class="salario">{{ empleado().salario | currency: 'EUR' }}</p>
}
<button type="button" class="ver" (click)="seleccionado.emit(empleado().empId)">
Ver ficha
</button>
<button type="button" class="favorito" (click)="favorito.set(!favorito())">
{{ favorito() ? 'Quitar de favoritos' : 'Añadir a favoritos' }}
</button>
</article>
`,
})
export class TarjetaEmpleado {
readonly empleado = input.required<Empleado>();
readonly destacada = input(false);
readonly mostrarSalario = input(true);
readonly favorito = model(false);
readonly seleccionado = output<number>();
protected readonly nombreCompleto = computed(
() => `${this.empleado().nombre} ${this.empleado().apellidos}`,
);
}
Crear el componente y usar setInput
// src/app/tarjeta-empleado/tarjeta-empleado.spec.ts
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { By } from '@angular/platform-browser';
import { Empleado } from '../modelos';
import { TarjetaEmpleado } from './tarjeta-empleado';
const ana: Empleado = {
empId: 100, nombre: 'Ana', apellidos: 'García López', email: 'ana.garcia@empresa.es',
oficio: 'DIRECTOR GENERAL', salario: 6500, deptId: 10,
};
describe('TarjetaEmpleado', () => {
let fixture: ComponentFixture<TarjetaEmpleado>;
let host: HTMLElement;
beforeEach(async () => {
fixture = TestBed.createComponent(TarjetaEmpleado);
fixture.componentRef.setInput('empleado', ana);
await fixture.whenStable();
host = fixture.nativeElement;
});
it('pinta nombre, iniciales y oficio', () => {
expect(host.querySelector('h3')?.textContent).toBe('Ana García López');
expect(host.querySelector('.avatar')?.textContent).toBe('AG');
expect(host.querySelector('.oficio')?.textContent).toBe('DIRECTOR GENERAL');
});
it('oculta el salario cuando mostrarSalario es false', async () => {
fixture.componentRef.setInput('mostrarSalario', false);
await fixture.whenStable();
expect(host.querySelector('.salario')).toBeNull();
});
it('aplica la clase destacada', async () => {
fixture.componentRef.setInput('destacada', true);
await fixture.whenStable();
expect(host.querySelector('article')?.classList.contains('destacada')).toBe(true);
});
it('emite el id al pulsar Ver ficha', () => {
const emitido = vi.fn();
fixture.componentInstance.seleccionado.subscribe(emitido);
host.querySelector<HTMLButtonElement>('.ver')!.click();
expect(emitido).toHaveBeenCalledExactlyOnceWith(100);
});
it('cambia el texto del botón de favorito', async () => {
const boton = fixture.debugElement.query(By.css('.favorito'));
expect(boton.nativeElement.textContent).toContain('Añadir');
boton.triggerEventHandler('click');
await fixture.whenStable();
expect(boton.nativeElement.textContent).toContain('Quitar');
expect(fixture.componentInstance.favorito()).toBe(true);
});
});
Las reglas que hay detrás de este código:
createComponentcrea, pero no espera. Coninput.required, debes dar valor a la entrada antes de la primera sincronización, o Angular lanzará un error al leerla.componentRef.setInputes la forma correcta de dar valor a una entrada. Pasa por el mismo camino que un enlace de plantilla. Uninput()es de solo lectura desde fuera, así que no podrías asignarlo.await fixture.whenStable()espera a que Angular termine lo que tenía pendiente, incluida la actualización de la vista.- Consulta el DOM, no el estado interno.
nombreCompletoesprotected, así que ni siquiera deberías tocarlo. - Una salida se escucha con
subscribeen la instancia. Una llamada a.click()real es la mejor forma de provocar el evento, porque también comprueba que el botón está enlazado.
nativeElement es el elemento raíz del componente, y debugElement es una capa de Angular por encima: permite buscar por directiva con By.directive y disparar un manejador sin evento de DOM con triggerEventHandler.
💡 Consejo: Consulta por texto, papel o
data-testidcuando puedas, y deja las clases de estilo para los ejemplos cortos. Si alguien renombra.verpor un cambio de diseño, la prueba no debería romperse.
bindings: enlaces sin componente anfitrión
createComponent acepta enlaces que siguen vivos durante la prueba. Con ellos, una señal hace de entrada y puedes cambiarla después:
import { inputBinding, outputBinding, signal, twoWayBinding } from '@angular/core';
it('reacciona a señales y recibe salidas sin componente anfitrión', async () => {
const empleado = signal(ana);
const favorito = signal(false);
const emitido = vi.fn();
const fixture = TestBed.createComponent(TarjetaEmpleado, {
bindings: [
inputBinding('empleado', empleado),
twoWayBinding('favorito', favorito),
outputBinding<number>('seleccionado', emitido),
],
});
await fixture.whenStable();
const host: HTMLElement = fixture.nativeElement;
empleado.set({ ...ana, nombre: 'Elena' });
await fixture.whenStable();
expect(host.querySelector('h3')?.textContent).toBe('Elena García López');
host.querySelector<HTMLButtonElement>('.favorito')!.click();
expect(favorito()).toBe(true);
host.querySelector<HTMLButtonElement>('.ver')!.click();
expect(emitido).toHaveBeenCalledWith(100);
});
Es la forma más cómoda de probar un model(), porque twoWayBinding comprueba las dos direcciones a la vez. Funciona igual en la LTS.
| Necesito | Elige |
|---|---|
| Un valor fijo o dos cambios sencillos | componentRef.setInput |
Entradas reactivas, salidas o model() | bindings |
| Comprobar proyección de contenido o varios componentes juntos | Un componente anfitrión (sección 11) |
Recuperar el último fixture: TestBed.getLastFixture 🆕 v22.0
🆕 Desde Angular 22.0. No existe en la LTS (Angular 21). Si usas la LTS: guarda el resultado de
TestBed.createComponenten una variable y pásala a tus funciones auxiliares.
const fixture = TestBed.createComponent(Panel);
expect(TestBed.getLastFixture<Panel>()).toBe(fixture);
Resulta útil en funciones de ayuda que no reciben el fixture como parámetro. Lanza un error si aún no has creado ninguno.
7. Cuándo se actualiza la vista en una prueba
Este apartado te ahorrará muchas horas. El proyecto no usa Zone.js, así que nada actualiza la vista por arte de magia. Angular actualiza cuando algo se lo notifica: una señal leída en la plantilla, un setInput, o un evento enlazado en la plantilla. Lo viste en el módulo 09, y en las pruebas pasa lo mismo.
| Herramienta | Qué hace | Cuándo usarla |
|---|---|---|
await fixture.whenStable() | Espera a que no quede trabajo pendiente | La opción por defecto |
TestBed.tick() | Sincroniza ya, de forma síncrona: efectos y vista | Con temporizadores falsos, o para arrancar un recurso |
fixture.detectChanges() | Fuerza la detección sobre el fixture | Código antiguo, o pruebas que quieren controlar cada paso |
fixture.autoDetectChanges() | Sincroniza como en una aplicación | En zoneless ya es el comportamiento habitual |
Detección y propiedades normales 🔄 v22.0
🔄 Cambia en Angular 22.0. Sin
changeDetection, el componente es OnPush: tras cambiar una propiedad normal,fixture.detectChanges()no actualiza la vista y no avisa. En la LTS (Angular 21) el mismo código lanzaNG0100. Si usas la LTS: la solución es la misma en ambas versiones: guarda en señales lo que muestra la plantilla y usaawait fixture.whenStable().
Hemos ejecutado esta prueba en las dos versiones:
@Component({ template: `<p>{{ titulo }}</p>` })
class SinEstrategia {
titulo = 'antes';
}
it('propiedad normal', () => {
const fixture = TestBed.createComponent(SinEstrategia);
fixture.detectChanges();
fixture.componentInstance.titulo = 'despues';
fixture.detectChanges();
expect(fixture.nativeElement.textContent).toBe('despues'); // ¿funciona?
});
| Escenario | Angular 22.2 | Angular 21.2 |
|---|---|---|
Sin changeDetection, propiedad normal | La vista sigue en antes, sin error | NG0100 |
ChangeDetectionStrategy.Eager, propiedad normal | NG0100 | NG0100 |
Señal más await fixture.whenStable() | despues | despues |
Las dos filas superiores tienen la misma causa: nadie ha notificado el cambio a Angular. En la 22, la comprobación OnPush ni mira el componente, así que la prueba sale en rojo por el expect, con un valor antiguo. NG0100 aparece cuando Angular revisa la vista en su comprobación de coherencia y ve una diferencia que no esperaba.
Esto te lo vas a encontrar al actualizar a 22: pruebas que mutaban propiedades pasan de NG0100 a mostrar valores antiguos. La migración change-detection-eager añade Eager para conservar el comportamiento de la aplicación, pero en estas pruebas seguirás viendo NG0100. El remedio es convertir la propiedad en señal, no pelearse con detectChanges.
💡 Consejo: Regla práctica:
await fixture.whenStable()después de cada acción que cambie algo, yexpectsobre el DOM. Si necesitas variosdetectChangesseguidos, sospecha de que el componente no usa señales.
8. HTTP: provideHttpClientTesting
Una prueba unitaria no debe llamar a la API real: sería lenta, dependería de que el servidor esté encendido y de sus datos, y no podrías provocar un error 500 cuando quisieras.
provideHttpClientTesting() sustituye el backend de HttpClient por uno que guarda las peticiones y no las envía. Tú las inspeccionas y las respondes con HttpTestingController.
Figura 3. La petición llega hasta el *backend de prueba y espera a que tú respondas.*
Funciona igual tanto si el backend real es fetch (22) como XHR (21), porque reemplaza a los dos. Por tanto, estas pruebas no cambian al actualizar. provideHttpClient() va delante de provideHttpClientTesting().
// src/app/servicios/empleados-api.ts
import { HttpClient, HttpErrorResponse } from '@angular/common/http';
import { inject, Service } from '@angular/core';
import { catchError, map, Observable, throwError } from 'rxjs';
import { API } from '../api';
import { Empleado, Pagina } from '../modelos';
@Service()
export class EmpleadosApi {
private readonly http = inject(HttpClient);
listar(deptId?: number): Observable<Empleado[]> {
const params: Record<string, number> = deptId === undefined ? {} : { deptId };
return this.http
.get<Pagina<Empleado>>(`${API}/empleados`, { params })
.pipe(map((pagina) => pagina.contenido));
}
obtener(id: number): Observable<Empleado> {
return this.http.get<Empleado>(`${API}/empleados/${id}`).pipe(
catchError((error: HttpErrorResponse) =>
throwError(() =>
error.status === 404 ? new Error(`No existe el empleado ${id}`) : error,
),
),
);
}
crear(datos: Omit<Empleado, 'empId'>): Observable<Empleado> {
return this.http.post<Empleado>(`${API}/empleados`, datos);
}
}
// src/app/servicios/empleados-api.spec.ts
import { provideHttpClient } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { TestBed } from '@angular/core/testing';
import { firstValueFrom } from 'rxjs';
import { API } from '../api';
import { Empleado } from '../modelos';
import { EmpleadosApi } from './empleados-api';
const ana: Empleado = {
empId: 100, nombre: 'Ana', apellidos: 'García López', email: 'ana.garcia@empresa.es',
oficio: 'DIRECTOR GENERAL', salario: 6500, deptId: 10,
};
describe('EmpleadosApi', () => {
let api: EmpleadosApi;
let http: HttpTestingController;
beforeEach(() => {
TestBed.configureTestingModule({
providers: [provideHttpClient(), provideHttpClientTesting()],
});
api = TestBed.inject(EmpleadosApi);
http = TestBed.inject(HttpTestingController);
});
afterEach(() => {
http.verify();
});
it('listar extrae el contenido de la página', async () => {
const resultado = firstValueFrom(api.listar());
const peticion = http.expectOne(`${API}/empleados`);
expect(peticion.request.method).toBe('GET');
peticion.flush({ contenido: [ana], pagina: 1, tamano: 20, total: 1, paginas: 1 });
expect(await resultado).toEqual([ana]);
});
it('listar envía el filtro como parámetro', () => {
api.listar(10).subscribe();
const peticion = http.expectOne(
(r) => r.url === `${API}/empleados` && r.params.get('deptId') === '10',
);
expect(peticion.request.urlWithParams).toContain('?deptId=10');
peticion.flush({ contenido: [], pagina: 1, tamano: 20, total: 0, paginas: 0 });
});
it('crear manda los datos en el cuerpo', async () => {
const { empId, ...datos } = ana;
const resultado = firstValueFrom(api.crear(datos));
const peticion = http.expectOne({ method: 'POST', url: `${API}/empleados` });
expect(peticion.request.body).toEqual(datos);
peticion.flush(ana, { status: 201, statusText: 'Created' });
expect((await resultado).empId).toBe(empId);
});
it('convierte el 404 en un error legible', async () => {
const resultado = firstValueFrom(api.obtener(999));
http.expectOne(`${API}/empleados/999`).flush(
{ status: 404, mensaje: 'No existe' },
{ status: 404, statusText: 'Not Found' },
);
await expect(resultado).rejects.toThrow('No existe el empleado 999');
});
it('deja pasar el error 500 sin tocarlo', async () => {
const resultado = firstValueFrom(api.obtener(100));
http.expectOne(`${API}/empleados/100`).flush(null, {
status: 500,
statusText: 'Server Error',
});
await expect(resultado).rejects.toMatchObject({ status: 500 });
});
it('simula un fallo de red', async () => {
const resultado = firstValueFrom(api.obtener(100));
http.expectOne(`${API}/empleados/100`).error(new ProgressEvent('error'));
await expect(resultado).rejects.toMatchObject({ status: 0 });
});
});
Puntos importantes:
- Primero suscribes, después esperas. Un Observable de
HttpClientes perezoso: sin suscripción no hay petición yexpectOnefallaría.firstValueFromse suscribe en el momento. expectOneexige exactamente una petición que encaje. Acepta una URL, un objeto conmethodyurl, o una función. Con una cadena, la URL incluye los parámetros.flushresponde con éxito o con error, según elstatus.errorsimula que la red ha fallado y dastatus: 0.verifyenafterEachcomprueba que no ha quedado ninguna petición sin atender. Sin él, un error que dispara una llamada de más pasa desapercibido.
Dos mensajes que verás mucho, y qué significan:
Expected one matching request for criteria "...", found none.
Expected no open requests, found 1: GET http://localhost:3000/api/empleados
El primero suele indicar que la URL no coincide o que aún no se ha lanzado la petición. El segundo, que tu código ha hecho una petición que la prueba no esperaba.
Componentes con httpResource 🔄 v22.0
🔄 Cambia en Angular 22.0.
httpResourcepasa de experimental a estable. Si usas la LTS: existe y se prueba igual: estas mismas pruebas se han ejecutado en la 21, pero al ser experimental su API puede cambiar entre versiones.
Un recurso del módulo 13 no lanza la petición al crearse, sino al sincronizar. Eso cambia el orden de la prueba.
// src/app/lista-empleados/lista-empleados.ts
import { httpResource } from '@angular/common/http';
import { Component, computed, inject, signal } from '@angular/core';
import { API } from '../api';
import { Empleado, Pagina } from '../modelos';
import { Seleccion } from '../servicios/seleccion';
import { TarjetaEmpleado } from '../tarjeta-empleado/tarjeta-empleado';
@Component({
selector: 'app-lista-empleados',
imports: [TarjetaEmpleado],
template: `
<h2>Empleados</h2>
<label>
Departamento
<select (change)="elegir($any($event.target).value)">
<option value="">Todos</option>
<option value="10">Dirección</option>
<option value="20">Contabilidad</option>
</select>
</label>
<p class="seleccion">Seleccionados: {{ seleccion.cuantos() }}</p>
@if (recurso.error()) {
<p class="error" role="alert">No se pudo cargar la lista</p>
<button type="button" class="reintentar" (click)="recurso.reload()">Reintentar</button>
} @else if (recurso.isLoading()) {
<p class="cargando">Cargando...</p>
} @else {
<p class="total">{{ empleados().length }} empleados</p>
@for (e of empleados(); track e.empId) {
<app-tarjeta-empleado [empleado]="e" (seleccionado)="seleccion.alternar(e)" />
} @empty {
<p class="vacio">No hay empleados</p>
}
}
`,
})
export class ListaEmpleados {
protected readonly seleccion = inject(Seleccion);
private readonly deptId = signal<string>('');
protected readonly recurso = httpResource<Pagina<Empleado>>(() => ({
url: `${API}/empleados`,
params: this.parametros(),
}));
private parametros(): Record<string, string> {
return this.deptId() ? { deptId: this.deptId() } : {};
}
protected readonly empleados = computed(() =>
this.recurso.hasValue() ? this.recurso.value().contenido : [],
);
protected elegir(valor: string): void {
this.deptId.set(valor);
}
}
// src/app/lista-empleados/lista-empleados.spec.ts
import { provideHttpClient } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { API } from '../api';
import { Empleado } from '../modelos';
import { ListaEmpleados } from './lista-empleados';
const ana: Empleado = {
empId: 100, nombre: 'Ana', apellidos: 'García López', email: 'ana.garcia@empresa.es',
oficio: 'DIRECTOR GENERAL', salario: 6500, deptId: 10,
};
const pagina = (contenido: Empleado[]) => ({
contenido, pagina: 1, tamano: 20, total: contenido.length, paginas: 1,
});
describe('ListaEmpleados', () => {
let fixture: ComponentFixture<ListaEmpleados>;
let http: HttpTestingController;
let host: HTMLElement;
beforeEach(() => {
TestBed.configureTestingModule({
providers: [provideHttpClient(), provideHttpClientTesting()],
});
http = TestBed.inject(HttpTestingController);
fixture = TestBed.createComponent(ListaEmpleados);
host = fixture.nativeElement;
});
afterEach(() => http.verify());
it('muestra Cargando y después los datos', async () => {
TestBed.tick();
expect(host.querySelector('.cargando')).not.toBeNull();
http.expectOne(`${API}/empleados`).flush(pagina([ana]));
await fixture.whenStable();
expect(host.querySelector('.cargando')).toBeNull();
expect(host.querySelector('.total')?.textContent).toBe('1 empleados');
expect(host.querySelectorAll('app-tarjeta-empleado').length).toBe(1);
});
it('muestra el estado vacío', async () => {
TestBed.tick();
http.expectOne(`${API}/empleados`).flush(pagina([]));
await fixture.whenStable();
expect(host.querySelector('.vacio')?.textContent).toBe('No hay empleados');
});
it('muestra el error y permite reintentar', async () => {
TestBed.tick();
http.expectOne(`${API}/empleados`).flush(null, { status: 500, statusText: 'Error' });
await fixture.whenStable();
expect(host.querySelector('[role=alert]')).not.toBeNull();
host.querySelector<HTMLButtonElement>('.reintentar')!.click();
TestBed.tick();
http.expectOne(`${API}/empleados`).flush(pagina([ana]));
await fixture.whenStable();
expect(host.querySelector('[role=alert]')).toBeNull();
expect(host.querySelector('h3')?.textContent).toBe('Ana García López');
});
it('vuelve a pedir datos al cambiar el filtro', async () => {
TestBed.tick();
http.expectOne(`${API}/empleados`).flush(pagina([ana]));
await fixture.whenStable();
const select = host.querySelector('select')!;
select.value = '10';
select.dispatchEvent(new Event('change'));
TestBed.tick();
http.expectOne(`${API}/empleados?deptId=10`).flush(pagina([ana]));
await fixture.whenStable();
expect(host.querySelector('.total')?.textContent).toBe('1 empleados');
});
it('el botón de la tarjeta hija actualiza la selección', async () => {
TestBed.tick();
http.expectOne(`${API}/empleados`).flush(pagina([ana]));
await fixture.whenStable();
host.querySelector<HTMLButtonElement>('app-tarjeta-empleado .ver')!.click();
await fixture.whenStable();
expect(host.querySelector('.seleccion')?.textContent).toBe('Seleccionados: 1');
});
});
La última prueba es de integración: usa la tarjeta hija real y el servicio real, y solo simula la red.
El interbloqueo: whenStable antes de flush
Cuando empiezas a escribir estas pruebas, lo natural es poner whenStable tras crear el componente. Se queda colgado:
const fixture = TestBed.createComponent(ListaEmpleados);
await fixture.whenStable(); // espera a la petición, que espera al flush
http.expectOne(`${API}/empleados`).flush({ contenido: [] }); // nunca llega aquí
Test timed out in 2000ms.
(El tiempo es el límite que le pusimos a esta prueba con { timeout: 2000 }; sin esa opción, el límite por defecto de Vitest es de 5000 ms.)
El motivo es un círculo: mientras hay una petición en vuelo, el recurso mantiene una tarea pendiente, y whenStable no resuelve hasta que termine. La tarea termina cuando llega la respuesta, y la respuesta la da tu flush, que está en la línea siguiente, esperando a whenStable.
La solución es TestBed.tick(): lanza el recurso de forma síncrona, sin esperar a nada. Entonces la petición existe, expectOne la encuentra, y flush la completa. Ya puedes usar whenStable. Es el orden de la figura 3: tick, flush, whenStable, expect.
9. Router y guardas
Una guarda funcional, por sí sola
Una guarda es una función que usa inject(), por lo que debe ejecutarse en un contexto de inyección:
// src/app/guardas/solo-admin.ts
import { inject } from '@angular/core';
import { CanActivateFn, Router } from '@angular/router';
import { Sesion } from '../servicios/sesion';
export const soloAdmin: CanActivateFn = () => {
const sesion = inject(Sesion);
const router = inject(Router);
return sesion.esAdmin() ? true : router.createUrlTree(['/acceso-denegado']);
};
// src/app/guardas/solo-admin.spec.ts
import { TestBed } from '@angular/core/testing';
import { ActivatedRouteSnapshot, provideRouter, RouterStateSnapshot, UrlTree } from '@angular/router';
import { Sesion } from '../servicios/sesion';
import { soloAdmin } from './solo-admin';
describe('soloAdmin', () => {
const ejecutar = () =>
TestBed.runInInjectionContext(() =>
soloAdmin({} as ActivatedRouteSnapshot, {} as RouterStateSnapshot),
);
beforeEach(() => {
TestBed.configureTestingModule({ providers: [provideRouter([])] });
});
it('deja pasar al administrador', () => {
TestBed.inject(Sesion).rol.set('ADMIN');
expect(ejecutar()).toBe(true);
});
it('redirige a los demás', () => {
TestBed.inject(Sesion).rol.set('USER');
const resultado = ejecutar() as UrlTree;
expect(resultado.toString()).toBe('/acceso-denegado');
});
});
Navegación real con RouterTestingHarness
Probar la guarda aislada no demuestra que esté puesta en la ruta. RouterTestingHarness monta un router-outlet y navega de verdad, sin navegador. Se importa de @angular/router/testing.
// src/app/rutas.spec.ts
import { provideHttpClient } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { Component } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { provideRouter, Router, withComponentInputBinding } from '@angular/router';
import { RouterTestingHarness } from '@angular/router/testing';
import { API } from './api';
import { DetalleEmpleado } from './detalle-empleado/detalle-empleado';
import { soloAdmin } from './guardas/solo-admin';
import { Sesion } from './servicios/sesion';
@Component({ template: '<h2>Lista</h2>' })
class ListaFalsa {}
@Component({ template: '<h2>Administración</h2>' })
class AdminFalso {}
@Component({ template: '<h2>Acceso denegado</h2>' })
class DenegadoFalso {}
describe('Rutas', () => {
let harness: RouterTestingHarness;
beforeEach(async () => {
TestBed.configureTestingModule({
providers: [
provideHttpClient(),
provideHttpClientTesting(),
provideRouter(
[
{ path: 'empleados', component: ListaFalsa },
{ path: 'empleados/:id', component: DetalleEmpleado },
{ path: 'admin', component: AdminFalso, canActivate: [soloAdmin] },
{ path: 'acceso-denegado', component: DenegadoFalso },
],
withComponentInputBinding(),
),
],
});
harness = await RouterTestingHarness.create();
});
it('pasa el parámetro id al input del componente', async () => {
const detalle = await harness.navigateByUrl('/empleados/100', DetalleEmpleado);
const http = TestBed.inject(HttpTestingController);
expect(detalle.id()).toBe(100);
http.expectOne(`${API}/empleados/100`).flush({
empId: 100, nombre: 'Ana', apellidos: 'García López', oficio: 'DIRECTOR GENERAL',
});
await harness.fixture.whenStable();
expect(harness.routeNativeElement?.querySelector('h2')?.textContent).toBe('Ana García López');
http.verify();
});
it('el enlace Volver navega a la lista', async () => {
await harness.navigateByUrl('/empleados/100');
TestBed.inject(HttpTestingController).expectOne(`${API}/empleados/100`).flush({});
await harness.fixture.whenStable();
harness.routeNativeElement?.querySelector<HTMLAnchorElement>('a.volver')!.click();
await harness.fixture.whenStable();
expect(TestBed.inject(Router).url).toBe('/empleados');
expect(harness.routeNativeElement?.textContent).toContain('Lista');
});
it('la guarda deja entrar al administrador', async () => {
TestBed.inject(Sesion).rol.set('ADMIN');
await harness.navigateByUrl('/admin');
expect(TestBed.inject(Router).url).toBe('/admin');
expect(harness.routeNativeElement?.textContent).toContain('Administración');
});
it('la guarda redirige a un usuario normal', async () => {
TestBed.inject(Sesion).rol.set('USER');
await harness.navigateByUrl('/admin');
expect(TestBed.inject(Router).url).toBe('/acceso-denegado');
expect(harness.routeNativeElement?.textContent).toContain('Acceso denegado');
});
});
navigateByUrl espera a que termine la navegación y, si le pasas el tipo del componente, te devuelve su instancia y comprueba que es del tipo esperado. Aquí navigateByUrl resuelve aunque el recurso de DetalleEmpleado tenga la petición pendiente, y por eso podemos llamar a expectOne después sin interbloqueo.
El componente de detalle es este:
// src/app/detalle-empleado/detalle-empleado.ts
import { httpResource } from '@angular/common/http';
import { Component, input, numberAttribute } from '@angular/core';
import { RouterLink } from '@angular/router';
import { API } from '../api';
import { Empleado } from '../modelos';
@Component({
selector: 'app-detalle-empleado',
imports: [RouterLink],
template: `
<a routerLink="/empleados" class="volver">Volver</a>
@if (recurso.hasValue()) {
<h2>{{ recurso.value().nombre }} {{ recurso.value().apellidos }}</h2>
<p>{{ recurso.value().oficio }}</p>
}
`,
})
export class DetalleEmpleado {
readonly id = input.required({ transform: numberAttribute });
protected readonly recurso = httpResource<Empleado>(() => `${API}/empleados/${this.id()}`);
}
⚠️ Importante: La prueba configura
provideRouterpor su cuenta, conwithComponentInputBinding(). No comprueba tuapp.config.ts. En la sección 14 verás cómo este detalle dejó pasar un error real.
💡 Consejo:
harness.detectChanges()yharness.fixtureson los equivalentes defixturepara el contenido de la ruta. Si quieres probar tus rutas reales, exporta el arrayroutesy pásalo aprovideRouter, con las dependencias HTTP simuladas.
10. Formularios
Formularios reactivos
// src/app/form-departamento/form-departamento.ts
import { Component, inject, output } from '@angular/core';
import { NonNullableFormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
export interface DatosDepartamento {
nombre: string;
localidad: string;
}
@Component({
selector: 'app-form-departamento',
imports: [ReactiveFormsModule],
template: `
<form [formGroup]="formulario" (ngSubmit)="enviar()">
<input id="nombre" formControlName="nombre" />
@if (formulario.controls.nombre.touched && formulario.controls.nombre.invalid) {
<p class="error">El nombre es obligatorio</p>
}
<input id="localidad" formControlName="localidad" />
<button type="submit" [disabled]="formulario.invalid">Guardar</button>
</form>
`,
})
export class FormDepartamento {
readonly guardado = output<DatosDepartamento>();
protected readonly formulario = inject(NonNullableFormBuilder).group({
nombre: ['', [Validators.required, Validators.minLength(3)]],
localidad: ['Madrid', Validators.required],
});
protected enviar(): void {
if (this.formulario.valid) {
this.guardado.emit(this.formulario.getRawValue());
}
}
}
// src/app/form-departamento/form-departamento.spec.ts
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { FormDepartamento } from './form-departamento';
describe('FormDepartamento', () => {
let fixture: ComponentFixture<FormDepartamento>;
let host: HTMLElement;
let nombre: HTMLInputElement;
let boton: HTMLButtonElement;
function escribir(campo: HTMLInputElement, valor: string) {
campo.value = valor;
campo.dispatchEvent(new Event('input'));
}
beforeEach(async () => {
fixture = TestBed.createComponent(FormDepartamento);
await fixture.whenStable();
host = fixture.nativeElement;
nombre = host.querySelector('#nombre')!;
boton = host.querySelector('button')!;
});
it('empieza con el botón desactivado y el valor inicial en la vista', () => {
expect(boton.disabled).toBe(true);
expect(host.querySelector<HTMLInputElement>('#localidad')!.value).toBe('Madrid');
});
it('activa el botón con datos válidos', async () => {
escribir(nombre, 'VENTAS');
await fixture.whenStable();
expect(boton.disabled).toBe(false);
});
it('muestra el error al salir del campo vacío', async () => {
expect(host.querySelector('.error')).toBeNull();
nombre.dispatchEvent(new Event('blur'));
await fixture.whenStable();
expect(host.querySelector('.error')?.textContent).toContain('obligatorio');
});
it('emite los datos al enviar', async () => {
const guardado = vi.fn();
fixture.componentInstance.guardado.subscribe(guardado);
escribir(nombre, 'VENTAS');
await fixture.whenStable();
host.querySelector('form')!.dispatchEvent(new Event('submit'));
expect(guardado).toHaveBeenCalledWith({ nombre: 'VENTAS', localidad: 'Madrid' });
});
});
Asignar campo.value no basta: el navegador no lanza ningún evento cuando el código cambia un valor. La directiva del formulario escucha input (y blur para marcar como tocado), así que tienes que disparar esos eventos, igual que lo haría el usuario. La función escribir es la que repetirás en todas las pruebas de formularios.
Signal Forms 🔄 v22.0
🔄 Cambia en Angular 22.0. Signal Forms pasa de experimental a estable. Si usas la LTS: este componente y sus pruebas funcionan sin cambios en la 21, pero la API es experimental; revisa las diferencias del módulo 18.
// src/app/form-empleado/form-empleado.ts
import { Component, signal } from '@angular/core';
import { email, form, FormField, required, submit } from '@angular/forms/signals';
interface Alta {
nombre: string;
email: string;
}
@Component({
selector: 'app-form-empleado',
imports: [FormField],
template: `
<form (submit)="enviar($event)">
<input id="nombre" [formField]="alta.nombre" />
<input id="email" type="email" [formField]="alta.email" />
@for (error of alta.email().errors(); track error) {
<p class="error">{{ error.message }}</p>
}
<button type="submit" [disabled]="alta().invalid()">Dar de alta</button>
</form>
@if (enviado(); as datos) {
<p class="ok">Alta de {{ datos.nombre }}</p>
}
`,
})
export class FormEmpleado {
private readonly modelo = signal<Alta>({ nombre: '', email: '' });
protected readonly enviado = signal<Alta | null>(null);
readonly alta = form(this.modelo, (ruta) => {
required(ruta.nombre);
required(ruta.email);
email(ruta.email, { message: 'Correo no válido' });
});
protected async enviar(evento: Event) {
evento.preventDefault();
await submit(this.alta, {
action: async (campo) => {
this.enviado.set(campo().value());
},
});
}
}
// src/app/form-empleado/form-empleado.spec.ts
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { FormEmpleado } from './form-empleado';
describe('FormEmpleado (Signal Forms)', () => {
let fixture: ComponentFixture<FormEmpleado>;
let host: HTMLElement;
function escribir(selector: string, valor: string) {
const campo = host.querySelector<HTMLInputElement>(selector)!;
campo.value = valor;
campo.dispatchEvent(new Event('input'));
}
beforeEach(async () => {
fixture = TestBed.createComponent(FormEmpleado);
await fixture.whenStable();
host = fixture.nativeElement;
});
it('valida directamente sobre el árbol de campos', () => {
const { alta } = fixture.componentInstance;
expect(alta().valid()).toBe(false);
alta.nombre().value.set('Ana');
alta.email().value.set('ana.garcia@empresa.es');
expect(alta().valid()).toBe(true);
});
it('muestra el mensaje del validador', async () => {
escribir('#email', 'no-es-un-correo');
await fixture.whenStable();
expect(host.querySelector('.error')?.textContent).toBe('Correo no válido');
});
it('la vista actualiza el modelo y el envío llega al final', async () => {
escribir('#nombre', 'Ana');
escribir('#email', 'ana.garcia@empresa.es');
await fixture.whenStable();
expect(host.querySelector('button')!.disabled).toBe(false);
host.querySelector('form')!.dispatchEvent(new Event('submit', { cancelable: true }));
await fixture.whenStable();
expect(host.querySelector('.ok')?.textContent).toBe('Alta de Ana');
});
});
La ventaja de Signal Forms se nota en la primera prueba: la validación es señales puras, así que se comprueba de forma síncrona, sin DOM ni esperas. Reserva las pruebas con eventos para lo que une el formulario con la vista.
En la tercera prueba, await fixture.whenStable() basta para ver el resultado de submit: no hace falta ningún temporizador.
11. Directivas
Hay dos caminos. Usa el primero si tu versión lo tiene y la directiva se basta sola, y el segundo cuando te interese cómo se comporta dentro de una plantilla.
// src/app/resaltar/resaltar.ts
import { Directive, input, output, signal } from '@angular/core';
@Directive({
selector: '[appResaltar]',
host: {
'[style.background-color]': 'activo() ? color() : null',
'[attr.data-resaltado]': 'activo()',
'(mouseenter)': 'cambiar(true)',
'(mouseleave)': 'cambiar(false)',
},
})
export class Resaltar {
readonly color = input('khaki', { alias: 'appResaltar' });
readonly cambio = output<boolean>();
protected readonly activo = signal(false);
protected cambiar(valor: boolean): void {
this.activo.set(valor);
this.cambio.emit(valor);
}
}
TestBed.createDirective 🆕 v22.2
🆕 Desde Angular 22.2. No existe en la LTS (Angular 21). Si usas la LTS: crea un componente anfitrión con la directiva en su plantilla y búscala con
By.directive, como en el apartado siguiente; funciona igual en las dos versiones y es lo que se ha hecho siempre.
Hasta ahora, probar una directiva exigía escribir un componente de mentira. createDirective crea un elemento, le aplica la directiva y devuelve un DirectiveFixture:
// src/app/resaltar/resaltar.spec.ts
import { inputBinding, outputBinding, signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { Resaltar } from './resaltar';
describe('Resaltar (createDirective)', () => {
it('aplica el color por defecto al pasar el ratón', async () => {
const fixture = TestBed.createDirective(Resaltar, { tagName: 'div' });
await fixture.whenStable();
const elemento = fixture.nativeElement as HTMLElement;
expect(elemento.tagName).toBe('DIV');
expect(fixture.directiveInstance.color()).toBe('khaki');
elemento.dispatchEvent(new MouseEvent('mouseenter'));
await fixture.whenStable();
expect(elemento.style.backgroundColor).toBe('khaki');
expect(elemento.getAttribute('data-resaltado')).toBe('true');
});
it('admite otra etiqueta, entradas reactivas y salidas', async () => {
const color = signal('lightblue');
const cambio = vi.fn();
const fixture = TestBed.createDirective(Resaltar, {
tagName: 'button',
bindings: [
inputBinding('appResaltar', color),
outputBinding<boolean>('cambio', cambio),
],
});
await fixture.whenStable();
const boton = fixture.nativeElement as HTMLButtonElement;
expect(boton.tagName).toBe('BUTTON');
boton.dispatchEvent(new MouseEvent('mouseenter'));
await fixture.whenStable();
expect(boton.style.backgroundColor).toBe('lightblue');
expect(cambio).toHaveBeenCalledWith(true);
color.set('pink');
await fixture.whenStable();
expect(boton.style.backgroundColor).toBe('pink');
});
it('limpia al destruir', () => {
const fixture = TestBed.createDirective(Resaltar, { tagName: 'div' });
const limpieza = vi.fn();
fixture.onDestroy(limpieza);
fixture.destroy();
expect(limpieza).toHaveBeenCalledOnce();
});
});
Qué ofrece:
| Miembro | Uso |
|---|---|
TestBed.createDirective(Clase, opciones) | Crea el elemento y aplica la directiva |
Opción tagName | Etiqueta del elemento |
Opción bindings | Igual que en createComponent: inputBinding, outputBinding… |
fixture.directiveInstance | La instancia de la directiva |
fixture.nativeElement | El elemento, con tipo Element |
fixture.whenStable(), detectChanges(), destroy() | Compartidos con ComponentFixture |
fixture.onDestroy(fn) | Función a ejecutar al destruir |
⚠️ Importante: A diferencia de
createComponent, aquítagNamees obligatorio si el selector no incluye etiqueta. Con[appResaltar]y sin esa opción obtienes este error:Cannot determine tag name for _Resaltar, because one was not set in the options object and the selector for _Resaltar does not include a tag name. Specify which tag name should be used via the `tagName` key in the options parameter.Con un selector como
input[appRecortar], Angular deduce la etiqueta y no hace falta la opción. Lo comprobarás en el ejercicio 6.
Con un componente anfitrión
Sigue siendo la forma adecuada si quieres probar la directiva dentro de una plantilla real, con otras directivas alrededor. Es también la que debes usar en la LTS:
// src/app/resaltar/resaltar-anfitrion.spec.ts
import { Component, signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { By } from '@angular/platform-browser';
import { Resaltar } from './resaltar';
@Component({
imports: [Resaltar],
template: `<button [appResaltar]="color()" (cambio)="ultimo.set($event)">Prueba</button>`,
})
class Anfitrion {
readonly color = signal('lightblue');
readonly ultimo = signal<boolean | null>(null);
}
describe('Resaltar (componente anfitrión)', () => {
it('funciona dentro de una plantilla', async () => {
const fixture = TestBed.createComponent(Anfitrion);
await fixture.whenStable();
const depurable = fixture.debugElement.query(By.directive(Resaltar));
const boton = depurable.nativeElement as HTMLButtonElement;
expect(depurable.injector.get(Resaltar).color()).toBe('lightblue');
boton.dispatchEvent(new MouseEvent('mouseenter'));
await fixture.whenStable();
expect(boton.style.backgroundColor).toBe('lightblue');
expect(fixture.componentInstance.ultimo()).toBe(true);
fixture.componentInstance.color.set('pink');
await fixture.whenStable();
expect(boton.style.backgroundColor).toBe('pink');
});
});
depurable.injector.get(Resaltar) obtiene la instancia de la directiva aplicada a ese elemento.
12. El tiempo: fakeAsync y los temporizadores de Vitest
Este buscador espera 300 ms tras la última tecla. Una prueba que esperara de verdad sería lenta, y con muchas pruebas así la suite tardaría minutos:
// src/app/buscador/buscador.ts
import { Component, DestroyRef, inject, output, signal } from '@angular/core';
@Component({
selector: 'app-buscador',
template: `
<input type="search" (input)="escribir($any($event.target).value)" />
@if (aviso(); as texto) {
<p class="aviso">{{ texto }}</p>
}
`,
})
export class Buscador {
readonly buscar = output<string>();
protected readonly aviso = signal('');
private temporizador?: ReturnType<typeof setTimeout>;
constructor() {
inject(DestroyRef).onDestroy(() => clearTimeout(this.temporizador));
}
protected escribir(texto: string): void {
clearTimeout(this.temporizador);
this.temporizador = setTimeout(() => {
this.buscar.emit(texto);
this.aviso.set(`Buscando «${texto}»`);
}, 300);
}
}
La idea es sustituir el reloj: los temporizadores no avanzan solos, sino cuando tú lo ordenas.
Figura 4. fakeAsync depende de Zone.js. Los temporizadores de Vitest no.
Por qué fakeAsync no funciona en un proyecto zoneless
En @angular/core/testing siguen estando fakeAsync, tick, flush, flushMicrotasks y waitForAsync, pero funcionan gracias a una zona de Zone.js. Los tipos lo dejan claro: «This API requires Zone.js and cannot be used with the Vitest test runner». Lo que obtienes si los usas en un proyecto nuevo, en 22 y en 21, es esto:
zone-testing.js is needed for the fakeAsync() test helper but could not be found.
Antes (fakeAsync) | Ahora (Vitest) |
|---|---|
fakeAsync(() => { ... }) | async () => { ... } con vi.useFakeTimers() en beforeEach |
tick(300) | await vi.advanceTimersByTimeAsync(300) |
flushMicrotasks() | await vi.advanceTimersByTimeAsync(0) |
flush() | await vi.runAllTimersAsync() |
discardPeriodicTasks() | vi.clearAllTimers() |
| Final obligatorio de la prueba | vi.useRealTimers() en afterEach |
Los temporizadores de Vitest
// src/app/buscador/buscador.spec.ts
import type { Mock } from 'vitest';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { Buscador } from './buscador';
describe('Buscador con temporizadores falsos', () => {
let fixture: ComponentFixture<Buscador>;
let host: HTMLElement;
let buscar: Mock<(texto: string) => void>;
function escribir(texto: string) {
const caja = host.querySelector('input')!;
caja.value = texto;
caja.dispatchEvent(new Event('input'));
}
beforeEach(() => {
vi.useFakeTimers();
fixture = TestBed.createComponent(Buscador);
buscar = vi.fn<(texto: string) => void>();
fixture.componentInstance.buscar.subscribe(buscar);
TestBed.tick();
host = fixture.nativeElement;
});
afterEach(() => {
vi.useRealTimers();
});
it('no busca antes de 300 ms', async () => {
escribir('ana');
await vi.advanceTimersByTimeAsync(299);
expect(buscar).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(1);
expect(buscar).toHaveBeenCalledExactlyOnceWith('ana');
});
it('cada tecla reinicia la espera', async () => {
escribir('a');
await vi.advanceTimersByTimeAsync(200);
escribir('an');
await vi.advanceTimersByTimeAsync(200);
escribir('ana');
await vi.advanceTimersByTimeAsync(300);
expect(buscar).toHaveBeenCalledExactlyOnceWith('ana');
});
it('pinta el aviso: basta TestBed.tick', async () => {
escribir('ana');
await vi.advanceTimersByTimeAsync(300);
TestBed.tick();
expect(host.querySelector('.aviso')?.textContent).toBe('Buscando «ana»');
});
it('no queda ningún temporizador pendiente al destruir', () => {
escribir('ana');
expect(vi.getTimerCount()).toBeGreaterThan(0);
fixture.destroy();
TestBed.tick();
expect(buscar).not.toHaveBeenCalled();
});
});
Reglas de uso:
vi.useFakeTimers()antes de crear el componente, para que susetTimeoutsea el falso.vi.useRealTimers()enafterEach, siempre. Como los ficheros comparten entorno por defecto, un reloj falso olvidado cuelga pruebas que no tienen nada que ver.- Usa las versiones
Async.advanceTimersByTimeAsyncdeja correr las promesas entre un temporizador y otro.
⚠️ Importante: Con el reloj parado,
await fixture.whenStable()no vuelve. El planificador zoneless de Angular también usa temporizadores para programar su trabajo, y convi.useFakeTimers()nunca se disparan. La prueba acaba enTest timed out. SustituyewhenStableporTestBed.tick(), que es síncrono y no depende del reloj.
Si necesitas whenStable a toda costa, puedes dejar que el reloj falso avance solo:
it('whenStable funciona si el reloj falso avanza solo', async () => {
vi.useFakeTimers({ shouldAdvanceTime: true, advanceTimeDelta: 1 });
const fixture = TestBed.createComponent(Buscador);
const host: HTMLElement = fixture.nativeElement;
await fixture.whenStable();
const caja = host.querySelector('input')!;
caja.value = 'ana';
caja.dispatchEvent(new Event('input'));
await vi.advanceTimersByTimeAsync(300);
await fixture.whenStable();
expect(host.querySelector('.aviso')?.textContent).toBe('Buscando «ana»');
vi.useRealTimers();
});
Tiene un precio: el reloj ya no es exacto, porque sigue avanzando con el tiempo real.
La migración automática de fakeAsync 🆕 v22.0
🆕 Desde Angular 22.0. La opción
--fake-asyncno existe en la LTS (Angular 21): allí la orden respondeUnknown argument: fake-async. Si usas la LTS: ejecuta el esquema sin esa opción para convertir Jasmine, y cambiafakeAsynca mano con la tabla de equivalencias de esta sección.
El esquema refactor-jasmine-vitest convierte pruebas de Jasmine a Vitest. Con --fake-async, también cambia fakeAsync:
ng g @schematics/angular:refactor-jasmine-vitest --include=src/app/migrar --fake-async --report
Jasmine to Vitest Refactoring Summary:
- 1 test file(s) scanned.
- 1 file(s) transformed.
CREATE jasmine-vitest-20261102T060220318Z.md (350 bytes)
UPDATE src/app/migrar/buscador-jasmine.spec.ts (1544 bytes)
Le pasamos esta prueba antigua, que usa Jasmine y fakeAsync:
import { fakeAsync, flush, flushMicrotasks, TestBed, tick } from '@angular/core/testing';
import { Buscador } from '../buscador/buscador';
describe('Buscador (código antiguo)', () => {
it('busca a los 300 ms', fakeAsync(() => {
const fixture = TestBed.createComponent(Buscador);
const buscar = jasmine.createSpy('buscar');
fixture.componentInstance.buscar.subscribe(buscar);
fixture.detectChanges();
const caja: HTMLInputElement = fixture.nativeElement.querySelector('input');
caja.value = 'ana';
caja.dispatchEvent(new Event('input'));
tick(299);
expect(buscar).not.toHaveBeenCalled();
tick(1);
expect(buscar).toHaveBeenCalledOnceWith('ana');
}));
it('vacía los temporizadores con flush', fakeAsync(() => {
// ...misma preparación con 'luis'...
flushMicrotasks();
flush();
expect(buscar).toHaveBeenCalledTimes(1);
}));
});
Y este es el resultado real:
import { TestBed } from '@angular/core/testing';
import { Buscador } from '../buscador/buscador';
describe('Buscador (código antiguo)', () => {
beforeEach(() => {
vi.useFakeTimers({ advanceTimeDelta: 1, shouldAdvanceTime: true });
});
afterEach(() => {
vi.useRealTimers();
});
it('busca a los 300 ms', async () => {
const fixture = TestBed.createComponent(Buscador);
const buscar = vi.fn().mockName('buscar');
fixture.componentInstance.buscar.subscribe(buscar);
fixture.detectChanges();
const caja: HTMLInputElement = fixture.nativeElement.querySelector('input');
caja.value = 'ana';
caja.dispatchEvent(new Event('input'));
await vi.advanceTimersByTimeAsync(299);
expect(buscar).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(1);
expect(buscar).toHaveBeenCalledTimes(1);
expect(buscar).toHaveBeenCalledWith('ana');
});
it('vacía los temporizadores con flush', async () => {
// ...
await vi.advanceTimersByTimeAsync(0);
await vi.runAllTimersAsync();
expect(buscar).toHaveBeenCalledTimes(1);
});
});
Ha resuelto bien lo repetitivo, incluido toHaveBeenCalledOnceWith, que Vitest escribe en dos líneas. Con --report, además, deja un resumen en jasmine-vitest-<fecha>.md.
⚠️ Importante: Revisa el resultado. La migración añade
shouldAdvanceTime: truepara quewhenStablesiga funcionando. Pero eso hace avanzar el reloj con el tiempo real, y nuestra prueba de exactitud falló en la primera ejecución:expected "buscar" to not be called at all, but actually been called 1 times. Los 299 ms falsos más el tiempo real de ejecución superaban los 300 ms. Al cambiar la línea porvi.useFakeTimers();, pasó. En cada prueba que mida un plazo exacto, quita esa opción; si además usawhenStable, pasa aTestBed.tick().
Por eso conviene revisar cada fichero y hacer que la migración sea un punto de partida, no el final.
Mantener fakeAsync con Zone.js
La guía oficial indica que se puede seguir usando fakeAsync en Vitest añadiendo zone.js/plugins/vitest-patch a los polyfills del objetivo de pruebas, y recomienda planificar el paso a async nativo y temporizadores de Vitest.
Nosotros no hemos conseguido que funcione con zone.js 0.16.3 y Vitest 5.0.5. Con ['zone.js', 'zone.js/plugins/vitest-patch'] falló con Missing ProxyZoneSpec, y con solo ['zone.js'], con Expected to be running in 'ProxyZone', but it was not found. Como este paquete declara Vitest 4 en sus dependencias de desarrollo, quizá sea un problema de versiones, pero no lo hemos confirmado. Lo razonable es migrar.
13. Sustituir hijos y controlar @defer
// src/app/panel/panel.ts
import { Component } from '@angular/core';
@Component({
selector: 'app-grafica',
template: `<p class="grafica">Gráfica real</p>`,
})
export class Grafica {}
@Component({
selector: 'app-panel',
imports: [Grafica],
template: `
<h2>Panel</h2>
<app-grafica />
@defer (on timer(5s)) {
<p class="diferido">Contenido diferido</p>
} @placeholder {
<p class="reserva">Reservado</p>
} @loading {
<p class="carga">Cargando bloque</p>
}
`,
})
export class Panel {}
// src/app/panel/panel.spec.ts
import { Component } from '@angular/core';
import { DeferBlockBehavior, DeferBlockState, TestBed } from '@angular/core/testing';
import { Grafica, Panel } from './panel';
@Component({ selector: 'app-grafica', template: `<p class="falsa">Gráfica falsa</p>` })
class GraficaFalsa {}
describe('Panel', () => {
it('sustituye a un hijo pesado con overrideComponent', async () => {
TestBed.overrideComponent(Panel, {
remove: { imports: [Grafica] },
add: { imports: [GraficaFalsa] },
});
const fixture = TestBed.createComponent(Panel);
await fixture.whenStable();
const host: HTMLElement = fixture.nativeElement;
expect(host.querySelector('.falsa')).not.toBeNull();
expect(host.querySelector('.grafica')).toBeNull();
});
it('recorre los estados de @defer a mano', async () => {
TestBed.configureTestingModule({ deferBlockBehavior: DeferBlockBehavior.Manual });
const fixture = TestBed.createComponent(Panel);
await fixture.whenStable();
const host: HTMLElement = fixture.nativeElement;
const [bloque] = await fixture.getDeferBlocks();
expect(host.querySelector('.reserva')).not.toBeNull();
await bloque.render(DeferBlockState.Loading);
expect(host.querySelector('.carga')).not.toBeNull();
await bloque.render(DeferBlockState.Complete);
expect(host.querySelector('.diferido')?.textContent).toBe('Contenido diferido');
});
});
overrideComponentcambia los metadatos de un componente solo en esta prueba. Quita al hijo real y pone uno falso con el mismo selector. Sirve para hijos que necesitan mucha preparación o usan APIs que jsdom no tiene. No abuses: cada sustitución resta realismo.DeferBlockBehavior.Manualdeja el bloque en@placeholderhasta que lo ordenes. CongetDeferBlocks()obtienes los bloques y conrendereliges el estado, sin esperar los cinco segundos deltimer. Encontrarás los bloques en el módulo 23.
14. Pruebas de extremo a extremo con Playwright
Vitest no comprueba lo que jsdom no tiene: diseño, CSS real, medidas o la configuración completa de la aplicación. Playwright controla un navegador de verdad, como lo haría un usuario. Ya lo usamos en el módulo 25 para comprobar el modo sin conexión con context.setOffline(true).
Instalación
Playwright no forma parte de Angular, así que no se ve afectado por la versión:
npm install --save-dev @playwright/test
npx playwright install chromium
mkdir e2e
// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './e2e',
fullyParallel: true,
forbidOnly: !!process.env['CI'],
retries: process.env['CI'] ? 2 : 0,
reporter: [['list'], ['html', { open: 'never' }]],
use: {
baseURL: 'http://localhost:4200',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
},
projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }],
webServer: {
command: 'npm start',
url: 'http://localhost:4200',
timeout: 150_000,
reuseExistingServer: !process.env['CI'],
},
});
webServer hace que Playwright arranque ng serve, espere a que responda y lo pare al final. Con reuseExistingServer, usa el servidor que ya tengas abierto.
Los ficheros de e2e/ están fuera de src, así que ng test no los recoge.
Las pruebas
Antes de ejecutarlas arranca la API EMPRESA (node server.mjs), porque aquí las peticiones son reales.
// e2e/empleados.spec.ts
import { expect, test } from '@playwright/test';
test.describe('Empleados', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/empleados');
});
test('muestra la lista que devuelve la API', async ({ page }) => {
await expect(page.getByRole('heading', { name: 'Empleados' })).toBeVisible();
await expect(page.getByRole('heading', { name: 'Ana García López' })).toBeVisible();
await expect(page.locator('app-tarjeta-empleado')).not.toHaveCount(0);
});
test('filtra por departamento', async ({ page }) => {
await expect(page.getByRole('heading', { name: 'Ana García López' })).toBeVisible();
await page.getByLabel('Departamento').selectOption({ label: 'Contabilidad' });
await expect(page.getByRole('heading', { name: 'Ana García López' })).toBeHidden();
await expect(page.locator('app-tarjeta-empleado')).not.toHaveCount(0);
});
test('cuenta los empleados seleccionados', async ({ page }) => {
const tarjeta = page.locator('app-tarjeta-empleado').filter({ hasText: 'Ana García López' });
await tarjeta.getByRole('button', { name: 'Ver ficha' }).click();
await expect(page.getByText('Seleccionados: 1')).toBeVisible();
});
test('muestra el error si la API falla', async ({ page }) => {
await page.route('**/api/empleados*', (ruta) =>
ruta.fulfill({ status: 500, json: { status: 500, mensaje: 'Error simulado' } }),
);
await page.reload();
await expect(page.getByRole('alert')).toHaveText('No se pudo cargar la lista');
});
test('navega a la ficha por URL y vuelve', async ({ page }) => {
await page.goto('/empleados/100');
await expect(page.getByRole('heading', { name: 'Ana García López' })).toBeVisible();
await page.getByRole('link', { name: 'Volver' }).click();
await expect(page).toHaveURL(/\/empleados$/);
});
});

Figura 5. La aplicación contra la que se ejecutan las pruebas E2E, con datos reales de la API.
Fíjate en cómo buscan los elementos: por papel y por nombre, igual que un lector de pantalla (getByRole, getByLabel). Eso hace que resistan cambios de HTML y, de paso, que premien la accesibilidad del módulo 21.
expect espera aquí de forma automática. toBeVisible reintenta hasta que el elemento aparece o se agota el tiempo, así que no necesitas sleep. Con page.route interceptas una petición en el navegador para provocar un error difícil de reproducir con la API real.
Órdenes
npx playwright test # todas, sin ventana
npx playwright test --headed # viendo el navegador
npx playwright test --ui # modo interactivo
npx playwright test --workers=2 # limitar procesos en paralelo
npx playwright show-report # abre el informe HTML
Un error real que solo vio el E2E
Al escribir este módulo, las pruebas de Vitest de DetalleEmpleado y de las rutas pasaban todas. Al lanzar Playwright por primera vez, cayó una:

Figura 6. El informe HTML de Playwright, con la prueba que detectó el fallo.
Error: expect(locator).toBeVisible() failed
Locator: getByRole('heading', { name: 'Ana García López' })
Expected: visible
Error: element(s) not found
El error estaba en app.config.ts, que seguía tal como lo deja ng new:
provideRouter(routes) // falta withComponentInputBinding()
DetalleEmpleado recibe id con un input.required, y eso exige withComponentInputBinding(). La prueba del Router pasaba porque su propio provideRouter sí lo incluía. Al añadirlo a app.config.ts, la prueba E2E pasó:
provideRouter(routes, withComponentInputBinding())
Esto resume bien por qué hace falta la cima de la pirámide: una prueba unitaria ve lo que tú le montas, y solo una E2E ve lo que montas de verdad en producción.
Tras la corrección, Playwright arrancó el servidor por su cuenta con webServer y las cinco pruebas pasaron:
Running 5 tests using 2 workers
ok 2 [chromium] › e2e\empleados.spec.ts:8:7 › Empleados › muestra la lista que devuelve la API (5.9s)
ok 1 [chromium] › e2e\empleados.spec.ts:14:7 › Empleados › filtra por departamento (6.1s)
ok 3 [chromium] › e2e\empleados.spec.ts:23:7 › Empleados › cuenta los empleados seleccionados (2.2s)
ok 4 [chromium] › e2e\empleados.spec.ts:31:7 › Empleados › muestra el error si la API falla (5.7s)
ok 5 [chromium] › e2e\empleados.spec.ts:41:7 › Empleados › navega a la ficha por URL y vuelve (4.8s)
5 passed (1.9m)

Figura 7. El mismo informe tras corregir app.config.ts.
💡 Consejo: Una prueba E2E que modifica datos debe dejar la API como estaba, o lanzarse contra datos que se reinicien, porque las pruebas pueden ejecutarse en cualquier orden. Reinicia el servidor de EMPRESA entre ejecuciones si es necesario.
Modo navegador de Vitest
Hay un punto intermedio: ejecutar Vitest en un navegador real, para que tus pruebas de TestBed vean CSS y diseño. Según la documentación se instala un proveedor y se elige con --browsers. Este curso sigue con jsdom, y no hemos ejecutado esta variante:
npm install --save-dev @vitest/browser-playwright playwright
ng test --browsers=chromiumHeadless
15. Cobertura
La cobertura mide qué parte del código se ha ejecutado durante las pruebas. Se pide con --coverage, pero necesita un paquete aparte:
npm install --save-dev @vitest/coverage-v8
ng test --watch=false --coverage --coverage-reporters=text --coverage-reporters=html
Sin ese paquete, ng test se detiene y te indica que lo instales. Vitest también puede quejarse con Cannot find package '@vitest/coverage-v8' si lo encuentra Angular pero no el propio Vitest, algo que pasa cuando el paquete está en otra carpeta.
Instala la versión que corresponda a tu Vitest (@vitest/coverage-v8@5 o @4). Esta es la salida real de todo el proyecto del módulo, con los ejercicios incluidos:
Test Files 18 passed (18)
Tests 83 passed (83)
% Coverage report from v8
-------------------|---------|----------|---------|---------|-------------------
File | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s
-------------------|---------|----------|---------|---------|-------------------
All files | 99.21 | 97.16 | 97.5 | 98.99 |
app/ejercicios | 100 | 97.87 | 100 | 100 |
ej.ts | 100 | 97.87 | 100 | 100 | 64
...m-departamento | 100 | 92.85 | 100 | 100 |
...partamento.ts | 100 | 92.85 | 100 | 100 | 32
...ista-empleados | 100 | 93.75 | 100 | 100 |
...-empleados.ts | 100 | 93.75 | 100 | 100 | 52
app/panel | 100 | 90 | 100 | 100 |
panel.ts | 100 | 90 | 100 | 100 | 24
app/resaltar | 92.3 | 100 | 100 | 100 |
resaltar.ts | 92.3 | 100 | 100 | 100 |
app/servicios | 95.34 | 96.42 | 90 | 93.33 |
seleccion.ts | 93.33 | 100 | 87.5 | 90 | 23
tema.ts | 90.9 | 85.71 | 80 | 87.5 | 4
-------------------|---------|----------|---------|---------|-------------------
La columna de la derecha señala lo que falta. La línea 23 de seleccion.ts es el método vaciar, que ninguna prueba llama. La línea 4 de tema.ts es la fábrica del token: todas las pruebas usan el almacén falso.
| Métrica | Qué mide |
|---|---|
| Statements | Sentencias ejecutadas |
| Branches | Ramas tomadas: las dos salidas de cada if, ?:, && |
| Functions | Funciones llamadas |
| Lines | Líneas ejecutadas |
El informe html queda en coverage/<proyecto>/index.html y permite ver cada fichero con las líneas marcadas:

Figura 8. Informe HTML de cobertura del proyecto del módulo.
Exigir un mínimo
En angular.json puedes fijar umbrales. Si no se alcanzan, ng test termina con error y el CI se detiene:
"test": {
"builder": "@angular/build:unit-test",
"options": {
"coverageThresholds": {
"statements": 95,
"branches": 90,
"functions": 95,
"lines": 95
}
}
}
Para ajustar qué se mide tienes coverageInclude y coverageExclude.
⚠️ Importante: Una cobertura del 100 % no significa que el código funcione: dice qué se ejecutó, no qué se comprobó. Una prueba sin
expecttambién suma. Úsala para localizar lo que has olvidado probar, no como objetivo. Perseguir el último punto suele producir pruebas que solo repiten el código.
Elegir Istanbul como proveedor 🆕 v22.0
🆕 Desde Angular 22.0.
ng test --coverageadmite también@vitest/coverage-istanbul. En la LTS (Angular 21) solo se comprueba@vitest/coverage-v8. Si usas la LTS: instala@vitest/coverage-v8, que es el que usa este módulo.
En la 22 el constructor acepta cualquiera de los dos paquetes. Si no tienes ninguna preferencia, usa v8.
16. Depuración y errores frecuentes
| Síntoma | Causa probable | Solución |
|---|---|---|
Test timed out in 5000ms con whenStable | Hay una petición sin responder | TestBed.tick(), flush y después whenStable |
Test timed out con vi.useFakeTimers() | El reloj está parado y Angular no planifica | TestBed.tick() en lugar de whenStable |
Expected one matching request ... found none | URL distinta o petición aún sin lanzar | Revisa la URL con parámetros y llama a TestBed.tick() |
Expected no open requests, found 1 | Petición sin atender en verify | Responde con flush o revisa por qué se ha lanzado |
| La vista no cambia y no hay error | Propiedad normal en un componente OnPush | Convierte la propiedad en señal |
NG0100 | Cambio no notificado a Angular | Igual: señales y whenStable |
NG0912: Component ID generation collision | Dos componentes de prueba sin selector y con plantilla igual | Es un aviso; dales un selector distinto si te molesta |
zone-testing.js is needed for the fakeAsync() | fakeAsync en un proyecto sin Zone.js | Temporizadores de Vitest |
Cannot determine tag name | createDirective sin tagName | Añade tagName o usa un selector con etiqueta |
Promise returned by ... was not awaited | Falta un await (Vitest 5) | await expect(...).rejects... |
Timeout waiting for worker to respond | Máquina saturada | --include, y pool: 'threads' y maxWorkers en un fichero de Vitest |
Worker exited unexpectedly | Lo mismo | Igual |
Cannot find package '@vitest/coverage-v8' | Falta el proveedor de cobertura | Instálalo en la versión de tu Vitest |
Un value escrito no llega al formulario | No has disparado input | dispatchEvent(new Event('input')) |
| Una prueba pasa sola y falla con las demás | Estado compartido o reloj falso sin restaurar | vi.useRealTimers() en afterEach |
Cómo depurar una prueba concreta:
- Aíslala.
it.only('...')ejecuta solo esa prueba, yit.skipla omite. Usa--includepara compilar menos. No confirmes nunca un.only: en Playwright,forbidOnlylo detecta en CI. - Mira el DOM.
console.log(host.innerHTML)enseña lo que hay de verdad. - Pregúntate quién actualiza la vista. Casi todos los problemas son de orden: se comprueba antes de sincronizar.
- En Playwright, abre el informe.
npx playwright show-reportmuestra capturas y, con--trace on, una traza paso a paso.
17. Ejercicios
En los ejercicios, @Service() requiere Angular 22. En la LTS sustitúyelo por @Injectable({ providedIn: 'root' }).
Ejercicio 1: Correo corporativo
Escribe esEmailCorporativo(email), que devuelve true si el correo es de @empresa.es, sin distinguir mayúsculas e ignorando espacios externos. Pruébala con it.each y con casos límite.
Ver solución
export function esEmailCorporativo(email: string): boolean {
return /^[a-z0-9.]+@empresa\.es$/i.test(email.trim());
}
describe('esEmailCorporativo', () => {
it.each([
['ana.garcia@empresa.es', true],
[' ANA@EMPRESA.ES ', true],
['ana@gmail.com', false],
['ana@empresa.es.com', false],
['@empresa.es', false],
['', false],
])('%j da %j', (email, esperado) => {
expect(esEmailCorporativo(email)).toBe(esperado);
});
});
El caso ana@empresa.es.com justifica el $ de la expresión: sin él, el dominio falso se daría por bueno. %j imprime el valor en JSON, y así el título se lee bien incluso con la cadena vacía.
Ejercicio 2: Servicio de notificaciones
Crea un servicio Notificaciones con señales: lista, un computed pendientes, y los métodos anadir(texto) y marcarLeida(id). Comprueba que marcar una no modifica las demás.
Ver solución
import { computed, Service, signal } from '@angular/core';
export interface Notificacion {
id: number;
texto: string;
leida: boolean;
}
@Service()
export class Notificaciones {
private siguienteId = 1;
private readonly _lista = signal<Notificacion[]>([]);
readonly lista = this._lista.asReadonly();
readonly pendientes = computed(() => this._lista().filter((n) => !n.leida).length);
anadir(texto: string): void {
this._lista.update((l) => [...l, { id: this.siguienteId++, texto, leida: false }]);
}
marcarLeida(id: number): void {
this._lista.update((l) => l.map((n) => (n.id === id ? { ...n, leida: true } : n)));
}
}
import { TestBed } from '@angular/core/testing';
describe('Notificaciones', () => {
let servicio: Notificaciones;
beforeEach(() => {
servicio = TestBed.inject(Notificaciones);
});
it('cuenta las pendientes', () => {
servicio.anadir('Nómina lista');
servicio.anadir('Reunión a las 10');
expect(servicio.pendientes()).toBe(2);
});
it('marca una como leída sin tocar las demás', () => {
servicio.anadir('Nómina lista');
servicio.anadir('Reunión a las 10');
const [primera, segunda] = servicio.lista();
servicio.marcarLeida(primera.id);
expect(servicio.pendientes()).toBe(1);
expect(servicio.lista()[0].leida).toBe(true);
expect(servicio.lista()[1]).toBe(segunda);
});
});
toBe(segunda) compara la identidad del objeto: demuestra que no se ha creado de nuevo, algo importante para que @for no repinte de más.
Ejercicio 3: Paginador
Crea Paginador, con pagina = model(1) y paginas = input.required<number>(), que muestre «Página X de Y» y dos botones, desactivados en los extremos. Pruébalo con twoWayBinding.
Ver solución
import { Component, input, model } from '@angular/core';
@Component({
selector: 'app-paginador',
template: `
<button type="button" class="anterior" [disabled]="pagina() === 1" (click)="pagina.set(pagina() - 1)">
Anterior
</button>
<span class="estado">Página {{ pagina() }} de {{ paginas() }}</span>
<button type="button" class="siguiente" [disabled]="pagina() === paginas()" (click)="pagina.set(pagina() + 1)">
Siguiente
</button>
`,
})
export class Paginador {
readonly pagina = model(1);
readonly paginas = input.required<number>();
}
import { inputBinding, signal, twoWayBinding } from '@angular/core';
import { TestBed } from '@angular/core/testing';
it('avanza, retrocede y desactiva los extremos', async () => {
const pagina = signal(1);
const paginas = signal(2);
const fixture = TestBed.createComponent(Paginador, {
bindings: [twoWayBinding('pagina', pagina), inputBinding('paginas', paginas)],
});
await fixture.whenStable();
const host: HTMLElement = fixture.nativeElement;
const anterior = host.querySelector<HTMLButtonElement>('.anterior')!;
const siguiente = host.querySelector<HTMLButtonElement>('.siguiente')!;
expect(anterior.disabled).toBe(true);
expect(host.querySelector('.estado')?.textContent).toBe('Página 1 de 2');
siguiente.click();
await fixture.whenStable();
expect(pagina()).toBe(2);
expect(siguiente.disabled).toBe(true);
expect(anterior.disabled).toBe(false);
anterior.click();
expect(pagina()).toBe(1);
});
Ejercicio 4: Borrar un departamento
Crea DepartamentosApi.borrar(id) con DELETE. Si la API responde 409, porque el departamento tiene empleados, convierte el error en new Error('El departamento tiene empleados'). Prueba los dos caminos.
Ver solución
import { HttpClient, HttpErrorResponse } from '@angular/common/http';
import { inject, Service } from '@angular/core';
import { catchError, Observable, throwError } from 'rxjs';
import { API } from '../api';
@Service()
export class DepartamentosApi {
private readonly http = inject(HttpClient);
borrar(id: number): Observable<void> {
return this.http.delete<void>(`${API}/departamentos/${id}`).pipe(
catchError((error: HttpErrorResponse) =>
throwError(() =>
error.status === 409
? new Error('El departamento tiene empleados')
: error,
),
),
);
}
}
describe('DepartamentosApi', () => {
let api: DepartamentosApi;
let http: HttpTestingController;
beforeEach(() => {
TestBed.configureTestingModule({
providers: [provideHttpClient(), provideHttpClientTesting()],
});
api = TestBed.inject(DepartamentosApi);
http = TestBed.inject(HttpTestingController);
});
afterEach(() => http.verify());
it('usa DELETE', async () => {
const resultado = firstValueFrom(api.borrar(70));
const peticion = http.expectOne({ method: 'DELETE', url: `${API}/departamentos/70` });
peticion.flush(null, { status: 204, statusText: 'No Content' });
await expect(resultado).resolves.toBeNull();
});
it('traduce el 409', async () => {
const resultado = firstValueFrom(api.borrar(10));
http.expectOne(`${API}/departamentos/10`).flush(
{ status: 409, mensaje: 'Tiene empleados' },
{ status: 409, statusText: 'Conflict' },
);
await expect(resultado).rejects.toThrow('El departamento tiene empleados');
});
});
Ejercicio 5: Interceptor de autorización
Escribe un interceptor funcional autorizacion que añada Authorization: Bearer <token> solo si hay token y la URL empieza por API. Pruébalo con withInterceptors, comprobando también que el token no sale hacia otro dominio.
Ver solución
import { HttpInterceptorFn } from '@angular/common/http';
import { API } from '../api';
export const TOKEN = { valor: '' };
export const autorizacion: HttpInterceptorFn = (peticion, siguiente) => {
if (!TOKEN.valor || !peticion.url.startsWith(API)) {
return siguiente(peticion);
}
return siguiente(
peticion.clone({ setHeaders: { Authorization: `Bearer ${TOKEN.valor}` } }),
);
};
import { HttpClient, provideHttpClient, withInterceptors } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { TestBed } from '@angular/core/testing';
import { API } from '../api';
describe('interceptor autorizacion', () => {
let cliente: HttpClient;
let http: HttpTestingController;
beforeEach(() => {
TOKEN.valor = '';
TestBed.configureTestingModule({
providers: [
provideHttpClient(withInterceptors([autorizacion])),
provideHttpClientTesting(),
],
});
cliente = TestBed.inject(HttpClient);
http = TestBed.inject(HttpTestingController);
});
afterEach(() => http.verify());
it('añade la cabecera si hay token', () => {
TOKEN.valor = 'abc';
cliente.get(`${API}/empleados`).subscribe();
const peticion = http.expectOne(`${API}/empleados`);
expect(peticion.request.headers.get('Authorization')).toBe('Bearer abc');
peticion.flush([]);
});
it('no añade nada sin token', () => {
cliente.get(`${API}/empleados`).subscribe();
const peticion = http.expectOne(`${API}/empleados`);
expect(peticion.request.headers.has('Authorization')).toBe(false);
peticion.flush([]);
});
it('no envía el token a otros dominios', () => {
TOKEN.valor = 'abc';
cliente.get('https://otro.example/datos').subscribe();
const peticion = http.expectOne('https://otro.example/datos');
expect(peticion.request.headers.has('Authorization')).toBe(false);
peticion.flush({});
});
});
Los interceptores se ejecutan en la prueba porque el backend falso está al final de la cadena. El objeto TOKEN es global de este ejercicio, y por eso se reinicia en beforeEach: los ficheros y pruebas no deben heredar estado.
Ejercicio 6: Directiva que recorta espacios
Crea la directiva input[appRecortar], que al perder el foco quita los espacios externos del campo. Pruébala con createDirective sin indicar tagName, y también con un componente anfitrión.
Ver solución
import { Directive, ElementRef, inject } from '@angular/core';
@Directive({
selector: 'input[appRecortar]',
host: { '(blur)': 'recortar()' },
})
export class Recortar {
private readonly campo = inject<ElementRef<HTMLInputElement>>(ElementRef);
protected recortar(): void {
this.campo.nativeElement.value = this.campo.nativeElement.value.trim();
}
}
import { Component } from '@angular/core';
import { TestBed } from '@angular/core/testing';
describe('Recortar', () => {
// Angular 22.2 o posterior
it('quita los espacios al salir (createDirective)', async () => {
const fixture = TestBed.createDirective(Recortar);
await fixture.whenStable();
const campo = fixture.nativeElement as HTMLInputElement;
expect(campo.tagName).toBe('INPUT');
campo.value = ' Ana ';
campo.dispatchEvent(new Event('blur'));
expect(campo.value).toBe('Ana');
});
// Válida también en la LTS
it('funciona con un anfitrión (LTS)', async () => {
@Component({ imports: [Recortar], template: `<input appRecortar />` })
class Anfitrion {}
const fixture = TestBed.createComponent(Anfitrion);
await fixture.whenStable();
const campo: HTMLInputElement = fixture.nativeElement.querySelector('input');
campo.value = ' Ana ';
campo.dispatchEvent(new Event('blur'));
expect(campo.value).toBe('Ana');
});
});
Como el selector empieza por input, createDirective deduce la etiqueta y no necesita tagName.
Ejercicio 7: Aviso que se cierra solo
Crea Aviso, con una entrada texto y una salida cerrado, que desaparece a los 3 segundos y cancela su temporizador si se destruye antes. Pruébalo sin esperar tres segundos reales.
Ver solución
import { Component, DestroyRef, inject, input, output, signal } from '@angular/core';
@Component({
selector: 'app-aviso',
template: `
@if (visible()) {
<p role="status">{{ texto() }}</p>
}
`,
})
export class Aviso {
readonly texto = input.required<string>();
readonly cerrado = output<void>();
protected readonly visible = signal(true);
constructor() {
const temporizador = setTimeout(() => {
this.visible.set(false);
this.cerrado.emit();
}, 3000);
inject(DestroyRef).onDestroy(() => clearTimeout(temporizador));
}
}
import { inputBinding, signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
describe('Aviso', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
function crear() {
const fixture = TestBed.createComponent(Aviso, {
bindings: [inputBinding('texto', signal('Guardado'))],
});
TestBed.tick();
return fixture;
}
it('se cierra a los 3 segundos', async () => {
const fixture = crear();
const cerrado = vi.fn();
fixture.componentInstance.cerrado.subscribe(cerrado);
const host: HTMLElement = fixture.nativeElement;
expect(host.querySelector('[role=status]')?.textContent).toBe('Guardado');
await vi.advanceTimersByTimeAsync(2999);
TestBed.tick();
expect(host.querySelector('[role=status]')).not.toBeNull();
await vi.advanceTimersByTimeAsync(1);
TestBed.tick();
expect(host.querySelector('[role=status]')).toBeNull();
expect(cerrado).toHaveBeenCalledOnce();
});
it('cancela el temporizador al destruirse', async () => {
const fixture = crear();
const cerrado = vi.fn();
fixture.componentInstance.cerrado.subscribe(cerrado);
fixture.destroy();
await vi.advanceTimersByTimeAsync(5000);
expect(cerrado).not.toHaveBeenCalled();
});
});
Se usa TestBed.tick() en lugar de whenStable porque el reloj está parado, y se prueba justo a 2999 y 3000 ms para fijar el plazo exacto.
Ejercicio 8 (propuesto): Un resolver con RouterTestingHarness
Crea un resolver que cargue un empleado antes de entrar en la ruta. Prueba la navegación, simulando la respuesta con HttpTestingController. Prueba también qué ocurre si la API responde 404.
Ejercicio 9 (propuesto): Cubre lo que falta
Lanza ng test --coverage en tu proyecto. Elige dos líneas sin cubrir y escribe la prueba que las ejecuta, con su expect. Añade un umbral en angular.json que tu proyecto cumpla, súbelo por encima de lo que tienes y observa cómo falla.
Ejercicio 10 (propuesto): Flujo E2E con escritura
Con Playwright, inicia sesión en la API desde la interfaz, crea un departamento y bórralo. La prueba debe dejar los datos como estaban. Intercepta con page.route una versión en la que la API responde 422 y comprueba que se ve el mensaje de error.
Ejercicio 11 (propuesto): Migra un fichero antiguo
Escribe una prueba de Jasmine con fakeAsync y tick para el Aviso del ejercicio 7. En Angular 22, pásale refactor-jasmine-vitest --fake-async, ejecuta el resultado y explica qué has tenido que cambiar a mano. En la LTS, haz la conversión con la tabla de equivalencias.
Resumen
| Concepto | API | Idea clave |
|---|---|---|
| Ejecutar pruebas | ng test, --include, --filter, --runner-config | Vitest sobre jsdom, tras compilar con Angular |
| Vitest 5 | clearMocks: true, await obligatorio | Espías limpios y sin promesas olvidadas. La LTS usa Vitest 4 |
| Comprobaciones | expect, it.each, vi.fn, vi.spyOn | Prueba el comportamiento visible |
| Montar la aplicación | TestBed, configureTestingModule, inject | Una aplicación nueva en cada prueba |
| Dobles | { provide, useValue }, overrideComponent | Aprovecha la inyección de dependencias |
| Componentes | createComponent, setInput, bindings | Actúa sobre el DOM y compruébalo |
| Fixture reciente | TestBed.getLastFixture 🆕 v22.0 | Útil en funciones de ayuda |
| Sincronizar | await fixture.whenStable(), TestBed.tick() | whenStable por defecto; tick es síncrono |
| OnPush por defecto | 🔄 v22.0 | Una propiedad normal deja la vista antigua: usa señales |
| Efectos | TestBed.tick() | Un effect no corre al cambiar la señal, sino al sincronizar |
| HTTP | provideHttpClientTesting, HttpTestingController | expectOne, flush, verify |
httpResource | tick, flush, whenStable | Con whenStable delante del flush hay interbloqueo |
| Router | RouterTestingHarness, runInInjectionContext | Navegación real, sin navegador |
| Formularios | dispatchEvent(new Event('input')) | Asignar value no avisa a Angular |
| Directivas | createDirective 🆕 v22.2, By.directive | tagName obligatorio si el selector no tiene etiqueta |
| Tiempo | vi.useFakeTimers, advanceTimersByTimeAsync | Sin whenStable con el reloj parado |
| Migración | refactor-jasmine-vitest --fake-async 🆕 v22.0 | Revisa shouldAdvanceTime en plazos exactos |
@defer | DeferBlockBehavior.Manual, getDeferBlocks | Elige el estado sin esperar |
| E2E | Playwright, getByRole, page.route, webServer | Lo que no ve TestBed: tu configuración real |
| Cobertura | --coverage, coverageThresholds | Dice qué se ejecutó, no qué se comprobó |