Skip to article frontmatterSkip to article content
Site not loading correctly?

This may be due to an incorrect BASE_URL configuration. See the MyST Documentation for reference.

Programación Orientada a Objetos

Testing OOP

Verificación y Validación de Sistemas Orientados a Objetos

Universidad Nacional de Rio Negro - Sede Andina

OOP 8: Testing en Programación Orientada a Objetos

En los capítulos anteriores diseñamos sistemas orientados a objetos aplicando contratos (Diseño por Contratos), patrones de diseño (Parte 4: Patrones de Diseño), principios SOLID (OOP 6: Principios SOLID) y técnicas de refactoring (OOP 7: Refactoring y Code Smells). Pero, ¿cómo sabemos que nuestro código funciona correctamente? ¿Cómo nos aseguramos de que los cambios futuros no rompan funcionalidad existente?

El testing es la disciplina que permite verificar el comportamiento del software de manera sistemática y repetible. En el contexto de la programación orientada a objetos, el testing presenta desafíos y oportunidades particulares debido a las características del paradigma: encapsulamiento, herencia, polimorfismo y la interacción entre objetos.


Fundamentos del Testing

¿Por qué Testing?

El software sin tests es como un puente sin inspección estructural: puede funcionar hoy, pero no tenemos garantías sobre mañana.

Código SIN Tests

Código CON Tests

Beneficios del testing:

  1. Verificación: Confirmar que el código hace lo esperado

  2. Prevención de regresiones: Detectar cuando un cambio rompe algo

  3. Documentación: Los tests muestran cómo usar el código

  4. Diseño: Escribir tests primero mejora el diseño

  5. Confianza: Permite refactorizar sin miedo

Niveles de Testing

Pirámide de testing: base amplia de tests unitarios, integración en el medio y E2E en la punta.

Figure 1:Pirámide de testing: base amplia de tests unitarios, integración en el medio y E2E en la punta.

NivelQué pruebaCaracterísticas
UnitariosUna clase o método aisladoRápidos, muchos, sin dependencias externas
IntegraciónInteracción entre componentesMás lentos, verifican que partes trabajen juntas
E2E (End-to-End)Sistema completoLentos, frágiles, simulan usuario real

En este capítulo nos enfocamos en tests unitarios, fundamentales para el desarrollo orientado a objetos.

Anatomía de un Test

Un test unitario sigue el patrón AAA (Arrange-Act-Assert):

@Test
public void testSumaPositivos() {
    // ARRANGE (Preparar)
    Calculadora calc = new Calculadora();
    
    // ACT (Actuar)
    int resultado = calc.sumar(2, 3);
    
    // ASSERT (Verificar)
    assertEquals(5, resultado);
}

Arrange: Configurar el escenario inicial Act: Ejecutar la acción a probar Assert: Verificar el resultado esperado


Testing de Clases y Objetos

¿Qué Testear en una Clase?

Cuando testeamos una clase, nos enfocamos en su comportamiento observable, no en sus detalles de implementación.

Qué conviene testear en una clase: contratos y comportamiento observable, no detalles internos.

Figure 2:Qué conviene testear en una clase: contratos y comportamiento observable, no detalles internos.

Testing Sin Getters/Setters

Una pregunta frecuente: ¿Cómo testeau si no puedo usar getters?

Respuesta: Los getters no son necesarios si diseñás métodos de dominio correctamente.

El Problema con Getters en Tests

Cuando testeas usando getters, tu test es frágil y acoplado a la implementación:

// ❌ Malo: Test acoplado a getters
@Test
void testCuentaDepositoProblemático() {
    CuentaBancaria cuenta = new CuentaBancaria(1000);
    cuenta.depositar(500);
    
    // ❌ ¿Por qué usas getSaldo()? Estás verificando estado interno
    assertEquals(1500, cuenta.getSaldo());
}

Si mañana cambias de atributo saldo a algo más complejo, este test se rompe aunque el comportamiento sea el mismo.

Solución: Métodos de Dominio

Diseña métodos públicos que representan acciones del dominio:

public class CuentaBancaria {
    private double saldo;
    
    // ❌ NO HAGAS: getters
    // public double getSaldo() { return saldo; }
    
    // ✅ HAAZ: métodos de dominio
    public double consultarSaldo() {
        return saldo;
    }
    
    public boolean tienefondosSuficientes(double monto) {
        return saldo >= monto;
    }
    
    public boolean puedoRetirar(double monto) {
        return tienefondosSuficientes(monto);
    }
    
    public void depositar(double monto) {
        if (monto <= 0) throw new IllegalArgumentException("Monto inválido");
        saldo += monto;
    }
    
    public void retirar(double monto) {
        if (!puedoRetirar(monto)) {
            throw new SaldoInsuficienteException("Fondos insuficientes");
        }
        saldo -= monto;
    }
}

// ✅ Tests sin getters
@Test
void testCuentaDepositoCorrectamente() {
    CuentaBancaria cuenta = new CuentaBancaria(1000);
    cuenta.depositar(500);
    
    // Verifica mediante método de dominio, no getter
    assertEquals(1500, cuenta.consultarSaldo());
}

@Test
void testCuentaTienefondosSuficientes() {
    CuentaBancaria cuenta = new CuentaBancaria(1000);
    
    // Verifica comportamiento: ¿tengo fondos?
    assertTrue(cuenta.puedoRetirar(500));
    assertTrue(cuenta.tienefondosSuficientes(1000));
    assertFalse(cuenta.puedoRetirar(1500));
}

@Test
void testCuentaRetiraFondos() {
    CuentaBancaria cuenta = new CuentaBancaria(1000);
    cuenta.retirar(300);
    
    // Verifica estado mediante comportamiento observable
    assertEquals(700, cuenta.consultarSaldo());
}

@Test
void testCuentaRetiraFondosInsuficientes() {
    CuentaBancaria cuenta = new CuentaBancaria(1000);
    
    // Verifica manejo de errores
    assertThrows(
        SaldoInsuficienteException.class,
        () -> cuenta.retirar(1500)
    );
}
Diferencia: Getter vs Método de Dominio
GetterMétodo de Dominio
getSaldo()consultarSaldo()
Expone estado internoExpresa intención del negocio
Trivial, sin lógicaPuede encapsular validación
Acoplado a impl. internaContrato semánticamente significativo
Tests frágilesTests robustos

Testing de Invariantes sin Getters

Los invariantes son propiedades que siempre deben ser verdaderas. Los tests verifican invariantes a través de comportamiento observable:

public class CuentaBancaria {
    private double saldo;
    private int depositos;
    private int retiros;
    
    // Invariantes:
    // 1. saldo >= 0
    // 2. depositos >= 0
    // 3. retiros >= 0
    
