Testing en Angular

Por: Artiko
angularbuenas-practicastestingvitestplaywrightsignalscomponent-harnesses

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

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 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:

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):

// 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ónPor qué duelePrá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 comportamientoHarnesses 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 usuarioVerificá salidas y estado observable
Mocks excesivosTerminás testeando tus mocks; si todo está mockeado, no probaste integraciónMockeá 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 intermitentewhenStable(), fake timers de Vitest, aserciones web-first de Playwright
Snapshots gigantes de HTMLUn cambio trivial de markup rompe un snapshot de 400 líneas que nadie revisaAserciones puntuales sobre lo relevante; snapshots pequeños y con intención
E2E para lógica de bajo nivelLento y caro para algo que un unitario cubre en 2msBajá 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

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 →