Ir al contenido

Tutorial: Pruebas del front con Vitest

Al terminar este tutorial vas a ser capaz de:

  • Simular el back con provideHttpClientTesting() y HttpTestingController.
  • 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.


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ú.


Es lo más fácil de probar: sin plantilla ni rutas. Crea artist.service.spec.ts:

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): un POST sin cuerpo tiene null.
  • req.flush(cuerpo, opciones) responde la petición. Sin opciones es un 200; con { status: 400, statusText: '...' } es un error, y es entonces cuando el subscribe recibe el error.
  • http.verify(), en afterEach, falla si quedó alguna petición sin responder. Por eso el beforeEach responde 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, expectOne no encuentra nada.

Corre ng test --watch=false. Deben pasar las dos pruebas del servicio.


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:

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 luego harness.detectChanges(). Sin ellos, la vista todavía no refleja la respuesta. Las funciones auxiliares ir y responder encapsulan esos pasos.
  • Una petición sin responder bloquea whenStable(). Por eso responder tambié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 input debe coincidir con el del parámetro de la ruta. Con la ruta artistas/:id, el componente necesita readonly id = input<string>(). Si no, id() queda undefined, el componente no pide nada y la prueba falla en expectOne: 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.


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:

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:

  • 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); por eso 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.
  • 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>.

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:

contrato.spec.ts
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.


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.


  • Expected one matching request ... found none. Falta el .subscribe() en la prueba del servicio, o la URL de expectOne no 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 con http.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 usa routerLink o el Router y la prueba no provee uno: agrega provideRouter(...) a los providers.
  • El <select> no cambia al asignar select.value = '2'. Con [ngValue] el value de la <option> no es el id. Busca la opción por su texto y usa su value.
  • La vista no refleja la respuesta. Faltan await harness.fixture.whenStable() y harness.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 test falla con ERR_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.

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ó por provideHttpClientTesting(), o olvidar whenStable() antes de leer la vista.

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.