Inicio / Angular / Angular Completo: De Cero a Experto / Testing en Angular

Testing en Angular

Aprende a escribir pruebas unitarias con Jasmine y TestBed, mockear servicios, probar HTTP con HttpClientTestingModule y E2E con Cypress.


Testing en Angular

El testing es una habilidad crítica en entornos de trabajo profesional. Esta lección cubre desde pruebas unitarias con Jasmine/TestBed hasta pruebas E2E con Cypress.


Tipos de prueba en Angular

Tipo Herramienta Velocidad Propósito
Unitaria Jasmine + Karma ⚡ Rápida Lógica de servicios, pipes, funciones puras
Componente Jasmine + TestBed 🔶 Media Renderizado, inputs, outputs, templates
Integración TestBed + HttpClientTesting 🔶 Media Componentes + servicios juntos
E2E Cypress / Playwright 🐢 Lenta Flujos completos del usuario

Jasmine — Estructura básica

// saludo.service.spec.ts
import { SaludoService } from './saludo.service';

describe('SaludoService', () => {     // Agrupa pruebas relacionadas
  let servicio: SaludoService;

  beforeEach(() => {                  // Se ejecuta antes de cada 'it'
    servicio = new SaludoService();
  });

  it('debe devolver un saludo', () => {
    const resultado = servicio.saludar('Angular');
    expect(resultado).toBe('Hola, Angular!');
  });

  it('debe lanzar error si el nombre está vacío', () => {
    expect(() => servicio.saludar('')).toThrowError('Nombre requerido');
  });
});

Matchers más usados de Jasmine

expect(valor).toBe(3);                    // Igualdad estricta (===)
expect(valor).toEqual({ id: 1 });         // Igualdad profunda (deep equal)
expect(valor).toBeTruthy();               // Truthy
expect(valor).toBeFalsy();                // Falsy
expect(valor).toBeNull();
expect(valor).toBeUndefined();
expect(valor).toContain('Angular');       // Substring o elemento de array
expect(valor).toHaveBeenCalled();         // Spy fue llamado
expect(valor).toHaveBeenCalledWith('x');  // Spy fue llamado con 'x'
expect(fn).toThrowError('mensaje');       // Función lanza error
expect(valor).toBeGreaterThan(5);

TestBed — Módulo de pruebas de Angular

TestBed crea un módulo Angular ligero para las pruebas:

// componente.spec.ts
import { TestBed, ComponentFixture } from '@angular/core/testing';
import { TarjetaComponent } from './tarjeta.component';
import { ProductosService } from '../servicios/productos.service';

describe('TarjetaComponent', () => {
  let fixture: ComponentFixture<TarjetaComponent>;
  let component: TarjetaComponent;

  beforeEach(async () => {
    // Configura el módulo de prueba
    await TestBed.configureTestingModule({
      imports: [TarjetaComponent],           // Standalone component
      providers: [
        // Puedes proveer servicios reales o mocks aquí
      ]
    }).compileComponents();

    fixture = TestBed.createComponent(TarjetaComponent);
    component = fixture.componentInstance;
    fixture.detectChanges();  // Dispara ngOnInit y la detección inicial
  });

  it('debe crearse', () => {
    expect(component).toBeTruthy();
  });
});

Testing de Componentes

// contador.component.ts
@Component({
  selector: 'app-contador',
  standalone: true,
  template: `
    <p>Valor: {{ contador }}</p>
    <button id="btn-mas" (click)="incrementar()">+</button>
    <button id="btn-menos" (click)="decrementar()">-</button>
  `
})
export class ContadorComponent {
  contador = 0;

  incrementar(): void { this.contador++; }
  decrementar(): void { this.contador--; }
}
// contador.component.spec.ts
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { By } from '@angular/platform-browser';
import { ContadorComponent } from './contador.component';