    public void depositar(double monto) {
        if (monto <= 0) throw new IllegalArgumentException();
        saldo += monto;
        depositos++;
    }
    
    public void retirar(double monto) {
        if (monto > saldo) throw new SaldoInsuficienteException();
        saldo -= monto;
        retiros++;
    }
    
    // Métodos para verificar invariantes (sin exponer detalles)
    public int obtenerNroDepositos() {
        return depositos;
    }
    
    public int obtenerNroRetiros() {
        return retiros;
    }
}

// Tests verifican invariantes
@Test
void testInvarianteSaldoNunca Negativo() {
    CuentaBancaria cuenta = new CuentaBancaria(1000);
    
    // No puedo hacer: assertEquals(1000, cuenta.saldo) [private]
    // En su lugar, verifica que el comportamiento mantiene invariante
    
    assertTrue(cuenta.tienefondosSuficientes(0));    // saldo >= 0
    assertThrows(
        SaldoInsuficienteException.class,
        () -> cuenta.retirar(2000)
    );
}

@Test
void testInvarianteContador() {
    CuentaBancaria cuenta = new CuentaBancaria(1000);
    
    cuenta.depositar(100);
    cuenta.depositar(200);
    cuenta.retirar(50);
    
    // Verifica invariantes mediante métodos públicos
    assertEquals(2, cuenta.obtenerNroDepositos());
    assertEquals(1, cuenta.obtenerNroRetiros());
}

Patrones para Testing de Código Encapsulado

  1. Usa métodos públicos que representen acciones del dominio

    • No getSaldo(), usa consultarSaldo()

    • No setEstado(), usa métodos semánticamente significativos

  2. Verifica comportamiento a través de múltiples acciones

    // En lugar de verificar estado:
    cuenta.depositar(100);
    assertEquals(1100, cuenta.getSaldo());  // ❌
    
    // Verifica comportamiento completo:
    cuenta.depositar(100);
    assertTrue(cuenta.puedoRetirar(1100));   // ✅
  3. Diseña excepciones para verificar invariantes

    // En lugar de:
    cuenta.retirar(2000);
    assertEquals(1000, cuenta.getSaldo());  // ❌
    
    // Verifica que la clase rechaza operaciones inválidas:
    assertThrows(SaldoInsuficienteException.class,
        () -> cuenta.retirar(2000));  // ✅
  4. Utiliza métodos “observables” para verificar estado sin getters

    public class Usuario {
        private String nombre;
        private String email;
        
        // En lugar de getNombre():
        public boolean tieneNombre(String nombre) {
            return this.nombre.equals(nombre);
        }
        
        public boolean tieneEmail(String email) {
            return this.email.equals(email);
        }
    }
    
    // Test:
    @Test
    void testUsuarioTiene NombreYEmail() {
        Usuario u = new Usuario("Ana", "ana@example.com");
        assertTrue(u.tieneNombre("Ana"));
        assertTrue(u.tieneEmail("ana@example.com"));
    }

Ver 0x200C - No usar métodos getter/setter si violan encapsulamiento y 0x2011 - No exponer detalles internos mediante getters (TP9 - Agenda) para más detalles sobre política de encapsulamiento en esta cátedra.


Comencemos con una clase simple:

public class CuentaBancaria {
    private String titular;
    private double saldo;
    
    public CuentaBancaria(String titular, double saldoInicial) {
        if (titular == null || titular.isBlank()) {
            throw new IllegalArgumentException("Titular requerido");
        }
        if (saldoInicial < 0) {
            throw new IllegalArgumentException("Saldo inicial no puede ser negativo");
        }
        this.titular = titular;
        this.saldo = saldoInicial;
    }
    
    public void depositar(double monto) {
        if (monto <= 0) {
            throw new IllegalArgumentException("Monto debe ser positivo");
        }
        this.saldo += monto;
    }
    
    public void extraer(double monto) {
        if (monto <= 0) {
            throw new IllegalArgumentException("Monto debe ser positivo");
        }
        if (monto > saldo) {
            throw new SaldoInsuficienteException(saldo, monto);
        }
        this.saldo -= monto;
    }
    
    public double getSaldo() {
        return saldo;
    }
    
    public String getTitular() {
        return titular;
    }
}

Tests para la clase CuentaBancaria:

import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import static org.junit.jupiter.api.Assertions.*;

class CuentaBancariaTest {
    
    @Nested
    @DisplayName("Creación de cuenta")
    class CreacionCuenta {
        
        @Test
        @DisplayName("Se crea con titular y saldo inicial")
        void crearCuentaConDatosValidos() {
            CuentaBancaria cuenta = new CuentaBancaria("Juan Pérez", 1000);
            
            assertEquals("Juan Pérez", cuenta.getTitular());
            assertEquals(1000, cuenta.getSaldo());
        }
        
        @Test
        @DisplayName("Se puede crear con saldo inicial cero")
        void crearCuentaConSaldoCero() {
            CuentaBancaria cuenta = new CuentaBancaria("María García", 0);
            
            assertEquals(0, cuenta.getSaldo());
        }
        
        @Test
        @DisplayName("Rechaza titular nulo")
        void rechazaTitularNulo() {
            assertThrows(IllegalArgumentException.class, () -> 
                new CuentaBancaria(null, 100)
            );
        }
        
        @Test
        @DisplayName("Rechaza titular vacío")
        void rechazaTitularVacio() {
            assertThrows(IllegalArgumentException.class, () -> 
                new CuentaBancaria("   ", 100)
            );
        }
        
        @Test
        @DisplayName("Rechaza saldo inicial negativo")
        void rechazaSaldoNegativo() {
            assertThrows(IllegalArgumentException.class, () -> 
                new CuentaBancaria("Juan", -100)
            );
        }
    }
    
    @Nested
    @DisplayName("Depósitos")
    class Depositos {
        
        private CuentaBancaria cuenta;
        
        @BeforeEach
        void setUp() {
            cuenta = new CuentaBancaria("Test User", 500);
        }
        
        @Test
        @DisplayName("Depósito aumenta el saldo")
        void depositoAumentaSaldo() {
            cuenta.depositar(200);
            
            assertEquals(700, cuenta.getSaldo());
        }
        
        @Test
        @DisplayName("Múltiples depósitos se acumulan")
        void multiplesDepositos() {
            cuenta.depositar(100);
            cuenta.depositar(50);
            cuenta.depositar(25);
            
            assertEquals(675, cuenta.getSaldo());
        }
        
        @Test
        @DisplayName("Rechaza depósito de monto cero")
        void rechazaDepositoCero() {
            assertThrows(IllegalArgumentException.class, () -> 
                cuenta.depositar(0)
            );
        }
        
