Ir al contenido

Pruebas del front con Vitest

Vitest es el ejecutor de pruebas que usa Angular, y la herramienta obligatoria de los niveles de servicio, componente e integración que describe Pruebas del front: qué probar y con qué. No hay que instalar nada para empezar: el proyecto ya lo trae, y las pruebas viven en archivos *.spec.ts junto al código que prueban. Las pruebas corren en un navegador simulado (jsdom), sin abrir un navegador de verdad, y por eso son rápidas.

Ventana de terminal
ng test # corre las pruebas
ng test --watch=false # las corre una sola vez y termina
pnpm test # lo mismo, a través de pnpm (así las ejecuta el pipeline)

Antes de escribir una sola prueba hay que decidir qué se va a probar, con qué escenarios y qué errores se espera encontrar. Si no, se terminan escribiendo pruebas que pasan y no protegen nada.

En el Release 8 el front tiene cuatro piezas que se comunican con el back. Cada una se prueba de una forma distinta y busca un tipo de error distinto:

Pieza Ejemplo Escenario positivo Escenario negativo Qué esperamos encontrar
Servicio asociarAlbum('1', 2) Hace un POST a /artistas/1/albumes/2, sin cuerpo El back responde 400 y el error llega con su mensaje URL, verbo o cuerpo equivocados
Ruta y detalle /artistas/1 Pide GET /artistas/1 y pinta el nombre; al pasar a /artistas/2 pide el nuevo Con otro artista elegido antes, no se ve mientras carga el de la URL; 404 muestra “no existe”; 500 muestra un error sin romper la vista id mal leído, petición que no se repite, datos del artista anterior, vista rota en estado de error
Componente de asociación Elegir un álbum y asociarlo Carga las opciones, envía el POST correcto y navega al detalle El 400 “ya está asociado” se muestra; sin álbumes aparece un aviso URL incorrecta, mensaje del back que no se muestra, falta de navegación
Contrato Respuesta real de GET /albumes/1 Cabe en los tipos TypeScript del front Falta un campo que el front espera Desajuste entre el back y los modelos

Una forma de no olvidar ninguno es cruzar lo que el back puede responder con cada pieza. Cada celda es una prueba posible:

El back responde… Servicio Detalle por ruta Componente de asociación
Éxito (200) Entrega el resultado Pinta el recurso Navega al detalle
Regla violada (400) Entrega el mensaje (no aplica) Muestra el mensaje y no navega
No existe (404) Entrega el error Muestra “no existe” (no aplica)
Falla (500) o no responde (status 0) Entrega el error Muestra un mensaje sin romper la vista Muestra un mensaje general
Lista vacía (no aplica) (no aplica) Avisa que no hay álbumes

No hace falta llenar todas las celdas: se escogen las que corresponden a un error que importa. Esa es la estrategia mínima del Release 8: una prueba de servicio, una de ruta, dos del componente de asociación (éxito y error) y una de contrato por módulo.

Al escribir estas pruebas contra el front de ejemplo del curso aparecieron cinco errores reales, y los cinco son del tipo que se espera encontrar:

  • Un nombre que no coincide. El componente de asociación enviaba POST /artistas/undefined/albumes/2: su input se llamaba artistaId y el parámetro de la ruta es :id, y Angular los conecta por nombre.
  • Una vista que se rompe cuando el back falla. Leer value() de un recurso en estado de error lanza una excepción, y el mensaje de error nunca se mostraba.
  • Un modelo que no coincide con el back. El modelo Cancion del front exigía genero, letra y fechaLanzamiento, que el back no manda.
  • Datos que no corresponden a la URL. Al llegar por la ruta a un artista distinto del elegido antes en la lista, la pantalla seguía mostrando al artista anterior (y la lista de álbumes del anterior) hasta que llegaba la respuesta.
  • Una petición que nadie esperaba. Abrir el detalle de un artista también pedía la lista completa de artistas, porque el servicio que lo usa la carga al crearse.
describe('ArtistaService', () => { // un grupo de pruebas
beforeEach(() => { /* preparar */ }); // antes de cada prueba
it('hace un POST sin cuerpo', () => { // una prueba: un comportamiento
// 1. Preparar: crear lo que la prueba necesita
// 2. Ejecutar: llamar a lo que se prueba
// 3. Verificar: expect(resultado).toBe(esperado)
});
});
  • describe agrupa las pruebas de una pieza, e it es una prueba; su nombre dice el comportamiento que verifica.
  • expect(valor).matcher(esperado) compara: toBe, toEqual, toContain, toBeNull.
  • beforeEach y afterEach corren antes y después de cada prueba, y dejan a cada una en un estado limpio e independiente.
  • TestBed arma un módulo de Angular solo para la prueba. Se le dicen los providers que hacen falta, como en app.config.ts.

