Tutorial: Pruebas del front con Vitest
1. Objetivos de aprendizaje
Sección titulada «1. Objetivos de aprendizaje»Al terminar este tutorial vas a ser capaz de:
- Simular el back con
provideHttpClientTesting()yHttpTestingController. - Probar un servicio: la URL, el verbo y el cuerpo de una llamada, y el mensaje de error.
- Probar un componente dentro de una ruta con
RouterTestingHarness. - Probar un componente con interacción: elegir una opción, pulsar un botón y comprobar lo que el usuario ve.
- Escribir una prueba de contrato entre una respuesta del back y el modelo del front.
- Romper el código a propósito para comprobar que cada prueba protege algo.
Prerrequisitos: los tres tutoriales del front del Release 8 (rutas y menú, detalle por ruta y asociación), porque estas pruebas ejercitan ese código. La estrategia, los conceptos y los dobles están en la teoría de Pruebas del front con Vitest y de Pruebas del front: qué probar y con qué: este tutorial las aplica y no las repite.
2. De dónde partes
Sección titulada «2. De dónde partes»Tu front ya tiene rutas, detalle por ruta y un componente de asociación, pero nada que compruebe que siguen funcionando cuando alguien cambia el código. El Release 8 pide, por módulo, una prueba por cada una de estas piezas:
| Pieza | Qué se comprueba | Pruebas en este tutorial |
|---|---|---|
| Servicio | La URL, el verbo y el cuerpo del POST de asociación, y el mensaje de error |
2 |
| Ruta y detalle | Que /artistas/:id pide el artista de la URL, no muestra el anterior mientras carga y maneja el 404 |
3 |
| Componente de asociación | Que asocia el álbum elegido y vuelve al detalle, que muestra el mensaje del back y que avisa si no hay álbumes | 3 |
| Contrato | Que una respuesta real del back cabe en el modelo TypeScript | 1 |
Ninguna prueba levanta el back: se le responden las peticiones desde la propia prueba.
Corre ng test --watch=false antes de empezar. Debe terminar en verde: si falla
app.spec.ts con No provider found for ActivatedRoute, es el cambio que se explica en
la sección 9 del tutorial de rutas y menú.
3. Probar el servicio
Sección titulada «3. Probar el servicio»Es lo más fácil de probar: sin plantilla ni rutas. Crea artist.service.spec.ts:
import { HttpErrorResponse, provideHttpClient } from '@angular/common/http';import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';import { TestBed } from '@angular/core/testing';import { environment } from '../environments/environment';import { ArtistaService } from './artist.service';
describe('ArtistaService', () => { let service: ArtistaService; let http: HttpTestingController;
beforeEach(() => { TestBed.configureTestingModule({ providers: [provideHttpClient(), provideHttpClientTesting()] }); service = TestBed.inject(ArtistaService); http = TestBed.inject(HttpTestingController); // El servicio pide la lista de artistas al crearse: se responde para que no quede pendiente. http.match(environment.ARTISTAS_URL).forEach((req) => req.flush([])); });
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'); });});Fíjate en las piezas:
provideHttpClientTesting()reemplaza el back por uno falso que intercepta las peticiones. Con él, la prueba decide qué responde el servidor.http.expectOne(url)comprueba que salió exactamente una petición a esa URL y la entrega. Con ella se verifica el verbo (req.request.method) y el cuerpo (req.request.body): unPOSTsin cuerpo tienenull.req.flush(cuerpo, opciones)responde la petición. Sin opciones es un200; con{ status: 400, statusText: '...' }es un error, y es entonces cuando elsubscriberecibe elerror.http.verify(), enafterEach, falla si quedó alguna petición sin responder. Por eso elbeforeEachresponde la lista de artistas: el servicio la pide en cuanto se crea, y si nadie la responde,verify()falla en todas las pruebas.- Hay que suscribirse.
http.post(...)es un Observable frío: la petición solo sale al llamar.subscribe(). Sin él,expectOneno encuentra nada.
Corre ng test --watch=false. Deben pasar las dos pruebas del servicio.
4. Probar el detalle por ruta
Sección titulada «4. Probar el detalle por ruta»Un componente que depende de la ruta se prueba dentro de una ruta, con
RouterTestingHarness, que monta una aplicación mínima con un <router-outlet>. Crea
artist-detalle/artist-detalle.spec.ts:
import { provideHttpClient } from '@angular/common/http';import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';import { TestBed } from '@angular/core/testing';import { Router, provideRouter, withComponentInputBinding } from '@angular/router';import { RouterTestingHarness } from '@angular/router/testing';import { environment } from '../../environments/environment';import { Artista } from '../album-list/album';import { ArtistaService } from '../artist.service';import { ArtistDetalle } from './artist-detalle';
describe('ArtistDetalle por ruta', () => { let http: HttpTestingController; let harness: RouterTestingHarness;
beforeEach(async () => { TestBed.configureTestingModule({ providers: [ provideHttpClient(), provideHttpClientTesting(), provideRouter([{ path: 'artistas/:id', component: ArtistDetalle }], withComponentInputBinding()), ], }); http = TestBed.inject(HttpTestingController); harness = await RouterTestingHarness.create(); });
afterEach(() => http.verify());
// El servicio pide la lista de artistas al crearse: se responde para que no quede pendiente. const responderLista = (): void => http.match(environment.ARTISTAS_URL).forEach((req) => req.flush([]));
// Navega con el Router y deja que Angular pinte. async function ir(url: string): Promise<void> { 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 }, ): Promise<void> { http.expectOne(url).flush(cuerpo, opciones); responderLista(); await harness.fixture.whenStable(); harness.detectChanges(); }
const texto = (): string => (harness.fixture.nativeElement as HTMLElement).textContent ?? '';
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 pruebas son las de la tabla de la sección 2, una por escenario. Tres detalles propios
de una aplicación Angular sin zone.js:
- 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. Las funciones auxiliaresiryresponderencapsulan esos pasos. - Una petición sin responder bloquea
whenStable(). Por esorespondertambién responde la lista de artistas pendiente: sin eso, la prueba se queda colgada hasta el límite de cinco segundos de Vitest. - El nombre del
inputdebe coincidir con el del parámetro de la ruta. Con la rutaartistas/:id, el componente necesitareadonly id = input<string>(). Si no,id()quedaundefined, el componente no pide nada y la prueba falla enexpectOne: es la forma de enterarse del error.
La segunda prueba es la que protege el comportamiento más fácil de romper: si el
effect del componente no vacía la selección mientras carga, la pantalla seguiría
mostrando al artista anterior. Mira en la sección 7 qué pasa cuando eso se rompe.
5. Probar el componente de asociación
Sección titulada «5. Probar el componente de asociación»El componente de asociación necesita más: se abre en 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. Crea artist-add-album/artist-add-album.spec.ts:
import { provideHttpClient } from '@angular/common/http';import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';import { TestBed } from '@angular/core/testing';import { Router, provideRouter, withComponentInputBinding } from '@angular/router';import { RouterTestingHarness } from '@angular/router/testing';import { environment } from '../../environments/environment';import { ArtistDetalle } from '../artist-detalle/artist-detalle';import { ArtistAddAlbum } from './artist-add-album';
describe('ArtistAddAlbum', () => { let http: HttpTestingController; let harness: RouterTestingHarness;
beforeEach(async () => { TestBed.configureTestingModule({ providers: [ provideHttpClient(), provideHttpClientTesting(), provideRouter( [ { path: 'artistas/:id', component: ArtistDetalle }, { path: 'artistas/:id/add-album', component: ArtistAddAlbum }, ], withComponentInputBinding(), ), ], }); http = TestBed.inject(HttpTestingController); harness = await RouterTestingHarness.create(); });
afterEach(() => http.verify());
const raiz = (): HTMLElement => harness.fixture.nativeElement as HTMLElement;
// Responde la lista de artistas pendiente, espera a que la vista se estabilice y la pinta. async function pintar(): Promise<void> { http.match(environment.ARTISTAS_URL).forEach((req) => req.flush([])); await harness.fixture.whenStable(); harness.detectChanges(); }
async function abrirConAlbumes(albumes: unknown[]): Promise<void> { await TestBed.inject(Router).navigateByUrl('/artistas/1/add-album'); harness.detectChanges(); http.expectOne(environment.ALBUMES_URL).flush(albumes); await pintar(); }
// Hace lo mismo que el usuario: elige la opción por su texto y pulsa "Asociar". async function elegirYAsociar(titulo: string): Promise<void> { const select: HTMLSelectElement = raiz().querySelector('select')!; const opcion: HTMLOptionElement = Array.from(select.options).find( (o: HTMLOptionElement): boolean => 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'); });
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(); });});Cómo leerla:
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); por eso 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.- Se verifica el resultado visible, no el interior del componente: la URL a la que llegó y lo que se pinta.
- Los dos escenarios negativos son la razón de esta prueba: el mensaje del back
aparece y la pantalla no navega, y sin álbumes no hay
<select>.
6. Probar el contrato
Sección titulada «6. Probar el contrato»La prueba de contrato es la más corta. Se guarda como ejemplo una respuesta real del
back (la de GET /albumes/1 que probaste con Postman) y se asigna a la interfaz del
front. Crea contrato.spec.ts en src/app:
import { AlbumCompleto } from './album-list/album';
describe('Contrato con el back', () => { it('la respuesta real de GET /albumes/1 cabe en el modelo AlbumCompleto del front', () => { // Respuesta real del back (la de Postman), con solo los campos que el front usa. const respuesta: AlbumCompleto = { id: 1, titulo: "Pa'lla Voy", fotoPortadaUrl: '', fechaLanzamiento: '2023-10-12', canciones: [{ id: 1, titulo: 'Valió la Pena', duracion: '4:06' }], };
expect(respuesta.canciones.length).toBe(1); });});Es la comprobación del compilador, no de ejecución: si la respuesta no cabe en el
modelo, el archivo no compila y ng test falla con el nombre del campo que sobra o
falta. Esta prueba compila porque, en el tutorial del detalle, genero, letra y
fechaLanzamiento de la canción se marcaron como opcionales, ya que el back no los manda.
7. Romper el código a propósito
Sección titulada «7. 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 corre ng test --watch=false y se restaura el código. Con
las pruebas de este tutorial, estos son los cambios y lo que ocurre (se comprobaron uno
por uno):
| Cambio en el código | Qué falla |
|---|---|
En asociarAlbum, enviar {} en vez de null |
La prueba del servicio “hace un POST sin cuerpo” |
En asociarAlbum, escribir /albums/ en vez de /albumes/ |
Las dos del servicio y las tres del componente de asociación |
En ArtistDetalle, quitar : undefined del effect, de modo que no vacíe la selección mientras carga |
La del artista anterior que sigue visible, y la del 404 |
En ArtistDetalle, hacer que noExiste devuelva siempre false |
La del 404 |
Renombrar el input id de ArtistDetalle a artistaId, en la clase y en la plantilla |
Las tres del detalle |
En ArtistAddAlbum, quitar el this.router.navigate(...) |
Las tres del componente de asociación |
En mensajeDe, devolver siempre el mensaje genérico |
La del mensaje del back |
| Quitar de la plantilla la rama de “sin álbumes” | La de “no hay álbumes” |
Volver a hacer obligatorio genero en Cancion |
La de contrato: el archivo no compila (TS2741: Property 'genero' is missing) |
Dos cosas se notan al hacerlo. Un solo cambio puede romper varias pruebas, y eso es normal: todas dependían de lo roto. Y las tres del componente de asociación fallan juntas cuando se quita la navegación, porque las tres usan la misma función auxiliar para abrir la ruta.
Si al romper el código ninguna prueba falla, falta una prueba. Escríbela, vuelve a romper el código y comprueba que ahora sí falla. Restaura el código y que vuelva a pasar.
8. Errores frecuentes
Sección titulada «8. Errores frecuentes»Expected one matching request ... found none. Falta el.subscribe()en la prueba del servicio, o la URL deexpectOneno es exactamente la que pide el código.verify()falla con peticiones pendientes en todas las pruebas. El servicio pide la lista de artistas al crearse y nadie la responde. Respóndela conhttp.match(url).forEach((req) => req.flush([])).- La prueba se queda colgada cinco segundos y falla por tiempo. Hay una petición sin
responder y
whenStable()la espera. Responde todas las peticiones pendientes. NG0201: No provider found for ActivatedRoute. El componente usarouterLinko el Router y la prueba no provee uno: agregaprovideRouter(...)a losproviders.- El
<select>no cambia al asignarselect.value = '2'. Con[ngValue]elvaluede la<option>no es elid. Busca la opción por su texto y usa suvalue. - La vista no refleja la respuesta. Faltan
await harness.fixture.whenStable()yharness.detectChanges()después de responder. - La prueba pasa aunque rompas el código. Verificas el interior del componente en vez de lo que el usuario ve. Compruébalo con la sección 7.
pnpm testfalla conERR_PNPM_IGNORED_BUILDS. Es un aviso de pnpm 11 sobre los scripts de instalación, no de tus pruebas: la teoría explica cómo aprobarlos.
9. Con ayuda de la IA
Sección titulada «9. Con ayuda de la IA»Las pruebas análogas a la primera se pueden pedir a la IA a partir de las que ya escribiste: dale el servicio o el componente y una prueba de ejemplo.
Lo que no se delega:
- Qué probar. Los escenarios salen de lo que el back puede responder y de las reglas de tu módulo, no de la IA.
- Revisar que cada prueba falle. Una IA puede escribir pruebas que siempre pasan. La defensa es la sección 7: rompe el código y comprueba que la prueba falla.
- Las APIs de pruebas de Angular 22. Un modelo entrenado con ejemplos antiguos
suele sugerir
HttpClientTestingModule, que Angular reemplazó porprovideHttpClientTesting(), o olvidarwhenStable()antes de leer la vista.
¿Dónde quedaste?
Sección titulada «¿Dónde quedaste?»Al final de este tutorial tienes:
- Nueve pruebas nuevas: dos del servicio, tres del detalle por ruta, tres del componente de asociación y una de contrato, todas con el back simulado.
- La comprobación de que cada una falla cuando se rompe el código que protege.
Lo que viene después, siguiendo el orden de Release 8:
- Medir la cobertura de estas pruebas, con el tutorial de cobertura (opcional) y la teoría.
- Una prueba de punta a punta con Playwright, que es opcional.