        @Test
        @DisplayName("Rechaza depósito de monto negativo")
        void rechazaDepositoNegativo() {
            assertThrows(IllegalArgumentException.class, () -> 
                cuenta.depositar(-50)
            );
        }
    }
    
    @Nested
    @DisplayName("Extracciones")
    class Extracciones {
        
        private CuentaBancaria cuenta;
        
        @BeforeEach
        void setUp() {
            cuenta = new CuentaBancaria("Test User", 1000);
        }
        
        @Test
        @DisplayName("Extracción disminuye el saldo")
        void extraccionDisminuyeSaldo() {
            cuenta.extraer(300);
            
            assertEquals(700, cuenta.getSaldo());
        }
        
        @Test
        @DisplayName("Puede extraer todo el saldo")
        void extraerTodoElSaldo() {
            cuenta.extraer(1000);
            
            assertEquals(0, cuenta.getSaldo());
        }
        
        @Test
        @DisplayName("Rechaza extracción mayor al saldo")
        void rechazaExtraccionExcesiva() {
            SaldoInsuficienteException ex = assertThrows(
                SaldoInsuficienteException.class, 
                () -> cuenta.extraer(1500)
            );
            
            assertEquals(1000, ex.getSaldoActual());
            assertEquals(1500, ex.getMontoSolicitado());
        }
        
        @Test
        @DisplayName("Rechaza extracción de monto cero")
        void rechazaExtraccionCero() {
            assertThrows(IllegalArgumentException.class, () -> 
                cuenta.extraer(0)
            );
        }
        
        @Test
        @DisplayName("El saldo no cambia si falla la extracción")
        void saldoNoCambiaSiFalla() {
            try {
                cuenta.extraer(2000);
            } catch (SaldoInsuficienteException e) {
                // Esperado
            }
            
            assertEquals(1000, cuenta.getSaldo());
        }
    }
}

Organización de Tests

Los tests anteriores muestran varias buenas prácticas:

  1. Clases anidadas (@Nested): Agrupan tests relacionados

  2. Nombres descriptivos (@DisplayName): Documentan el comportamiento

  3. Setup compartido (@BeforeEach): Evita duplicación

  4. Un assert por test (idealmente): Cada test verifica una cosa


Test-Driven Development (TDD)

¿Qué es TDD?

Test-Driven Development es una técnica donde los tests se escriben antes que el código de producción. No es solo una técnica de testing, sino una técnica de diseño.

Las tres reglas de TDD (Uncle Bob):

  1. No escribir código de producción excepto para hacer pasar un test que falla

  2. No escribir más de un test unitario que sea suficiente para fallar

  3. No escribir más código de producción del necesario para pasar el test

Ejemplo TDD: Implementando una Pila

Implementemos una Pila usando TDD paso a paso.

Iteración 1: RED - Test para pila vacía

@Test
void pilaRecienCreadaEstaVacia() {
    Pila<Integer> pila = new Pila<>();
    
    assertTrue(pila.estaVacia());
}

Este test no compila porque Pila no existe. ¡Eso es RED!

Iteración 1: GREEN - Código mínimo

public class Pila<T> {
    public boolean estaVacia() {
        return true;  // ¡Código mínimo para pasar!
    }
}

Test pasa. ¿Parece trampa? Es intencional. Fuerza a escribir más tests.

Iteración 2: RED - Test para push

@Test
void pilaDespuesDePushNoEstaVacia() {
    Pila<Integer> pila = new Pila<>();
    
    pila.push(1);
    
    assertFalse(pila.estaVacia());
}

Falla porque push no existe y estaVacia() siempre retorna true.

Iteración 2: GREEN

public class Pila<T> {
    private boolean tieneElementos = false;
    
    public void push(T elemento) {
        tieneElementos = true;
    }
    
    public boolean estaVacia() {
        return !tieneElementos;
    }
}

Iteración 3: RED - Test para pop

@Test
void popRetornaUltimoElementoPusheado() {
    Pila<Integer> pila = new Pila<>();
    pila.push(42);
    
    int elemento = pila.pop();
    
    assertEquals(42, elemento);
}

Iteración 3: GREEN

public class Pila<T> {
    private T elemento;
    private boolean tieneElementos = false;
    
    public void push(T elemento) {
        this.elemento = elemento;
        tieneElementos = true;
    }
    
    public T pop() {
        tieneElementos = false;
        return elemento;
    }
    
    public boolean estaVacia() {
        return !tieneElementos;
    }
}

Iteración 4: RED - Test para múltiples elementos (LIFO)

@Test
void popRetornaElementosEnOrdenLIFO() {
    Pila<String> pila = new Pila<>();
    pila.push("primero");
    pila.push("segundo");
    pila.push("tercero");
    
    assertEquals("tercero", pila.pop());
    assertEquals("segundo", pila.pop());
    assertEquals("primero", pila.pop());
}

Este test falla porque solo guardamos un elemento.

Iteración 4: GREEN

public class Pila<T> {
    private List<T> elementos = new ArrayList<>();
    
    public void push(T elemento) {
        elementos.add(elemento);
    }
    
    public T pop() {
        if (elementos.isEmpty()) {
            throw new EmptyStackException();
        }
        return elementos.remove(elementos.size() - 1);
    }
    
    public boolean estaVacia() {
        return elementos.isEmpty();
    }
}

Iteración 5: REFACTOR

El código está limpio, pero podemos agregar más tests para casos límite:

@Test
void popEnPilaVaciaLanzaExcepcion() {
    Pila<Integer> pila = new Pila<>();
    
    assertThrows(EmptyStackException.class, () -> pila.pop());
}

@Test
void sizeRetornaCantidadDeElementos() {
    Pila<Integer> pila = new Pila<>();
    assertEquals(0, pila.size());
    
    pila.push(1);
    assertEquals(1, pila.size());
    
    pila.push(2);
    pila.push(3);
    assertEquals(3, pila.size());
}

@Test
void peekRetornaTopesinRemover() {
    Pila<Integer> pila = new Pila<>();
    pila.push(10);
    pila.push(20);
    
    assertEquals(20, pila.peek());
    assertEquals(20, pila.peek());  // No cambió
    assertEquals(2, pila.size());    // No se removió
}

Beneficios de TDD


Dobles de Prueba (Test Doubles)

El Problema de las Dependencias

¿Qué pasa cuando una clase depende de otra? ¿Cómo la testeamos en aislamiento?

public class ServicioNotificaciones {
    private final EnviadorEmail enviador;
    private final RepositorioUsuarios repositorio;
    
    public ServicioNotificaciones(EnviadorEmail enviador, 
                                   RepositorioUsuarios repositorio) {
        this.enviador = enviador;
        this.repositorio = repositorio;
    }
    