Las pruebas no llaman al back real. Angular ofrece un back falso, provideHttpClientTesting(), que intercepta las peticiones y deja que la prueba las responda:

TestBed.configureTestingModule({
providers: [provideHttpClient(), provideHttpClientTesting()],
});
const http = TestBed.inject(HttpTestingController);

Con él, una prueba hace tres cosas:

  • http.expectOne(url) comprueba que salió exactamente una petición a esa URL, y la entrega. Si no salió ninguna, la prueba falla. Con ella se verifica el verbo (req.request.method) y el cuerpo (req.request.body).
  • req.flush(cuerpo, opciones) responde la petición. Sin opciones es un 200; con { status: 400, statusText: '...' } es un error.
  • http.verify(), en afterEach, falla si quedó alguna petición sin responder o sin esperar.

El servicio es lo más fácil de probar: sin plantilla ni rutas. Este es el ArtistaService con dos pruebas, una por escenario:

artist.service.spec.ts
describe('ArtistaService', () => {
let service: ArtistaService;
let http: HttpTestingController;
beforeEach(() => {
TestBed.configureTestingModule({ providers: [provideHttpClient(), provideHttpClientTesting()] });
service = TestBed.inject(ArtistaService);
http = TestBed.inject(HttpTestingController);
});
afterEach(() => http.verify());
it('asociarAlbum hace un POST sin cuerpo a /artistas/1/albumes/2', () => {
service.asociarAlbum('1', 2).subscribe();
const req = http.expectOne(`${environment.ARTISTAS_URL}/1/albumes/2`);
expect(req.request.method).toBe('POST');
expect(req.request.body).toBeNull();
req.flush({});
});
it('asociarAlbum entrega el mensaje del back cuando la asociación ya existe', () => {
let mensaje: string | undefined;
service.asociarAlbum('1', 2).subscribe({
error: (error: HttpErrorResponse): void => {
mensaje = error.error.message;
},
});
http
.expectOne(`${environment.ARTISTAS_URL}/1/albumes/2`)
.flush(
{ status: 400, message: 'El álbum ya está asociado al artista' },
{ status: 400, statusText: 'Bad Request' },
);
expect(mensaje).toBe('El álbum ya está asociado al artista');
});
});

Recuerda que http.post(...) es un Observable frío: la petición solo sale al llamar .subscribe(). Sin él, expectOne no encuentra nada.

Un componente se prueba dentro de una ruta con RouterTestingHarness, que monta la aplicación mínima con un <router-outlet>. Estas son las pruebas del detalle por ruta del tema de Enrutamiento y Angular Router:

detalle-artista.spec.ts
beforeEach(async () => {
TestBed.configureTestingModule({
providers: [
provideHttpClient(),
provideHttpClientTesting(),
provideRouter([{ path: 'artistas/:id', component: ArtistDetalle }], withComponentInputBinding()),
],
});
http = TestBed.inject(HttpTestingController);
harness = await RouterTestingHarness.create();
});
it('pide el artista de la URL y pinta su nombre', async () => {
await ir('/artistas/1');
await responder(`${environment.ARTISTAS_URL}/1`, { id: 1, nombre: 'Marc Anthony', albumes: [] });
expect(texto()).toContain('Marc Anthony');
});
it('no muestra el artista seleccionado antes mientras carga el de la URL', async () => {
// El usuario había elegido a Marc Anthony en la lista...
TestBed.inject(ArtistaService).seleccionar({ id: 1, nombre: 'Marc Anthony', albumes: [] } as unknown as Artista);
// ...y ahora llega por la ruta al artista 2, cuya respuesta todavía no ha llegado.
await ir('/artistas/2');
responderLista();
harness.detectChanges();
expect(texto()).not.toContain('Marc Anthony');
await responder(`${environment.ARTISTAS_URL}/2`, { id: 2, nombre: 'Datty', albumes: [] });
expect(texto()).toContain('Datty');
});
it('muestra "no existe" cuando el back responde 404', async () => {
await ir('/artistas/999');
await responder(
`${environment.ARTISTAS_URL}/999`,
{ status: 404, message: 'El artista no existe' },
{ status: 404, statusText: 'Not Found' },
);
expect(texto()).toContain('Ese artista no existe');
});

Las funciones auxiliares encapsulan los pasos repetidos:

