Testing OOP
Verificación y Validación de Sistemas Orientados a Objetos
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:
Verificación: Confirmar que el código hace lo esperado
Prevención de regresiones: Detectar cuando un cambio rompe algo
Documentación: Los tests muestran cómo usar el código
Diseño: Escribir tests primero mejora el diseño
Confianza: Permite refactorizar sin miedo
Niveles de Testing¶
Figure 1:Pirámide de testing: base amplia de tests unitarios, integración en el medio y E2E en la punta.
| Nivel | Qué prueba | Características |
|---|---|---|
| Unitarios | Una clase o método aislado | Rápidos, muchos, sin dependencias externas |
| Integración | Interacción entre componentes | Más lentos, verifican que partes trabajen juntas |
| E2E (End-to-End) | Sistema completo | Lentos, 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.
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¶
| Getter | Método de Dominio |
|---|---|
getSaldo() | consultarSaldo() |
| Expone estado interno | Expresa intención del negocio |
| Trivial, sin lógica | Puede encapsular validación |
| Acoplado a impl. interna | Contrato semánticamente significativo |
| Tests frágiles | Tests 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¶
Usa métodos públicos que representen acciones del dominio
No
getSaldo(), usaconsultarSaldo()No
setEstado(), usa métodos semánticamente significativos
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)); // ✅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)); // ✅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:
Clases anidadas (
@Nested): Agrupan tests relacionadosNombres descriptivos (
@DisplayName): Documentan el comportamientoSetup compartido (
@BeforeEach): Evita duplicaciónUn 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):
No escribir código de producción excepto para hacer pasar un test que falla
No escribir más de un test unitario que sea suficiente para fallar
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:
No queremos enviar emails reales (lento, costoso, efectos secundarios)
No queremos depender de una base de datos real
Solución: Reemplazar las dependencias con dobles de prueba.
Tipos de Dobles de Prueba¶
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ón | Propó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:
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 testsProblema 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ón | Ejemplo |
|---|---|
metodo_escenario_resultado | depositar_montoPositivo_aumentaSaldo |
dado_cuando_entonces | dadoCuentaConSaldo_cuandoExtraeTodo_entoncesQuedaEnCero |
should_when | shouldThrowException_whenAmountIsNegative |
| Oración descriptiva | extraerMasDelSaldoLanzaSaldoInsuficienteException |
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¶
Figure 5:Comparación entre Object Mother, Test Data Builder y una combinación de ambos para crear datos de prueba.
Resumen¶
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¶
Beck, K. (2002). Test-Driven Development: By Example
Freeman, S. & Pryce, N. (2009). Growing Object-Oriented Software, Guided by Tests
Meszaros, G. (2007). xUnit Test Patterns: Refactoring Test Code
Martin, R. C. (2008). Clean Code, Capítulo 9: Unit Tests
Próximo paso¶
Para seguir, conviene pasar a el material siguiente, donde el recorrido continúa sobre esta base.