    public void notificarPromocion(String promocion) {
        List<Usuario> usuarios = repositorio.obtenerActivos();
        for (Usuario u : usuarios) {
            enviador.enviar(u.getEmail(), "Nueva promoción", promocion);
        }
    }
}

Si queremos testear ServicioNotificaciones:

Solución: Reemplazar las dependencias con dobles de prueba.

Tipos de Dobles de Prueba

Tipos de test doubles y el rol que cumplen al aislar dependencias reales.

Figure 3:Tipos de test doubles y el rol que cumplen al aislar dependencias reales.

Ejemplo: Usando Stubs

Creamos implementaciones simples para los tests:

// Stub del repositorio que retorna usuarios predefinidos
class RepositorioUsuariosStub implements RepositorioUsuarios {
    private List<Usuario> usuarios;
    
    public RepositorioUsuariosStub(Usuario... usuarios) {
        this.usuarios = Arrays.asList(usuarios);
    }
    
    @Override
    public List<Usuario> obtenerActivos() {
        return usuarios;
    }
}

// Spy del enviador que registra los emails enviados
class EnviadorEmailSpy implements EnviadorEmail {
    private List<EmailEnviado> emailsEnviados = new ArrayList<>();
    
    @Override
    public void enviar(String destinatario, String asunto, String cuerpo) {
        emailsEnviados.add(new EmailEnviado(destinatario, asunto, cuerpo));
    }
    
    public int cantidadEnviados() {
        return emailsEnviados.size();
    }
    
    public boolean seEnvioA(String email) {
        return emailsEnviados.stream()
            .anyMatch(e -> e.destinatario().equals(email));
    }
    
    public EmailEnviado ultimoEnviado() {
        return emailsEnviados.get(emailsEnviados.size() - 1);
    }
}

record EmailEnviado(String destinatario, String asunto, String cuerpo) {}

Tests usando los dobles:

class ServicioNotificacionesTest {
    
    @Test
    void notificaATodosLosUsuariosActivos() {
        // Arrange
        Usuario juan = new Usuario("juan@mail.com", "Juan");
        Usuario maria = new Usuario("maria@mail.com", "María");
        RepositorioUsuarios repo = new RepositorioUsuariosStub(juan, maria);
        EnviadorEmailSpy enviador = new EnviadorEmailSpy();
        
        ServicioNotificaciones servicio = new ServicioNotificaciones(enviador, repo);
        
        // Act
        servicio.notificarPromocion("¡50% de descuento!");
        
        // Assert
        assertEquals(2, enviador.cantidadEnviados());
        assertTrue(enviador.seEnvioA("juan@mail.com"));
        assertTrue(enviador.seEnvioA("maria@mail.com"));
    }
    
    @Test
    void noEnviaEmailsSiNoHayUsuarios() {
        RepositorioUsuarios repo = new RepositorioUsuariosStub();  // Sin usuarios
        EnviadorEmailSpy enviador = new EnviadorEmailSpy();
        ServicioNotificaciones servicio = new ServicioNotificaciones(enviador, repo);
        
        servicio.notificarPromocion("Promoción");
        
        assertEquals(0, enviador.cantidadEnviados());
    }
    
    @Test
    void emailContieneTextoDePromocion() {
        Usuario usuario = new Usuario("test@mail.com", "Test");
        RepositorioUsuarios repo = new RepositorioUsuariosStub(usuario);
        EnviadorEmailSpy enviador = new EnviadorEmailSpy();
        ServicioNotificaciones servicio = new ServicioNotificaciones(enviador, repo);
        
        servicio.notificarPromocion("¡Oferta especial!");
        
        EmailEnviado email = enviador.ultimoEnviado();
        assertEquals("Nueva promoción", email.asunto());
        assertEquals("¡Oferta especial!", email.cuerpo());
    }
}

Usando Frameworks de Mocking: Mockito

En lugar de crear dobles manualmente, podemos usar frameworks como Mockito:

import static org.mockito.Mockito.*;
import static org.mockito.ArgumentMatchers.*;

class ServicioNotificacionesTestConMockito {
    
    @Mock
    private EnviadorEmail enviadorMock;
    
    @Mock
    private RepositorioUsuarios repositorioMock;
    
    @InjectMocks
    private ServicioNotificaciones servicio;
    
    @BeforeEach
    void setUp() {
        MockitoAnnotations.openMocks(this);
    }
    
    @Test
    void notificaATodosLosUsuariosActivos() {
        // Arrange - configurar comportamiento del mock
        List<Usuario> usuarios = List.of(
            new Usuario("juan@mail.com", "Juan"),
            new Usuario("maria@mail.com", "María")
        );
        when(repositorioMock.obtenerActivos()).thenReturn(usuarios);
        
        // Act
        servicio.notificarPromocion("¡Descuento!");
        
        // Assert - verificar interacciones
        verify(enviadorMock, times(2)).enviar(anyString(), anyString(), anyString());
        verify(enviadorMock).enviar(eq("juan@mail.com"), anyString(), eq("¡Descuento!"));
        verify(enviadorMock).enviar(eq("maria@mail.com"), anyString(), eq("¡Descuento!"));
    }
    
    @Test
    void noEnviaEmailsSiNoHayUsuarios() {
        when(repositorioMock.obtenerActivos()).thenReturn(Collections.emptyList());
        
        servicio.notificarPromocion("Oferta");
        
        verify(enviadorMock, never()).enviar(anyString(), anyString(), anyString());
    }
}

Funciones principales de Mockito:

FunciónPropósito
mock(Clase.class)Crear un mock
when(...).thenReturn(...)Configurar comportamiento
when(...).thenThrow(...)Configurar que lance excepción
verify(mock).metodo()Verificar que se llamó
verify(mock, times(n))Verificar cantidad de llamadas
verify(mock, never())Verificar que NO se llamó
any(), eq(), anyString()Matchers para argumentos

Diseño para Testeabilidad

Características del Código Testeable

No todo código es fácil de testear. Un código testeable tiene ciertas características:

Rasgos de un diseño testeable: dependencias reemplazables, bajo acoplamiento y comportamiento predecible.

Figure 4:Rasgos de un diseño testeable: dependencias reemplazables, bajo acoplamiento y comportamiento predecible.

Código Difícil de Testear

Problema 1: Dependencias creadas internamente

// ✗ DIFÍCIL DE TESTEAR
public class ReporteVentas {
    public String generar(LocalDate fecha) {
        // Crea la dependencia internamente - ¡no podemos reemplazarla!
        ConexionBaseDatos conexion = new ConexionBaseDatos("prod-server");
        List<Venta> ventas = conexion.obtenerVentas(fecha);
        // ... generar reporte
    }
}

Solución: Inyección de dependencias

// ✓ FÁCIL DE TESTEAR
public class ReporteVentas {
    private final RepositorioVentas repositorio;
    