// Navega con el Router y deja que Angular pinte
async function ir(url: string) {
await TestBed.inject(Router).navigateByUrl(url);
harness.detectChanges();
}
// Responde la petición del detalle y espera a que la vista se actualice
async function responder(url: string, cuerpo: string | object, opciones?: { status: number; statusText: string }) {
http.expectOne(url).flush(cuerpo, opciones);
responderLista();
await harness.fixture.whenStable();
harness.detectChanges();
}
const texto = () => (harness.fixture.nativeElement as HTMLElement).textContent;

Tres detalles propios de una aplicación sin zone.js, que aparecieron al escribir estas pruebas:

  • Hay que esperar a que la vista se estabilice. Después de responder una petición, await harness.fixture.whenStable() y luego harness.detectChanges(). Sin ellos, la vista todavía no refleja la respuesta.

  • Una petición sin responder bloquea whenStable(). La prueba se queda colgada hasta el límite de 5 segundos de Vitest. La causa es una segunda petición: ArtistaService pide la lista de artistas al crearse, y nadie la responde. Por eso responderLista() la responde con una lista vacía:

    const responderLista = () => http.match(environment.ARTISTAS_URL).forEach((r) => r.flush([]));

    Es también un aviso de diseño: el detalle por ruta pide la lista completa aunque no la necesite.

  • El nombre del input debe coincidir con el del parámetro de ruta. Con la ruta artistas/:id, el componente necesita readonly id = input<string>(). Con otro nombre, id() queda undefined y la URL se arma mal: es el error que se describió al principio.

El componente de asociación necesita más: abre una ruta, espera la lista de álbumes, el usuario elige una opción y pulsa un botón. La prueba hace lo mismo que el usuario:

asociar-album.spec.ts
async function abrirConAlbumes(albumes: unknown[]) {
await TestBed.inject(Router).navigateByUrl('/artistas/1/add-album');
harness.detectChanges();
http.expectOne(environment.ALBUMES_URL).flush(albumes);
await pintar();
}
async function elegirYAsociar(titulo: string) {
const select = raiz().querySelector('select')!;
const opcion = Array.from(select.options).find((o) => o.textContent?.trim() === titulo)!;
select.value = opcion.value;
select.dispatchEvent(new Event('change')); // el usuario elige una opción
await pintar();
raiz().querySelector('button')!.click(); // el usuario pulsa "Asociar"
}
it('carga las opciones, asocia el álbum elegido y vuelve al detalle del artista', async () => {
await abrirConAlbumes([
{ id: 1, titulo: "Pa'lla Voy" },
{ id: 2, titulo: 'Vivir Mi Vida' },
]);
await elegirYAsociar('Vivir Mi Vida');
const req = http.expectOne(`${environment.ARTISTAS_URL}/1/albumes/2`);
expect(req.request.method).toBe('POST');
req.flush({});
// Al asociar, navega al detalle, que pide el artista: se responde y se comprueba lo que se ve
await vi.waitFor(() =>
http.expectOne(`${environment.ARTISTAS_URL}/1`).flush({ id: 1, nombre: 'Marc Anthony', albumes: [] }),
);
await pintar();
expect(TestBed.inject(Router).url).toBe('/artistas/1');
expect(raiz().textContent).toContain('Marc Anthony');
});
  • elegirYAsociar elige la opción por su texto, no por un número. Con [ngValue], el atributo value de cada <option> no es el id del álbum sino una etiqueta interna de Angular (por ejemplo 2: 2), así que se busca la <option> por su título y se usa su value.
  • select.dispatchEvent(new Event('change')) simula la elección del usuario: cambiar el valor de un <select> desde código no dispara el evento por sí solo.
  • vi.waitFor(...) reintenta hasta que no lance una excepción. Se usa porque la petición del detalle no existe todavía: nace después de que la navegación termina, y expectOne falla si la busca antes de tiempo.
  • pintar() es la función auxiliar que responde la lista pendiente del servicio, espera a que la vista se estabilice y ejecuta detectChanges(), como responder en el detalle.
  • Se verifica el resultado visible, no el interior del componente: la URL a la que llegó y lo que se pinta.

Y los dos escenarios negativos, que son la razón de esta prueba:

it('muestra el mensaje del back y no navega cuando la asociación falla', async () => {
await abrirConAlbumes([{ id: 2, titulo: 'Vivir Mi Vida' }]);
await elegirYAsociar('Vivir Mi Vida');
http
.expectOne(`${environment.ARTISTAS_URL}/1/albumes/2`)
.flush({ status: 400, message: 'El álbum ya está asociado al artista' }, { status: 400, statusText: 'Bad Request' });
await pintar();
expect(raiz().textContent).toContain('El álbum ya está asociado al artista');
expect(TestBed.inject(Router).url).toBe('/artistas/1/add-album');
});
it('avisa cuando no hay álbumes para elegir', async () => {
await abrirConAlbumes([]);
expect(raiz().textContent).toContain('No hay álbumes para elegir');
expect(raiz().querySelector('select')).toBeNull();
});