describe('ContadorComponent', () => {
  let fixture: ComponentFixture<ContadorComponent>;
  let component: ContadorComponent;

  beforeEach(async () => {
    await TestBed.configureTestingModule({
      imports: [ContadorComponent]
    }).compileComponents();

    fixture = TestBed.createComponent(ContadorComponent);
    component = fixture.componentInstance;
    fixture.detectChanges();
  });

  it('debe empezar en 0', () => {
    expect(component.contador).toBe(0);
  });

  it('debe incrementar al hacer click en "+"', () => {
    // Buscar el botón en el DOM
    const boton = fixture.debugElement.query(By.css('#btn-mas'));
    boton.triggerEventHandler('click', null);

    fixture.detectChanges();
    expect(component.contador).toBe(1);

    // Verificar el DOM también
    const parrafo = fixture.debugElement.query(By.css('p'));
    expect(parrafo.nativeElement.textContent).toContain('Valor: 1');
  });

  it('debe decrementar al hacer click en "-"', () => {
    component.contador = 5;  // Estado inicial para la prueba
    const boton = fixture.debugElement.query(By.css('#btn-menos'));
    boton.triggerEventHandler('click', null);
    fixture.detectChanges();
    expect(component.contador).toBe(4);
  });
});

Mocking de Servicios

Con jasmine.createSpyObj

// productos.service.ts
@Injectable({ providedIn: 'root' })
export class ProductosService {
  private http = inject(HttpClient);

  obtenerProductos(): Observable<Producto[]> {
    return this.http.get<Producto[]>('/api/productos');
  }
}
// lista-productos.component.spec.ts
import { of } from 'rxjs';

describe('ListaProductosComponent', () => {
  let servicioMock: jasmine.SpyObj<ProductosService>;

  beforeEach(async () => {
    // Crear el mock del servicio
    servicioMock = jasmine.createSpyObj('ProductosService', ['obtenerProductos']);
    servicioMock.obtenerProductos.and.returnValue(of([
      { id: 1, nombre: 'Laptop', precio: 1200 },
      { id: 2, nombre: 'Mouse', precio: 25 }
    ]));

    await TestBed.configureTestingModule({
      imports: [ListaProductosComponent],
      providers: [
        { provide: ProductosService, useValue: servicioMock }  // ← Inyectar mock
      ]
    }).compileComponents();
  });

  it('debe mostrar 2 productos', () => {
    const fixture = TestBed.createComponent(ListaProductosComponent);
    fixture.detectChanges();

    const items = fixture.debugElement.queryAll(By.css('.producto-item'));
    expect(items.length).toBe(2);
    expect(servicioMock.obtenerProductos).toHaveBeenCalled();
  });
});

HttpClientTestingModule — Testing de HTTP

import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';

describe('ProductosService', () => {
  let servicio: ProductosService;
  let httpMock: HttpTestingController;

  beforeEach(() => {
    TestBed.configureTestingModule({
      imports: [HttpClientTestingModule],  // ← Intercepta llamadas HTTP
      providers: [ProductosService]
    });

    servicio = TestBed.inject(ProductosService);
    httpMock = TestBed.inject(HttpTestingController);
  });

  afterEach(() => {
    httpMock.verify();  // Verifica que no quedan requests sin manejar
  });

  it('debe hacer GET /api/productos y devolver lista', () => {
    const productosMock = [{ id: 1, nombre: 'Laptop', precio: 1200 }];

    servicio.obtenerProductos().subscribe(productos => {
      expect(productos.length).toBe(1);
      expect(productos[0].nombre).toBe('Laptop');
    });

    // Interceptar la request y responder con datos de prueba
    const req = httpMock.expectOne('/api/productos');
    expect(req.request.method).toBe('GET');
    req.flush(productosMock);  // Simular la respuesta del servidor
  });

  it('debe manejar error 404', () => {
    servicio.obtenerProductos().subscribe({
      next: () => fail('Debería haber fallado'),
      error: (error) => {
        expect(error.status).toBe(404);
      }
    });

    const req = httpMock.expectOne('/api/productos');
    req.flush('Not Found', { status: 404, statusText: 'Not Found' });
  });
});

Testing de Observables y código asíncrono

Con fakeAsync y tick