    public ReporteVentas(RepositorioVentas repositorio) {
        this.repositorio = repositorio;  // Dependencia inyectada
    }
    
    public String generar(LocalDate fecha) {
        List<Venta> ventas = repositorio.obtenerVentas(fecha);
        // ... generar reporte
    }
}

// En el test:
ReporteVentas reporte = new ReporteVentas(repositorioMock);

Problema 2: Dependencia de fecha/hora actual

// ✗ DIFÍCIL DE TESTEAR
public class Pedido {
    private LocalDateTime fechaCreacion;
    
    public Pedido() {
        this.fechaCreacion = LocalDateTime.now();  // Indeterminístico
    }
    
    public boolean estaVencido() {
        return fechaCreacion.plusDays(7).isBefore(LocalDateTime.now());
    }
}

Solución: Inyectar un Clock o la fecha

// ✓ FÁCIL DE TESTEAR
public class Pedido {
    private LocalDateTime fechaCreacion;
    private final Clock clock;
    
    public Pedido(Clock clock) {
        this.clock = clock;
        this.fechaCreacion = LocalDateTime.now(clock);
    }
    
    public boolean estaVencido() {
        return fechaCreacion.plusDays(7).isBefore(LocalDateTime.now(clock));
    }
}

// En el test:
Clock clockFijo = Clock.fixed(Instant.parse("2024-01-15T10:00:00Z"), ZoneId.systemDefault());
Pedido pedido = new Pedido(clockFijo);
// Ahora podés controlar el tiempo en los tests

Problema 3: Singleton

// ✗ DIFÍCIL DE TESTEAR
public class Logger {
    private static final Logger INSTANCE = new Logger();
    private Logger() {}
    public static Logger getInstance() { return INSTANCE; }
}

public class Servicio {
    public void procesar() {
        Logger.getInstance().log("Procesando...");  // Acoplamiento al singleton
    }
}

Solución: Inyectar la dependencia

// ✓ FÁCIL DE TESTEAR
public interface Logger {
    void log(String mensaje);
}

public class Servicio {
    private final Logger logger;
    
    public Servicio(Logger logger) {
        this.logger = logger;
    }
    
    public void procesar() {
        logger.log("Procesando...");
    }
}

Relación SOLID-Testing

Los principios SOLID (ver OOP 6: Principios SOLID) facilitan el testing:


Testing de Herencia y Polimorfismo

Testing de Jerarquías de Clases

Cuando tenemos una jerarquía de herencia, ¿cómo organizamos los tests?

public abstract class Figura {
    public abstract double area();
    public abstract double perimetro();
}

public class Rectangulo extends Figura {
    private final double ancho;
    private final double alto;
    
    public Rectangulo(double ancho, double alto) {
        if (ancho <= 0 || alto <= 0) {
            throw new IllegalArgumentException("Dimensiones deben ser positivas");
        }
        this.ancho = ancho;
        this.alto = alto;
    }
    
    @Override
    public double area() {
        return ancho * alto;
    }
    
    @Override
    public double perimetro() {
        return 2 * (ancho + alto);
    }
}

public class Cuadrado extends Rectangulo {
    public Cuadrado(double lado) {
        super(lado, lado);
    }
}

public class Circulo extends Figura {
    private final double radio;
    
    public Circulo(double radio) {
        if (radio <= 0) {
            throw new IllegalArgumentException("Radio debe ser positivo");
        }
        this.radio = radio;
    }
    
    @Override
    public double area() {
        return Math.PI * radio * radio;
    }
    
    @Override
    public double perimetro() {
        return 2 * Math.PI * radio;
    }
}

Estrategia 1: Tests específicos por clase

class RectanguloTest {
    
    @Test
    void calculaAreaCorrectamente() {
        Rectangulo r = new Rectangulo(4, 5);
        assertEquals(20, r.area(), 0.001);
    }
    
    @Test
    void calculaPerimetroCorrectamente() {
        Rectangulo r = new Rectangulo(4, 5);
        assertEquals(18, r.perimetro(), 0.001);
    }
    
    @Test
    void rechazaDimensionesNegativas() {
        assertThrows(IllegalArgumentException.class, () -> 
            new Rectangulo(-1, 5)
        );
    }
}

class CirculoTest {
    
    @Test
    void calculaAreaCorrectamente() {
        Circulo c = new Circulo(3);
        assertEquals(Math.PI * 9, c.area(), 0.001);
    }
    
    @Test
    void calculaPerimetroCorrectamente() {
        Circulo c = new Circulo(3);
        assertEquals(2 * Math.PI * 3, c.perimetro(), 0.001);
    }
}

Estrategia 2: Tests parametrizados para comportamiento común

class FiguraContractTest {
    
    static Stream<Arguments> figuras() {
        return Stream.of(
            Arguments.of(new Rectangulo(4, 5), 20.0, 18.0),
            Arguments.of(new Cuadrado(4), 16.0, 16.0),
            Arguments.of(new Circulo(1), Math.PI, 2 * Math.PI)
        );
    }
    
    @ParameterizedTest
    @MethodSource("figuras")
    void areaEsPositiva(Figura figura, double areaEsperada, double perimetroEsperado) {
        assertTrue(figura.area() > 0, "Área debe ser positiva");
    }
    
    @ParameterizedTest
    @MethodSource("figuras")
    void perimetroEsPositivo(Figura figura, double areaEsperada, double perimetroEsperado) {
        assertTrue(figura.perimetro() > 0, "Perímetro debe ser positivo");
    }
    
    @ParameterizedTest
    @MethodSource("figuras")
    void calculaAreaCorrectamente(Figura figura, double areaEsperada, double perimetroEsperado) {
        assertEquals(areaEsperada, figura.area(), 0.001);
    }
    
    @ParameterizedTest
    @MethodSource("figuras")
    void calculaPerimetroCorrectamente(Figura figura, double areaEsperada, double perimetroEsperado) {
        assertEquals(perimetroEsperado, figura.perimetro(), 0.001);
    }
}

Testing de Comportamiento Polimórfico

Cuando el código usa polimorfismo, testear se vuelve más interesante:

public class CalculadoraGeometrica {
    
    public double areaTotal(List<Figura> figuras) {
        return figuras.stream()
            .mapToDouble(Figura::area)
            .sum();
    }
    
    public Figura mayorArea(List<Figura> figuras) {
        return figuras.stream()
            .max(Comparator.comparing(Figura::area))
            .orElseThrow(() -> new IllegalArgumentException("Lista vacía"));
    }
}
class CalculadoraGeometricaTest {
    
    private CalculadoraGeometrica calculadora;
    
    @BeforeEach
    void setUp() {
        calculadora = new CalculadoraGeometrica();
    }
    
