Testing en Angular
Testing en Angular
Un test existe para darte confianza al cambiar código. Ese es el único criterio que importa. Un test que se rompe cada vez que renombrás una clase CSS o reordenás un div no te da confianza: te da trabajo. Un test que verifica que “el método privado calcularTotal se llamó una vez” no te dice si el usuario ve el total correcto. Este capítulo va sobre escribir tests que verifican comportamiento y sobreviven a los refactors, usando las herramientas que Angular 22 trae por defecto.
El cambio grande respecto a lo que quizás recordás: Angular 22 usa Vitest como runner por defecto en proyectos nuevos del CLI. Karma y Jasmine quedaron atrás (Karma está deprecado y hay una guía oficial de migración “Migrating from Karma to Vitest”). Los tests corren en Node.js con jsdom simulando el DOM, lo que los hace mucho más rápidos que arrancar un navegador real por cada corrida. Si venís de proyectos viejos, la sintaxis de aserciones cambia de expect(x).toBe(y) de Jasmine a la de Vitest (casi idéntica), y los espías pasan de jasmine.createSpy a vi.fn().
La pirámide de tests aplicada a Angular
La pirámide no es dogma, es economía. Cada nivel es más lento y más caro de mantener que el de abajo, así que querés muchos tests baratos en la base y pocos tests caros arriba, cubriendo solo los flujos críticos.
flowchart TB
E2E["e2e · Playwright<br/>Flujos críticos completos con navegador real<br/>Lentos, caros, alta confianza de integración"]
INT["Integración · TestBed + Harnesses<br/>Componente + template + DI reales<br/>Velocidad media"]
UNIT["Unitarios · Vitest sin TestBed<br/>Servicios y lógica pura<br/>Milisegundos, cientos de ellos"]
E2E --- INT --- UNIT
style UNIT fill:#1f6f3f,color:#fff
style INT fill:#b8860b,color:#fff
style E2E fill:#8b2f2f,color:#fff
- Base (unitarios): lógica pura, servicios sin dependencias del DOM, funciones de transformación,
computedde signals. Corren en milisegundos. Deberías tener cientos. - Medio (integración de componentes): un componente con su template, su change detection y su árbol de DI reales, pero con las dependencias externas (HTTP, routing) mockeadas. Acá vive el grueso del valor en una app Angular.
- Cima (e2e): el navegador real recorre un flujo de negocio de punta a punta contra un backend (real o mockeado a nivel de red). Reservalos para lo que rompería el negocio si falla: login, checkout, alta de un recurso.
La regla práctica: si podés testear algo en un nivel más bajo, hacelo ahí. Un e2e para validar que un validador de email rechaza foo@ es un desperdicio: eso es un unitario de dos líneas.
Tests unitarios: servicios y lógica pura sin TestBed
TestBed compila un módulo de testing y monta un árbol de inyección: es potente pero no es gratis. Para lógica que no necesita DI de Angular, instanciá la clase directamente. Es más rápido y deja explícitas las dependencias.
// precio.service.ts
import { Injectable } from '@angular/core';
@Injectable({ providedIn: 'root' })
export class PrecioService {
aplicarDescuento(precio: number, porcentaje: number): number {
if (porcentaje < 0 || porcentaje > 100) {
throw new RangeError('El porcentaje debe estar entre 0 y 100');
}
return Math.round(precio * (1 - porcentaje / 100));
}
}
// precio.service.spec.ts
import { describe, expect, it } from 'vitest';
import { PrecioService } from './precio.service';
describe('PrecioService', () => {
const service = new PrecioService(); // sin TestBed: instanciación directa
it('aplica el descuento y redondea', () => {
expect(service.aplicarDescuento(1000, 15)).toBe(850);
});
it('rechaza porcentajes fuera de rango', () => {
expect(() => service.aplicarDescuento(1000, 150)).toThrow(RangeError);
});
});
Si el servicio sí tiene dependencias inyectadas, tenés dos caminos. Cuando la dependencia es una clase simple, pasala por constructor con un doble hecho a mano. Cuando el servicio usa inject() internamente y querés respetar la DI, usá TestBed.inject():
// notificador.service.spec.ts
import { TestBed } from '@angular/core/testing';
import { describe, expect, it, vi } from 'vitest';
import { NotificadorService } from './notificador.service';
import { HttpClient } from '@angular/common/http';
describe('NotificadorService', () => {
it('postea el mensaje al endpoint', () => {
const httpSpy = { post: vi.fn().mockReturnValue(of({ ok: true })) };
TestBed.configureTestingModule({
providers: [
NotificadorService,
{ provide: HttpClient, useValue: httpSpy }, // doble inyectado vía DI
],
});
const service = TestBed.inject(NotificadorService);
service.enviar('hola');
expect(httpSpy.post).toHaveBeenCalledWith('/api/notificaciones', { texto: 'hola' });
});
});
Fijate que no acoplás el test a cómo NotificadorService obtiene el HttpClient: lo reemplazás por el token. Esa es la idea que desarrollamos en Inyección de dependencias y que hace testeable el código.
Tests de componentes con TestBed
Para un componente querés verificar la tríada estado → change detection → DOM: cambiás una entrada, Angular renderiza, y el usuario ve el resultado esperado. Con componentes standalone (los únicos en Angular 22) el setup es directo: importás el componente y proveés lo que necesite.
// contador.component.ts
import { ChangeDetectionStrategy, Component, signal } from '@angular/core';
@Component({
selector: 'app-contador',
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<button (click)="incrementar()">+</button>
<span data-testid="valor">{{ valor() }}</span>
`,
})
export class ContadorComponent {
readonly valor = signal(0);
incrementar() {
this.valor.update((v) => v + 1);
}
}
// contador.component.spec.ts
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { beforeEach, describe, expect, it } from 'vitest';
import { ContadorComponent } from './contador.component';
describe('ContadorComponent', () => {
let fixture: ComponentFixture<ContadorComponent>;
beforeEach(() => {
TestBed.configureTestingModule({ imports: [ContadorComponent] });
fixture = TestBed.createComponent(ContadorComponent);
});
it('incrementa el valor al hacer click', async () => {
const boton: HTMLButtonElement = fixture.nativeElement.querySelector('button');
boton.click();
await fixture.whenStable(); // deja que corra el change detection
const valor = fixture.nativeElement.querySelector('[data-testid="valor"]');
expect(valor.textContent).toContain('1');
});
});
Dos detalles importantes de Angular 22:
TestBed.createComponent()ya no dispara change detection sincrónicamente. Después de un cambio de estado usáawait fixture.whenStable()(recomendado con zoneless) ofixture.detectChanges().whenStable()espera a que se estabilice todo el ciclo, incluidos loseffect.- Para setear signal inputs desde el test no accedas al
componentInstancea mano: usá las funciones de bindinginputBinding()youtputBinding()al crear el componente, que atan una señal o un handler a la entrada/salida como lo haría un template padre.
import { inputBinding, outputBinding, signal } from '@angular/core';
it('renderiza el saludo del input y emite al cerrar', async () => {
const nombre = signal('Ada');
const cerrado = vi.fn();
const fixture = TestBed.createComponent(TarjetaComponent, {
bindings: [
inputBinding('nombre', nombre), // input signal
outputBinding('cerrar', cerrado), // output
],
});
await fixture.whenStable();
expect(fixture.nativeElement.textContent).toContain('Ada');
nombre.set('Grace'); // cambiar la fuente re-renderiza
await fixture.whenStable();
expect(fixture.nativeElement.textContent).toContain('Grace');
});
Providers mockeados por token
El patrón para aislar el componente de sus dependencias es siempre el mismo: sustituir el proveedor por el token. Nada de parchear imports ni de espiar métodos globales.
TestBed.configureTestingModule({
imports: [PerfilComponent],
providers: [
provideRouter([]), // routing real pero vacío
{ provide: UsuarioService, useValue: usuarioMock }, // servicio mockeado
{ provide: CONFIG_TOKEN, useValue: { apiUrl: '/x' } }, // InjectionToken
],
});
Como el componente pide sus dependencias con inject(), el test las intercepta en el árbol de DI sin que el componente sepa que está en un test. Ese desacople es exactamente lo que compra la inversión de control.
Component Harnesses del CDK: interacción robusta, no acoplada al DOM
El problema de querySelector('.mat-mdc-button-touch-target') es que estás testeando la estructura interna de Material, no tu comportamiento. Cuando Material cambie esa clase en la próxima versión, tu test se rompe sin que tu código haya cambiado. Los Component Harnesses del CDK resuelven esto: son clases que exponen una API estable para interactuar con un componente “como lo haría un usuario”, aislándote de su DOM.
flowchart LR
T["Test"] -->|"getHarness(MatSelectHarness)"| L["HarnessLoader"]
L -->|"TestbedHarnessEnvironment"| F["ComponentFixture"]
T -->|"await select.open()<br/>await select.clickOptions()"| H["MatSelectHarness"]
H -->|"API estable"| DOM["DOM interno de Material<br/>(oculto al test)"]
style H fill:#1f6f3f,color:#fff
style DOM fill:#8b2f2f,color:#fff
// antes (antipatrón): acoplado al DOM interno de Material
it('selecciona una opción', () => {
const trigger = fixture.nativeElement.querySelector('.mat-mdc-select-trigger');
trigger.click();
fixture.detectChanges();
const opciones = document.querySelectorAll('.mat-mdc-option'); // frágil
(opciones[1] as HTMLElement).click();
// ...clases internas que Material puede cambiar en cualquier release
});
// después (mejor): harness con API estable
import { TestbedHarnessEnvironment } from '@angular/cdk/testing/testbed';
import { MatSelectHarness } from '@angular/material/select/testing';
it('selecciona una opción', async () => {
const loader = TestbedHarnessEnvironment.loader(fixture);
const select = await loader.getHarness(MatSelectHarness);
await select.open();
await select.clickOptions({ text: 'Argentina' });
expect(await select.getValueText()).toBe('Argentina');
});
El harness awaitea internamente por la estabilización, así que no necesitás sembrar detectChanges() entre pasos. Podés cargar varios harnesses (getAllHarnesses), filtrarlos (MatButtonHarness.with({ text: 'Guardar' })) y hasta escribir harnesses propios para tus componentes reutilizables, extendiendo ComponentHarness. La ganancia es concreta: un cambio de DOM interno deja de romper tus tests.
Testing de signals y estado reactivo
Las señales tienen una propiedad que las hace muy testeables: signal y computed son síncronos. Leés el valor y ya está, sin timers ni suscripciones.
import { computed, signal } from '@angular/core';
import { describe, expect, it } from 'vitest';
describe('carrito (computed)', () => {
it('recalcula el total al cambiar los items', () => {
const items = signal([{ precio: 100 }, { precio: 250 }]);
const total = computed(() => items().reduce((acc, i) => acc + i.precio, 0));
expect(total()).toBe(350);
items.update((prev) => [...prev, { precio: 50 }]);
expect(total()).toBe(400); // el computed se reevaluó solo
});
});
Los effect son distintos: corren de forma asíncrona durante el change detection, no en el instante en que cambiás la señal. Para testearlos necesitás un contexto de inyección y forzar la estabilización. TestBed.runInInjectionContext() te da el contexto para crear el effect, y fixture.whenStable() (o TestBed.tick()) lo dispara.
import { effect, signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
it('el effect reacciona al cambio de la señal', async () => {
const fuente = signal(0);
const vistos: number[] = [];
TestBed.runInInjectionContext(() => {
effect(() => vistos.push(fuente()));
});
await TestBed.inject(ApplicationRef).whenStable(); // primera ejecución del effect
fuente.set(5);
await TestBed.inject(ApplicationRef).whenStable(); // segunda ejecución
expect(vistos).toEqual([0, 5]);
});
Un error común: esperar que el effect corra sincrónicamente al llamar set(). No lo hace. Si tu test no estabiliza, verás solo el valor inicial y creerás que el effect está roto.
Testing de código asíncrono
Con zoneless estable en Angular 22, la forma preferida de esperar trabajo asíncrono es await fixture.whenStable() para lo que gestiona Angular, y los fake timers de Vitest para controlar setTimeout/setInterval/Date:
import { vi } from 'vitest';
it('emite el valor tras el debounce de 300ms', async () => {
vi.useFakeTimers();
const service = new BusquedaService();
const resultado = vi.fn();
service.resultado$.subscribe(resultado);
service.buscar('a');
service.buscar('ab'); // debería colapsar por debounceTime(300)
await vi.advanceTimersByTimeAsync(300);
expect(resultado).toHaveBeenCalledTimes(1);
expect(resultado).toHaveBeenCalledWith('ab');
vi.useRealTimers();
});
fakeAsync / tick / flush siguen existiendo y funcionan, pero están atados a zone.js; en un proyecto zoneless la recomendación oficial se inclina por los fake timers de Vitest y whenStable(). Si mantenés un proyecto basado en zonas, fakeAsync sigue siendo válido:
import { fakeAsync, tick } from '@angular/core/testing';
it('resuelve tras el timeout (proyecto con zone.js)', fakeAsync(() => {
let valor = 0;
setTimeout(() => (valor = 42), 1000);
tick(1000); // avanza el reloj virtual
expect(valor).toBe(42);
}));
Para HTTP, usá provideHttpClientTesting() y HttpTestingController: nunca pegues a la red real en un test de componente o servicio. Verificás la request que se hizo y respondés con datos controlados, lo que te da tests deterministas.
E2E con Playwright: qué probar y cómo no volverlo frágil
El e2e es caro, así que su misión es distinta: no re-testear lógica que ya cubriste abajo, sino verificar que las piezas integradas funcionan de punta a punta en un navegador real. Login completo, checkout, el flujo de crear-editar-borrar un recurso. Todo lo que sea “¿este validador rechaza el input X?” ya debería estar cubierto por un unitario.
Reglas que evitan tests frágiles (alineadas con las best practices de Playwright):
- Localizadores orientados al usuario:
getByRole,getByLabel,getByText. Son resilientes a cambios del DOM. ReservágetByTestIdpara cuando no haya un rol o texto accesible, y evitá selectores CSS/XPath acoplados a la estructura. - Aserciones web-first:
await expect(locator).toBeVisible()reintenta y espera automáticamente. La forma sincrónicaexpect(await locator.isVisible()).toBe(true)no espera ni un segundo y produce tests intermitentes (flaky). - Aislamiento: cada test arranca con su propio storage y cookies. Usá
test.beforeEach()para setup sin compartir estado entre tests. - No testees dependencias de terceros: interceptá con
page.route()para mockear respuestas de red y tener datos predecibles, en vez de depender de un backend o una API externa que puede caerse.
// e2e/login.spec.ts
import { test, expect } from '@playwright/test';
test('el usuario inicia sesión y ve el dashboard', async ({ page }) => {
await page.route('**/api/login', (route) =>
route.fulfill({ json: { token: 'fake-jwt' } }), // sin backend real
);
await page.goto('/login');
await page.getByLabel('Correo').fill('[email protected]');
await page.getByLabel('Contraseña').fill('secreto');
await page.getByRole('button', { name: 'Ingresar' }).click();
await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
});
Page Objects: encapsular la página, no repetir selectores
Cuando el mismo flujo aparece en varios tests, los selectores duplicados se vuelven una carga de mantenimiento. El Page Object Model encapsula los localizadores y las acciones de una página en una clase, de modo que un cambio de UI se arregla en un solo lugar.
// e2e/pages/login.page.ts
import { type Page, type Locator } from '@playwright/test';
export class LoginPage {
private readonly correo: Locator;
private readonly clave: Locator;
private readonly ingresar: Locator;
constructor(private readonly page: Page) {
this.correo = page.getByLabel('Correo');
this.clave = page.getByLabel('Contraseña');
this.ingresar = page.getByRole('button', { name: 'Ingresar' });
}
async ir() {
await this.page.goto('/login');
}
async iniciarSesion(correo: string, clave: string) {
await this.correo.fill(correo);
await this.clave.fill(clave);
await this.ingresar.click();
}
}
Los tests quedan legibles y expresan intención de negocio, no mecánica de DOM: await loginPage.iniciarSesion('[email protected]', 'secreto').
Antipatrones de testing
| Antipatrón | Por qué duele | Práctica que lo reemplaza |
|---|---|---|
Acoplarse al DOM interno (clases de Material, orden de div) | El test se rompe en refactors o updates sin cambios de comportamiento | Harnesses del CDK, getByRole/getByText, data-testid |
| Testear detalle de implementación (métodos privados, “se llamó X veces”) | Verificás cómo, no qué; el test no valida lo que ve el usuario | Verificá salidas y estado observable |
| Mocks excesivos | Terminás testeando tus mocks; si todo está mockeado, no probaste integración | Mockeá los bordes (HTTP, tiempo, terceros); usá lo real adentro |
Fragilidad por timing (setTimeout en el test, esperas fijas) | Pasa en local, falla en CI de forma intermitente | whenStable(), fake timers de Vitest, aserciones web-first de Playwright |
| Snapshots gigantes de HTML | Un cambio trivial de markup rompe un snapshot de 400 líneas que nadie revisa | Aserciones puntuales sobre lo relevante; snapshots pequeños y con intención |
| E2E para lógica de bajo nivel | Lento y caro para algo que un unitario cubre en 2ms | Bajá el test un nivel en la pirámide |
El hilo conductor de todos: testeá comportamiento, no implementación. Preguntate siempre “si refactorizo la implementación sin cambiar lo que hace, ¿este test debería seguir pasando?”. Si la respuesta es no, el test está acoplado al detalle equivocado.
flowchart TD
Q{"¿Qué querés verificar?"}
Q -->|Lógica pura o servicio| U["Unitario Vitest<br/>sin TestBed"]
Q -->|Componente + template + DI| C["TestBed + Harness<br/>mocks por token"]
Q -->|Flujo de negocio completo| E["Playwright e2e<br/>Page Objects + route mock"]
U --> R{"¿Depende del tiempo?"}
C --> R
R -->|Sí| T["Fake timers de Vitest<br/>o whenStable()"]
R -->|No| A["Aserción directa"]
style U fill:#1f6f3f,color:#fff
style C fill:#b8860b,color:#fff
style E fill:#8b2f2f,color:#fff
Checklist
- El proyecto usa Vitest (runner por defecto de Angular 22); si venís de Karma/Jasmine, planificá la migración con la guía oficial.
- La mayoría de tus tests son unitarios rápidos; los e2e cubren solo flujos críticos de negocio.
- La lógica pura y los servicios sin dependencias del DOM se testean sin
TestBed(instanciación directa). - Las dependencias se mockean por token (
{ provide: X, useValue }), aprovechando la DI, no parcheando imports. - Interactuás con componentes de UI vía Component Harnesses del CDK, no con selectores del DOM interno.
- Después de cambiar estado usás
await fixture.whenStable(); loseffectse disparan estabilizando, no conset(). - El código asíncrono se controla con fake timers de Vitest o
whenStable()(zoneless);fakeAsync/ticksolo si el proyecto sigue conzone.js. - Los e2e usan localizadores orientados al usuario (
getByRole), aserciones web-first ypage.route()para mockear la red. - Los flujos repetidos en e2e están encapsulados en Page Objects.
- Ningún test se acopla a detalle de implementación, abusa de mocks, ni depende de esperas fijas por tiempo.
Con una suite que verifica comportamiento y no detalle, refactorizás con red de seguridad. El siguiente paso es elegir bien las piezas externas que sumás al proyecto sin comprometer esa testeabilidad: Librerías del ecosistema →