Pruebas del front con Vitest
Qué es Vitest y cómo se corre
Sección titulada «Qué es Vitest y cómo se corre»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.
ng test # corre las pruebasng test --watch=false # las corre una sola vez y terminapnpm test # lo mismo, a través de pnpm (así las ejecuta el pipeline)La estrategia de pruebas
Sección titulada «La estrategia de pruebas»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.
Qué vamos a probar
Sección titulada «Qué vamos a probar»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 |
Cómo se eligen los escenarios
Sección titulada «Cómo se eligen los escenarios»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.
Qué esperamos encontrar
Sección titulada «Qué esperamos encontrar»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: suinputse llamabaartistaIdy 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
Canciondel front exigíagenero,letrayfechaLanzamiento, 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.
Anatomía de una prueba
Sección titulada «Anatomía de una prueba»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) });});describeagrupa las pruebas de una pieza, eites una prueba; su nombre dice el comportamiento que verifica.expect(valor).matcher(esperado)compara:toBe,toEqual,toContain,toBeNull.beforeEachyafterEachcorren antes y después de cada prueba, y dejan a cada una en un estado limpio e independiente.TestBedarma un módulo de Angular solo para la prueba. Se le dicen losprovidersque hacen falta, como enapp.config.ts.
Simular el back: HttpTestingController
Sección titulada «Simular el back: HttpTestingController»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 un200; con{ status: 400, statusText: '...' }es un error.http.verify(), enafterEach, falla si quedó alguna petición sin responder o sin esperar.
Probar un servicio
Sección titulada «Probar un servicio»El servicio es lo más fácil de probar: sin plantilla ni rutas. Este es el ArtistaService con dos pruebas, una por escenario:
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.
Probar rutas y componentes
Sección titulada «Probar rutas y componentes»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:
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 pinteasync 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 actualiceasync 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 luegoharness.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:ArtistaServicepide la lista de artistas al crearse, y nadie la responde. Por esoresponderLista()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
inputdebe coincidir con el del parámetro de ruta. Con la rutaartistas/:id, el componente necesitareadonly id = input<string>(). Con otro nombre,id()quedaundefinedy la URL se arma mal: es el error que se describió al principio.
Probar un componente con interacción
Sección titulada «Probar un componente con interacción»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:
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');});elegirYAsociarelige la opción por su texto, no por un número. Con[ngValue], el atributovaluede cada<option>no es eliddel álbum sino una etiqueta interna de Angular (por ejemplo2: 2), así que se busca la<option>por su título y se usa suvalue.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, yexpectOnefalla 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 ejecutadetectChanges(), comoresponderen 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();});Probar el contrato
Sección titulada «Probar el contrato»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:
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, fechaLanzamientoEl 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.
Romper el código a propósito
Sección titulada «Romper el código a propósito»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.
Correr con cobertura
Sección titulada «Correr con cobertura»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:
pnpm add -D @vitest/coverage-v8@<la versión de vitest>pnpm test --coverage --coverage-reporters=lcov --coverage-reporters=textEl 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 | 19Los 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.