    @Test
    void calculaAreaTotalDeFigurasMixtas() {
        List<Figura> figuras = List.of(
            new Rectangulo(2, 3),    // área = 6
            new Cuadrado(4),          // área = 16
            new Circulo(1)            // área = π ≈ 3.14
        );
        
        double areaTotal = calculadora.areaTotal(figuras);
        
        assertEquals(6 + 16 + Math.PI, areaTotal, 0.01);
    }
    
    @Test
    void encuentraFiguraConMayorArea() {
        Cuadrado grande = new Cuadrado(10);  // área = 100
        List<Figura> figuras = List.of(
            new Rectangulo(2, 3),
            grande,
            new Circulo(3)
        );
        
        Figura mayor = calculadora.mayorArea(figuras);
        
        assertSame(grande, mayor);
    }
    
    @Test
    void listaVaciaLanzaExcepcion() {
        assertThrows(IllegalArgumentException.class, () -> 
            calculadora.mayorArea(Collections.emptyList())
        );
    }
}

Buenas Prácticas de Testing

Principios FIRST

Los buenos tests siguen el acrónimo FIRST:

Nombres Descriptivos

Un test bien nombrado actúa como documentación:

// ✗ Nombres pobres
@Test
void test1() { ... }

@Test
void testDepositar() { ... }

// ✓ Nombres descriptivos
@Test
void depositarMontoPositivoAumentaElSaldo() { ... }

@Test
void depositarMontoNegativoLanzaExcepcion() { ... }

@Test
void extraerMasDelSaldoDisponibleFalla() { ... }

Patrones para nombrar tests:

PatrónEjemplo
metodo_escenario_resultadodepositar_montoPositivo_aumentaSaldo
dado_cuando_entoncesdadoCuentaConSaldo_cuandoExtraeTodo_entoncesQuedaEnCero
should_whenshouldThrowException_whenAmountIsNegative
Oración descriptivaextraerMasDelSaldoLanzaSaldoInsuficienteException

Un Concepto por Test

Cada test debe verificar una sola cosa:

// ✗ Test que verifica múltiples cosas
@Test
void testOperacionesBancarias() {
    CuentaBancaria cuenta = new CuentaBancaria("Juan", 1000);
    
    cuenta.depositar(500);
    assertEquals(1500, cuenta.getSaldo());
    
    cuenta.extraer(200);
    assertEquals(1300, cuenta.getSaldo());
    
    assertThrows(SaldoInsuficienteException.class, () -> 
        cuenta.extraer(2000)
    );
}

// ✓ Tests separados por concepto
@Test
void depositarAumentaElSaldo() {
    CuentaBancaria cuenta = new CuentaBancaria("Juan", 1000);
    cuenta.depositar(500);
    assertEquals(1500, cuenta.getSaldo());
}

@Test
void extraerDisminuyeElSaldo() {
    CuentaBancaria cuenta = new CuentaBancaria("Juan", 1000);
    cuenta.extraer(200);
    assertEquals(800, cuenta.getSaldo());
}

@Test
void extraerMasDelSaldoFalla() {
    CuentaBancaria cuenta = new CuentaBancaria("Juan", 1000);
    assertThrows(SaldoInsuficienteException.class, () -> 
        cuenta.extraer(2000)
    );
}

Secciones Claramente Separadas

Mantené las tres secciones del test visualmente separadas:

@Test
void transferenciaMueveMontoEntreCuentas() {
    // Arrange
    CuentaBancaria origen = new CuentaBancaria("A", 1000);
    CuentaBancaria destino = new CuentaBancaria("B", 500);
    ServicioTransferencias servicio = new ServicioTransferencias();
    
    // Act
    servicio.transferir(origen, destino, 300);
    
    // Assert
    assertEquals(700, origen.getSaldo());
    assertEquals(800, destino.getSaldo());
}

Patrones de Testing Avanzados

Object Mother

Un patrón para crear objetos de test de manera consistente:

public class CuentaMother {
    
    public static CuentaBancaria cuentaVacia() {
        return new CuentaBancaria("Test User", 0);
    }
    
    public static CuentaBancaria cuentaConSaldo(double saldo) {
        return new CuentaBancaria("Test User", saldo);
    }
    
    public static CuentaBancaria cuentaPremium() {
        CuentaBancaria cuenta = new CuentaBancaria("Premium User", 10000);
        cuenta.activarBeneficiosPremium();
        return cuenta;
    }
    
    public static CuentaBancaria cuentaBloqueada() {
        CuentaBancaria cuenta = new CuentaBancaria("Blocked User", 500);
        cuenta.bloquear();
        return cuenta;
    }
}

// Uso en tests
@Test
void cuentaBloqueadaNoPuedeExtraer() {
    CuentaBancaria cuenta = CuentaMother.cuentaBloqueada();
    
    assertThrows(CuentaBloqueadaException.class, () -> 
        cuenta.extraer(100)
    );
}

Builder para Tests

Para objetos complejos con muchos atributos:

public class PedidoBuilder {
    private String cliente = "Cliente Default";
    private LocalDate fecha = LocalDate.now();
    private List<ItemPedido> items = new ArrayList<>();
    private EstadoPedido estado = EstadoPedido.PENDIENTE;
    private Direccion direccionEntrega = DireccionMother.direccionDefault();
    
    public static PedidoBuilder unPedido() {
        return new PedidoBuilder();
    }
    
    public PedidoBuilder deCliente(String cliente) {
        this.cliente = cliente;
        return this;
    }
    
    public PedidoBuilder conFecha(LocalDate fecha) {
        this.fecha = fecha;
        return this;
    }
    
    public PedidoBuilder conItem(String producto, int cantidad, double precio) {
        this.items.add(new ItemPedido(producto, cantidad, precio));
        return this;
    }
    
    public PedidoBuilder conEstado(EstadoPedido estado) {
        this.estado = estado;
        return this;
    }
    
    public PedidoBuilder entregadoEn(Direccion direccion) {
        this.direccionEntrega = direccion;
        return this;
    }
    
    public Pedido build() {
        Pedido pedido = new Pedido(cliente, fecha);
        items.forEach(pedido::agregarItem);
        pedido.setEstado(estado);
        pedido.setDireccionEntrega(direccionEntrega);
        return pedido;
    }
}

// Uso en tests
@Test
void pedidoConMultiplesItemsCalculaTotalCorrectamente() {
    Pedido pedido = PedidoBuilder.unPedido()
        .deCliente("Juan")
        .conItem("Producto A", 2, 100.0)
        .conItem("Producto B", 1, 50.0)
        .build();
    
    assertEquals(250.0, pedido.getTotal(), 0.01);
}

Cuándo usar cada patrón

Comparación entre Object Mother, Test Data Builder y una combinación de ambos para crear datos de prueba.