La prueba de contrato es la más corta. Se guarda como ejemplo una respuesta real del back (por ejemplo, la de GET /albumes/1 que probaste con Postman) y se asigna a la interfaz del front:

contrato.spec.ts
it('la respuesta real de GET /albumes/1 cabe en el modelo AlbumCompleto del front', () => {
const respuesta: AlbumCompleto = {
id: 1,
titulo: "Pa'lla Voy",
fotoPortadaUrl: '',
fechaLanzamiento: '2004-05-25',
canciones: [{ id: 1, titulo: 'Valió la Pena', fotoPortadaUrl: '', duracion: '4:06' }],
};
expect(respuesta.canciones.length).toBe(1);
});

Es la comprobación del compilador, no de ejecución. Con el modelo Cancion original del front, esta prueba no compila:

TS2739: Type '{ id: number; titulo: string; fotoPortadaUrl: string; duracion: string; }'
is missing the following properties from type 'Cancion': genero, letra, fechaLanzamiento

El mensaje dice exactamente qué no coincide. Hay dos arreglos, según de qué lado esté el error: marcar esos campos como opcionales en el modelo (genero?: string) si el back no los va a mandar, o agregarlos al back si el front los necesita. En el front del curso se marcaron como opcionales, porque el back no los manda.

Una prueba solo protege si falla cuando el código se rompe. Para comprobarlo, se hace un cambio a la vez, se corren las pruebas y se restaura el código. Con las pruebas de esta página:

Se rompe Fallan
En asociarAlbum, la URL: /albumes/ por /album/ Las dos pruebas del servicio y la de éxito del componente
En mensajeDe, devolver siempre el mensaje genérico La del mensaje del back
En ArtistAddAlbum, quitar el this.router.navigate(...) La de éxito (no llega al detalle)
En ArtistDetalle, leer value() sin hasValue() Las del 404 y del 500
En ArtistDetalle, no vaciar la selección mientras carga La de la selección anterior

Con cada una de estas piezas rota, su prueba falla. Si al romper una pieza ninguna prueba falla, falta una prueba.

La cobertura mide qué parte del código ejecutan las pruebas. Es lo que SonarQube lee en el pipeline. Hace falta un paquete adicional, con la misma versión que vitest:

Ventana de terminal
pnpm add -D @vitest/coverage-v8@<la versión de vitest>
pnpm test --coverage --coverage-reporters=lcov --coverage-reporters=text

El reporte lcov.info queda en coverage/<proyecto>/lcov.info, que es la ruta que busca el Jenkinsfile. El de texto se ve en la consola:

File | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s
app/asociar | 100 | 94.44 | 100 | 100 |
asociar-album.ts | 100 | 94.44 | 100 | 100 | 40
app/detalle | 100 | 95.23 | 100 | 100 |
...le-artista.ts | 100 | 95.23 | 100 | 100 | 19

Los números dicen qué no se ejecutó (la columna de líneas sin cubrir), no si lo que se ejecutó se verificó. Por eso la cobertura sirve para encontrar código sin pruebas, y no para saber si las pruebas son buenas: para eso está el ejercicio de romper el código.

El tutorial de pruebas del front con Vitest escribe estas pruebas paso a paso, y el tutorial de cobertura mide su cobertura.

1. Una prueba de servicio llama a service.asociarAlbum('1', 2) pero no hace .subscribe(). Después, http.expectOne(url) falla porque no encuentra ninguna petición. ¿Por qué?
2. Una prueba de un componente en una aplicación sin zone.js se queda colgada en whenStable() hasta que Vitest la corta a los 5 segundos. ¿Cuál es una causa probable?
3. El componente de asociación envía POST /artistas/undefined/albumes/2. La ruta es artistas/:id/add-album y el componente declara input.required<string>() con el nombre artistaId. ¿Cuál es el arreglo?
4. Escribes una prueba, rompes a propósito el código que debería proteger y la prueba sigue en verde. ¿Qué haces?
5. ¿Por qué el valor de un <select> se cambia en la prueba con select.value = opcion.value seguido de select.dispatchEvent(new Event('change'))?
6. Una prueba de contrato asigna la respuesta real del back a la interfaz Cancion y el compilador dice que faltan genero, letra y fechaLanzamiento. ¿Qué significa?
7. Un proyecto tiene alta cobertura. ¿Qué se puede concluir?