Capítulo 31 - Parte 3: Testing de Servicios, HTTP y NgRx¶
Parte 3 de 4 · Capítulo 31 · PARTE XIII - Librerías Esenciales del Ecosistema
Testar componentes visuales es importante, pero la lógica de negocio más crítica suele vivir en los servicios. Y si usamos NgRx, el store es el corazón del estado de la aplicación. En esta parte veamos cómo aislar y testar servicios con dependencias de HTTP, y cómo verificar que nuestros componentes interactúan correctamente con el store.
Testing de un servicio simple¶
Empecemos por lo más básico: un servicio con lógica pura que no depende de HTTP:
// carrito.service.ts
import { Injectable, signal, computed } from '@angular/core';
interface ItemCarrito {
productoId: number;
nombre: string;
precio: number;
cantidad: number;
}
@Injectable({ providedIn: 'root' })
export class CarritoService {
private readonly items = signal<ItemCarrito[]>([]);
readonly totalItems = computed(() =>
this.items().reduce((acc, item) => acc + item.cantidad, 0)
);
readonly totalPrecio = computed(() =>
this.items().reduce((acc, item) => acc + item.precio * item.cantidad, 0)
);
agregar(item: ItemCarrito): void {
this.items.update(lista => {
const existente = lista.find(i => i.productoId === item.productoId);
if (existente) {
return lista.map(i =>
i.productoId === item.productoId
? { ...i, cantidad: i.cantidad + item.cantidad }
: i
);
}
return [...lista, item];
});
}
vaciar(): void { this.items.set([]); }
}
// carrito.service.spec.ts
import { TestBed } from '@angular/core/testing';
import { CarritoService } from './carrito.service';
describe('CarritoService', () => {
let servicio: CarritoService;
beforeEach(() => {
TestBed.configureTestingModule({});
servicio = TestBed.inject(CarritoService);
});
it('debería iniciar con el carrito vacío', () => {
expect(servicio.totalItems()).toBe(0);
expect(servicio.totalPrecio()).toBe(0);
});
it('debería agregar un item correctamente', () => {
servicio.agregar({ productoId: 1, nombre: 'Teclado', precio: 150000, cantidad: 2 });
expect(servicio.totalItems()).toBe(2);
expect(servicio.totalPrecio()).toBe(300000);
});
it('debería acumular cantidad si el producto ya existe', () => {
const item = { productoId: 1, nombre: 'Teclado', precio: 150000, cantidad: 1 };
servicio.agregar(item);
servicio.agregar(item);
expect(servicio.totalItems()).toBe(2);
});
it('debería vaciar el carrito', () => {
servicio.agregar({ productoId: 1, nombre: 'Mouse', precio: 80000, cantidad: 1 });
servicio.vaciar();
expect(servicio.totalItems()).toBe(0);
});
});
Testing de servicios HTTP con provideHttpClientTesting()¶
A partir de Angular 16+, la forma moderna de testar HTTP es con provideHttpClientTesting() en lugar del módulo antiguo HttpClientTestingModule:
// productos.service.ts
import { Injectable, inject } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { Observable } from 'rxjs';
interface Producto {
id: number;
nombre: string;
precio: number;
}
interface RespuestaPaginada<T> {
datos: T[];
total: number;
pagina: number;
}
@Injectable({ providedIn: 'root' })
export class ProductosService {
private readonly http = inject(HttpClient);
private readonly urlBase = '/api/productos';
obtenerTodos(pagina = 1, porPagina = 10): Observable<RespuestaPaginada<Producto>> {
const params = new HttpParams()
.set('pagina', pagina)
.set('porPagina', porPagina);
return this.http.get<RespuestaPaginada<Producto>>(this.urlBase, { params });
}
obtenerPorId(id: number): Observable<Producto> {
return this.http.get<Producto>(`${this.urlBase}/${id}`);
}
crear(producto: Omit<Producto, 'id'>): Observable<Producto> {
return this.http.post<Producto>(this.urlBase, producto);
}
eliminar(id: number): Observable<void> {
return this.http.delete<void>(`${this.urlBase}/${id}`);
}
}
// productos.service.spec.ts
import { TestBed } from '@angular/core/testing';
import { provideHttpClient } from '@angular/common/http';
import { provideHttpClientTesting, HttpTestingController } from '@angular/common/http/testing';
import { ProductosService } from './productos.service';
describe('ProductosService', () => {
let servicio: ProductosService;
let httpMock: HttpTestingController;
const productosMock = [
{ id: 1, nombre: 'Laptop', precio: 2500000 },
{ id: 2, nombre: 'Mouse', precio: 85000 },
];
beforeEach(() => {
TestBed.configureTestingModule({
providers: [
provideHttpClient(),
provideHttpClientTesting(),
],
});
servicio = TestBed.inject(ProductosService);
httpMock = TestBed.inject(HttpTestingController);
});
afterEach(() => {
httpMock.verify(); // Verifica que no haya peticiones pendientes sin atender
});
it('debería obtener todos los productos con paginación', () => {
const respuestaMock = { datos: productosMock, total: 2, pagina: 1 };
let resultado: typeof respuestaMock | undefined;
servicio.obtenerTodos(1, 10).subscribe(res => (resultado = res));
const req = httpMock.expectOne('/api/productos?pagina=1&porPagina=10');
expect(req.request.method).toBe('GET');
req.flush(respuestaMock);
expect(resultado).toEqual(respuestaMock);
});
it('debería obtener un producto por ID', () => {
let resultado: { id: number; nombre: string; precio: number } | undefined;
servicio.obtenerPorId(1).subscribe(p => (resultado = p));
const req = httpMock.expectOne('/api/productos/1');
expect(req.request.method).toBe('GET');
req.flush(productosMock[0]);
expect(resultado?.nombre).toBe('Laptop');
});
it('debería manejar error 404', () => {
let errorCapturado: Error | undefined;
servicio.obtenerPorId(999).subscribe({
error: err => (errorCapturado = err),
});
const req = httpMock.expectOne('/api/productos/999');
req.flush('No encontrado', { status: 404, statusText: 'Not Found' });
expect(errorCapturado).toBeDefined();
});
it('debería crear un producto con POST', () => {
const nuevoProducto = { nombre: 'Teclado', precio: 120000 };
servicio.crear(nuevoProducto).subscribe();
const req = httpMock.expectOne('/api/productos');
expect(req.request.method).toBe('POST');
expect(req.request.body).toEqual(nuevoProducto);
req.flush({ id: 3, ...nuevoProducto });
});
});
Testing con MockStore de NgRx¶
Cuando un componente depende de NgRx, usamos MockStore para controlar el estado sin necesitar un store real:
// lista-productos.component.spec.ts
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { provideMockStore, MockStore } from '@ngrx/store/testing';
import { screen } from '@testing-library/angular';
import { ListaProductosComponent } from './lista-productos.component';
import { selectProductos, selectCargando } from './productos.selectors';
import { ProductosActions } from './productos.actions';
describe('ListaProductosComponent', () => {
let store: MockStore;
let fixture: ComponentFixture<ListaProductosComponent>;
const productosMock = [
{ id: 1, nombre: 'Laptop', precio: 2500000, enStock: true },
{ id: 2, nombre: 'Mouse', precio: 85000, enStock: false },
];
const estadoInicial = {
productos: {
lista: [],
cargando: false,
error: null,
},
};
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [ListaProductosComponent],
providers: [
provideMockStore({ initialState: estadoInicial }),
],
}).compileComponents();
store = TestBed.inject(MockStore);
fixture = TestBed.createComponent(ListaProductosComponent);
fixture.detectChanges();
});
afterEach(() => {
store.resetSelectors();
});
it('debería mostrar spinner cuando está cargando', () => {
store.overrideSelector(selectCargando, true);
store.refreshState();
fixture.detectChanges();
expect(screen.getByRole('progressbar')).toBeTruthy();
});
it('debería mostrar la lista de productos cuando carga exitosamente', () => {
store.overrideSelector(selectProductos, productosMock);
store.overrideSelector(selectCargando, false);
store.refreshState();
fixture.detectChanges();
expect(screen.getByText('Laptop')).toBeTruthy();
expect(screen.getByText('Mouse')).toBeTruthy();
});
it('debería despachar la acción de cargar al inicializar', () => {
const accionesEsperadas: string[] = [];
store.scannedActions$.subscribe(accion => {
accionesEsperadas.push(accion.type);
});
// Re-creamos el componente para capturar ngOnInit
fixture = TestBed.createComponent(ListaProductosComponent);
fixture.detectChanges();
expect(accionesEsperadas).toContain(ProductosActions.cargarProductos.type);
});
it('debería despachar eliminar al confirmar', () => {
const spy = jest.spyOn(store, 'dispatch');
store.overrideSelector(selectProductos, productosMock);
store.refreshState();
fixture.detectChanges();
const botonEliminar = fixture.nativeElement.querySelector('[data-testid="eliminar-1"]');
botonEliminar?.click();
fixture.detectChanges();
expect(spy).toHaveBeenCalledWith(
ProductosActions.eliminarProducto({ id: 1 })
);
});
});
Diagrama de la estrategia de testing¶
flowchart TD
A[Componente\nListaProductos] --> B[MockStore]
B --> C[overrideSelector\nselectProductos]
B --> D[overrideSelector\nselectCargando]
C --> E[store.refreshState]
D --> E
E --> F[fixture.detectChanges]
F --> G[DOM actualizado\npara assertions]
A --> H{Dispatch de acciones}
H --> I[store.scannedActions$\no jest.spyOn store.dispatch]
I --> J[Verificar tipo\ny payload de acción]
Puntos clave¶
- Para servicios simples,
TestBed.inject(MiServicio)es suficiente; no necesitamos módulos adicionales. provideHttpClientTesting()reemplaza aHttpClientTestingModuledesde Angular 16; ambas APIs coexisten pero la funcional es la preferida.httpMock.verify()enafterEachgarantiza que no haya peticiones HTTP sin responder en cada test.MockStorede@ngrx/store/testingpermite controlar selectores conoverrideSelectoryrefreshState().store.scannedActions$es un Observable que emite cada acción despachada; ideal para verificar dispatches.
¿Qué sigue?¶
En la última parte del capítulo subimos a un nivel más alto: testing end-to-end con Playwright, donde automatizamos el navegador real para validar flujos completos de usuario.