Figure 5:Comparación entre Object Mother, Test Data Builder y una combinación de ambos para crear datos de prueba.


Resumen

Resumen de fundamentos, TDD, dobles de prueba, diseño testeable y buenas prácticas de testing en POO.

Figure 6:Resumen de fundamentos, TDD, dobles de prueba, diseño testeable y buenas prácticas de testing en POO.


Ejercicios

Solution to Exercise 1
class CarritoComprasTest {
    
    private CarritoCompras carrito;
    private Producto laptop;
    private Producto mouse;
    
    @BeforeEach
    void setUp() {
        carrito = new CarritoCompras();
        laptop = new Producto("Laptop", 1000.0);
        mouse = new Producto("Mouse", 50.0);
    }
    
    @Nested
    @DisplayName("Carrito vacío")
    class CarritoVacio {
        
        @Test
        void carritoNuevoEstaVacio() {
            assertTrue(carrito.estaVacio());
            assertEquals(0, carrito.cantidadItems());
        }
        
        @Test
        void carritoVacioTieneTotalCero() {
            assertEquals(0, carrito.total(), 0.001);
        }
    }
    
    @Nested
    @DisplayName("Agregar productos")
    class AgregarProductos {
        
        @Test
        void agregarProductoLoIncluye() {
            carrito.agregar(laptop, 1);
            
            assertFalse(carrito.estaVacio());
            assertEquals(1, carrito.cantidadItems());
        }
        
        @Test
        void agregarMultiplesUnidadesDelMismoProducto() {
            carrito.agregar(laptop, 3);
            
            assertEquals(3, carrito.cantidadItems());
            assertEquals(3000.0, carrito.subtotal(), 0.001);
        }
        
        @Test
        void agregarDiferentesProductos() {
            carrito.agregar(laptop, 1);
            carrito.agregar(mouse, 2);
            
            assertEquals(3, carrito.cantidadItems());
            assertEquals(1100.0, carrito.subtotal(), 0.001);
        }
    }
    
    @Nested
    @DisplayName("Remover productos")
    class RemoverProductos {
        
        @Test
        void removerProductoLoElimina() {
            carrito.agregar(laptop, 1);
            carrito.agregar(mouse, 1);
            
            carrito.remover(laptop);
            
            assertEquals(1, carrito.cantidadItems());
            assertEquals(50.0, carrito.subtotal(), 0.001);
        }
        
        @Test
        void removerTodosLosProductosDejaCarritoVacio() {
            carrito.agregar(laptop, 1);
            carrito.remover(laptop);
            
            assertTrue(carrito.estaVacio());
        }
        
        @Test
        void vaciarCarritoRemoverTodo() {
            carrito.agregar(laptop, 2);
            carrito.agregar(mouse, 5);
            
            carrito.vaciar();
            
            assertTrue(carrito.estaVacio());
            assertEquals(0, carrito.total(), 0.001);
        }
    }
    
    @Nested
    @DisplayName("Descuentos")
    class Descuentos {
        
        @Test
        void aplicarDescuentoReduceTotal() {
            carrito.agregar(laptop, 1);  // $1000
            
            carrito.aplicarDescuento(10);  // 10%
            
            assertEquals(900.0, carrito.total(), 0.001);
        }
        
        @Test
        void descuentoCienPorcientoResultaEnCero() {
            carrito.agregar(laptop, 1);
            
            carrito.aplicarDescuento(100);
            
            assertEquals(0, carrito.total(), 0.001);
        }
        
        @Test
        void subtotalNoCambiaConDescuento() {
            carrito.agregar(laptop, 1);
            
            carrito.aplicarDescuento(20);
            
            assertEquals(1000.0, carrito.subtotal(), 0.001);
            assertEquals(800.0, carrito.total(), 0.001);
        }
        
        @Test
        void rechazaDescuentoNegativo() {
            assertThrows(IllegalArgumentException.class, () ->
                carrito.aplicarDescuento(-10)
            );
        }
        
        @Test
        void rechazaDescuentoMayorACien() {
            assertThrows(IllegalArgumentException.class, () ->
                carrito.aplicarDescuento(110)
            );
        }
    }
}
Solution to Exercise 2

Ciclo TDD paso a paso:

// ITERACIÓN 1: RED - Test básico de suma
@Test
void sumaBasica() {
    Calculadora calc = new Calculadora();
    assertEquals(5, calc.evaluar("2 + 3"));
}

// ITERACIÓN 1: GREEN - Implementación mínima
public class Calculadora {
    public int evaluar(String expresion) {
        return 5;  // ¡Hardcodeado!
    }
}

// ITERACIÓN 2: RED - Otra suma para forzar generalización
@Test
void otraSuma() {
    Calculadora calc = new Calculadora();
    assertEquals(15, calc.evaluar("10 + 5"));
}

// ITERACIÓN 2: GREEN - Parsear la expresión
public class Calculadora {
    public int evaluar(String expresion) {
        String[] partes = expresion.split("\\+");
        int a = Integer.parseInt(partes[0].trim());
        int b = Integer.parseInt(partes[1].trim());
        return a + b;
    }
}

// ITERACIÓN 3: RED - Resta
@Test
void restaBasica() {
    Calculadora calc = new Calculadora();
    assertEquals(6, calc.evaluar("10 - 4"));
}

// ITERACIÓN 3: GREEN - Detectar operador
public class Calculadora {
    public int evaluar(String expresion) {
        if (expresion.contains("+")) {
            String[] partes = expresion.split("\\+");
            return Integer.parseInt(partes[0].trim()) + 
                   Integer.parseInt(partes[1].trim());
        } else if (expresion.contains("-")) {
            String[] partes = expresion.split("-");
            return Integer.parseInt(partes[0].trim()) - 
                   Integer.parseInt(partes[1].trim());
        }
        throw new IllegalArgumentException("Expresión inválida");
    }
}

// ITERACIÓN 4: REFACTOR - Extraer lógica común
public class Calculadora {
    
    public int evaluar(String expresion) {
        char operador = encontrarOperador(expresion);
        String[] operandos = expresion.split("\\" + operador);
        int a = Integer.parseInt(operandos[0].trim());
        int b = Integer.parseInt(operandos[1].trim());
        
        return switch (operador) {
            case '+' -> a + b;
            case '-' -> a - b;
            default -> throw new IllegalArgumentException("Operador desconocido");
        };
    }
    
    private char encontrarOperador(String expresion) {
        for (char c : new char[]{'+', '-', '*', '/'}) {
            if (expresion.contains(String.valueOf(c))) {
                return c;
            }
        }
        throw new IllegalArgumentException("No se encontró operador");
    }
}

// ITERACIÓN 5: RED - Multiplicación y división
@Test
void multiplicacion() {
    assertEquals(12, new Calculadora().evaluar("3 * 4"));
}