import { fakeAsync, tick } from '@angular/core/testing';

it('debe cargar datos después del debounce', fakeAsync(() => {
  component.buscar('Angular');

  tick(300);  // Avanzar el tiempo simulado 300ms (el debounce time)
  fixture.detectChanges();

  expect(component.resultados.length).toBeGreaterThan(0);
}));

Con async / await

it('debe resolver la promesa', async () => {
  const resultado = await servicio.operacionAsincrona();
  expect(resultado).toBe('éxito');
});

Con done (callback pattern)

it('debe emitir un valor', (done) => {
  servicio.obtenerDatos().subscribe(datos => {
    expect(datos).toBeTruthy();
    done();  // Indicar que la prueba asíncrona terminó
  });
});

Testing de Signals

// contador.component.ts (con Signals)
@Component({
  standalone: true,
  template: `<p>{{ contador() }}</p>`
})
export class ContadorSignalComponent {
  contador = signal(0);
  doble = computed(() => this.contador() * 2);

  incrementar() { this.contador.update(n => n + 1); }
}
// contador.component.spec.ts
it('debe actualizar el signal y el computed', () => {
  component.incrementar();
  fixture.detectChanges();

  expect(component.contador()).toBe(1);
  expect(component.doble()).toBe(2);
});

Ejecutar las pruebas

# Ejecutar todas las pruebas con Karma (browser)
ng test

# Con cobertura de código
ng test --code-coverage

# En modo headless (para CI/CD)
ng test --watch=false --browsers=ChromeHeadless

# Ver el reporte de cobertura
open coverage/index.html

Configurar el reporte de cobertura en angular.json

{
  "test": {
    "options": {
      "codeCoverageExclude": [
        "src/app/**/*.module.ts",
        "src/main.ts"
      ],
      "codeCoverage": true
    }
  }
}

E2E con Cypress

Cypress es la herramienta más popular para pruebas end-to-end:

# Instalar Cypress
npm install --save-dev cypress

# Abrir Cypress (interfaz gráfica)
npx cypress open

# Ejecutar en modo headless (CI/CD)
npx cypress run

Ejemplo de prueba E2E

// cypress/e2e/login.cy.js
describe('Flujo de Login', () => {
  beforeEach(() => {
    cy.visit('/login');
  });

  it('debe iniciar sesión correctamente', () => {
    cy.get('[data-cy="email"]').type('admin@empresa.com');
    cy.get('[data-cy="password"]').type('secreto123');
    cy.get('[data-cy="btn-login"]').click();

    // Verificar redirección al dashboard
    cy.url().should('include', '/dashboard');
    cy.get('[data-cy="bienvenida"]').should('contain', 'Hola, Admin');
  });

  it('debe mostrar error con credenciales incorrectas', () => {
    cy.get('[data-cy="email"]').type('usuario@test.com');
    cy.get('[data-cy="password"]').type('contraseña-incorrecta');
    cy.get('[data-cy="btn-login"]').click();

    cy.get('[data-cy="error-mensaje"]').should('be.visible');
    cy.get('[data-cy="error-mensaje"]').should('contain', 'Credenciales inválidas');
  });
});

Buena práctica: atributos data-cy

<!-- En tu componente Angular — separar selectores de UI de selectores de test -->
<input
  formControlName="email"
  class="form-control"
  data-cy="email"
/>

<button
  type="submit"
  class="btn btn-primary"
  data-cy="btn-login"
>
  Iniciar sesión
</button>

Resumen — Qué probar y qué no

Probar ✅ No probar ❌
Lógica de negocio en servicios Implementación interna del framework
Transformaciones de datos (pipes) Código de terceros ya testeado
Interacciones del usuario (clicks) Getters/setters triviales
Llamadas HTTP (con mock) CSS y estilos
Manejo de errores Configuración de módulos
Casos límite (lista vacía, null)

Cobertura — objetivos razonables

Nivel Cobertura
Mínimo aceptable 60%
Buen estándar 80%
Óptimo 90%+
Perfeccionismo innecesario 100% (raro que valga la pena)