@Test
void division() {
    assertEquals(5, new Calculadora().evaluar("15 / 3"));
}

// ITERACIÓN 5: GREEN - Agregar operadores
public int evaluar(String expresion) {
    char operador = encontrarOperador(expresion);
    String[] operandos = expresion.split(
        operador == '*' || operador == '+' ? "\\" + operador : String.valueOf(operador)
    );
    int a = Integer.parseInt(operandos[0].trim());
    int b = Integer.parseInt(operandos[1].trim());
    
    return switch (operador) {
        case '+' -> a + b;
        case '-' -> a - b;
        case '*' -> a * b;
        case '/' -> a / b;
        default -> throw new IllegalArgumentException("Operador desconocido");
    };
}

// ITERACIÓN 6: RED - División por cero
@Test
void divisionPorCeroLanzaExcepcion() {
    assertThrows(ArithmeticException.class, () ->
        new Calculadora().evaluar("10 / 0")
    );
}

// ITERACIÓN 6: GREEN - Ya funciona con Java! (ArithmeticException nativa)

// ITERACIÓN 7: RED - Espacios variables
@Test
void expresionSinEspacios() {
    assertEquals(5, new Calculadora().evaluar("2+3"));
}

@Test
void expresionConMuchosEspacios() {
    assertEquals(5, new Calculadora().evaluar("2  +  3"));
}

// ITERACIÓN 7: GREEN - trim() ya maneja esto
// Los tests pasan sin cambios (¡buena señal de diseño robusto!)

Versión final refactorizada:

public class Calculadora {
    
    private static final Set<Character> OPERADORES = Set.of('+', '-', '*', '/');
    
    public int evaluar(String expresion) {
        char operador = encontrarOperador(expresion);
        int[] operandos = parsearOperandos(expresion, operador);
        return calcular(operandos[0], operandos[1], operador);
    }
    
    private char encontrarOperador(String expresion) {
        for (char c : OPERADORES) {
            // Evitar confundir signo negativo con resta
            int index = expresion.indexOf(c, 1);
            if (index > 0) {
                return c;
            }
        }
        throw new IllegalArgumentException("Operador no encontrado: " + expresion);
    }
    
    private int[] parsearOperandos(String expresion, char operador) {
        String regex = operador == '*' || operador == '+' 
            ? "\\" + operador 
            : String.valueOf(operador);
        String[] partes = expresion.split(regex, 2);
        
        return new int[] {
            Integer.parseInt(partes[0].trim()),
            Integer.parseInt(partes[1].trim())
        };
    }
    
    private int calcular(int a, int b, char operador) {
        return switch (operador) {
            case '+' -> a + b;
            case '-' -> a - b;
            case '*' -> a * b;
            case '/' -> a / b;  // ArithmeticException si b == 0
            default -> throw new IllegalArgumentException("Operador inválido: " + operador);
        };
    }
}
Solution to Exercise 3
@ExtendWith(MockitoExtension.class)
class SistemaAlertasTest {
    
    @Mock
    private Notificador emailNotificador;
    
    @Mock
    private Notificador smsNotificador;
    
    @Mock
    private Notificador pushNotificador;
    
    @Mock
    private RegistroAlertas registro;
    
    private SistemaAlertas sistema;
    
    @BeforeEach
    void setUp() {
        sistema = new SistemaAlertas(
            List.of(emailNotificador, smsNotificador, pushNotificador),
            registro
        );
    }
    
    @Test
    void enviaAlertaPorTodosLosNotificadoresDisponibles() {
        // Arrange
        when(emailNotificador.estaDisponible()).thenReturn(true);
        when(smsNotificador.estaDisponible()).thenReturn(true);
        when(pushNotificador.estaDisponible()).thenReturn(true);
        
        // Act
        sistema.enviarAlerta("usuario@mail.com", "Alerta crítica");
        
        // Assert
        verify(emailNotificador).enviar("usuario@mail.com", "Alerta crítica");
        verify(smsNotificador).enviar("usuario@mail.com", "Alerta crítica");
        verify(pushNotificador).enviar("usuario@mail.com", "Alerta crítica");
    }
    
    @Test
    void noEnviaPorNotificadoresNoDisponibles() {
        // Arrange
        when(emailNotificador.estaDisponible()).thenReturn(true);
        when(smsNotificador.estaDisponible()).thenReturn(false);  // No disponible
        when(pushNotificador.estaDisponible()).thenReturn(true);
        
        // Act
        sistema.enviarAlerta("user", "Mensaje");
        
        // Assert
        verify(emailNotificador).enviar(anyString(), anyString());
        verify(smsNotificador, never()).enviar(anyString(), anyString());
        verify(pushNotificador).enviar(anyString(), anyString());
    }
    
    @Test
    void siempreRegistraLaAlertaAunqueFallenTodosLosNotificadores() {
        // Arrange
        when(emailNotificador.estaDisponible()).thenReturn(false);
        when(smsNotificador.estaDisponible()).thenReturn(false);
        when(pushNotificador.estaDisponible()).thenReturn(false);
        
        // Act
        sistema.enviarAlerta("destino", "mensaje importante");
        
        // Assert
        verify(registro).registrar("destino", "mensaje importante", 0);
    }
    
    @Test
    void retornaCantidadCorrectaDeEnvios() {
        // Arrange
        when(emailNotificador.estaDisponible()).thenReturn(true);
        when(smsNotificador.estaDisponible()).thenReturn(false);
        when(pushNotificador.estaDisponible()).thenReturn(true);
        
        // Act
        int enviados = sistema.enviarAlerta("dest", "msg");
        
        // Assert
        assertEquals(2, enviados);
    }
    
    @Test
    void registraConCantidadCorrectaDeEnvios() {
        // Arrange
        when(emailNotificador.estaDisponible()).thenReturn(true);
        when(smsNotificador.estaDisponible()).thenReturn(true);
        when(pushNotificador.estaDisponible()).thenReturn(false);
        
        // Act
        sistema.enviarAlerta("usuario", "alerta");
        
        // Assert
        verify(registro).registrar(eq("usuario"), eq("alerta"), eq(2));
    }
    
    @Test
    void manejaListaVaciaDeNotificadores() {
        // Arrange
        SistemaAlertas sistemaSinNotificadores = new SistemaAlertas(
            Collections.emptyList(),
            registro
        );
        
        // Act
        int enviados = sistemaSinNotificadores.enviarAlerta("dest", "msg");
        
        // Assert
        assertEquals(0, enviados);
        verify(registro).registrar("dest", "msg", 0);
    }
}

Lecturas Recomendadas

Próximo paso

Para seguir, conviene pasar a el material siguiente, donde el recorrido continúa sobre esta base.