Testing con JUnit
Ya sabemos obtener JUnit mediante Maven. Ahora lo usaremos para responder una pregunta distinta: ¿el código se comporta como promete? Un test automatizado prepara una situación, ejecuta una acción y compara el resultado con una expectativa.
El código de producción vive en src/main/java; sus tests, en src/test/java y normalmente en el mismo package. Ejecutamos todos con:
mvn test
Caso conductor
package es.skilly.tareas;
import java.util.ArrayList;
import java.util.List;
public class GestorTareas {
private final List<String> tareas = new ArrayList<>();
public void agregar(String descripcion) {
if (descripcion == null || descripcion.isBlank()) {
throw new IllegalArgumentException("La descripción es obligatoria");
}
tareas.add(descripcion);
}
public int cantidad() {
return tareas.size();
}
public String buscar(int posicion) {
if (posicion < 0 || posicion >= tareas.size()) {
throw new IllegalArgumentException("Posición inexistente");
}
return tareas.get(posicion);
}
}
El POM usa Java 17, JUnit Jupiter 5.14.2 con scope test, Compiler 3.13.0 y Surefire 3.5.4, igual que en la unidad anterior.
Arrange, Act, Assert
package es.skilly.tareas;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
class GestorTareasTest {
@Test
void agregarIncrementaLaCantidad() {
// Arrange
GestorTareas gestor = new GestorTareas();
// Act
gestor.agregar("Preparar tests");
// Assert
assertEquals(1, gestor.cantidad());
}
}
@Test marca un método de prueba. Su nombre describe comportamiento, no un número genérico como test1. Arrange prepara, Act ejecuta y Assert comprueba. Las tres partes pueden ser breves, pero deben poder reconocerse.
Un test útil fallaría si rompemos el comportamiento. assertTrue(true) siempre pasa y no protege nada. Tampoco conviene repetir dentro del test el mismo algoritmo de producción.
Assertions frecuentes
assertEquals("Preparar tests", gestor.buscar(0));
assertTrue(gestor.cantidad() > 0);
assertFalse(gestor.cantidad() == 0);
assertNotNull(gestor.buscar(0));
assertNull y assertNotNull aportan cuando null forma parte del contrato. No sustituyen una comparación más precisa si conocemos el valor esperado.
Para excepciones usamos assertThrows:
import static org.junit.jupiter.api.Assertions.assertThrows;
@Test
void rechazaDescripcionVacia() {
GestorTareas gestor = new GestorTareas();
IllegalArgumentException error = assertThrows(
IllegalArgumentException.class,
() -> gestor.agregar(" ")
);
assertEquals("La descripción es obligatoria", error.getMessage());
}
No provocamos una excepción artificial: comprobamos un contrato real del dominio.
Casos nominales y límites
El camino nominal no basta. Para buscar, son relevantes una lista con un elemento, la primera posición, la última y posiciones fuera del rango:
@Test
void recuperaLaUltimaTarea() {
GestorTareas gestor = new GestorTareas();
gestor.agregar("Una");
gestor.agregar("Dos");
assertEquals("Dos", gestor.buscar(1));
}
@Test
void rechazaPosicionIgualAlTamano() {
GestorTareas gestor = new GestorTareas();
gestor.agregar("Una");
assertThrows(IllegalArgumentException.class, () -> gestor.buscar(1));
}
Un test que descubre un bug
Imagina esta validación defectuosa:
if (posicion < 0 || posicion > tareas.size()) {
throw new IllegalArgumentException("Posición inexistente");
}
El operador > deja pasar posicion == tareas.size(). El test anterior falla porque termina en IndexOutOfBoundsException, no en la excepción del contrato. La corrección es usar >=. El test no demuestra que nunca habrá errores; conserva evidencia automatizada de este caso.
Preparación común
Cada test debe poder ejecutarse solo y en cualquier orden. @BeforeEach crea un estado nuevo antes de cada método:
import org.junit.jupiter.api.BeforeEach;
class GestorTareasTest {
private GestorTareas gestor;
@BeforeEach
void prepararGestor() {
gestor = new GestorTareas();
}
@Test
void empiezaVacio() {
assertEquals(0, gestor.cantidad());
}
}
@AfterEach se ejecuta después de cada test. Úsalo para cerrar o limpiar recursos que realmente lo necesiten, no por rutina:
import org.junit.jupiter.api.AfterEach;
@AfterEach
void limpiar() {
// Por ejemplo, eliminar un fichero temporal creado por el test.
}
JUnit crea una instancia nueva de la clase de test por método de forma predeterminada, pero un @BeforeEach hace explícita la preparación compartida. Evita campos static mutables y dependencias entre tests.
Leer el informe de Maven
Surefire descubre las clases de test y resume el resultado:
Tests run: 4, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESS
- failure: una assertion no se cumplió;
- error: el test terminó con un error no esperado;
- skipped: no se ejecutó.
Ante un fallo, lee el nombre del test, la diferencia esperado/real y la primera línea propia de la traza.
Práctica: completar la cobertura de comportamiento
Añade tests para:
- un gestor nuevo está vacío;
- dos altas producen cantidad
2; - buscar la primera tarea devuelve su descripción;
- una descripción
nullse rechaza; - una posición negativa se rechaza;
- cada test puede ejecutarse de forma independiente.
Solución
@Test
void dosAltasProducenCantidadDos() {
gestor.agregar("Una");
gestor.agregar("Dos");
assertEquals(2, gestor.cantidad());
}
@Test
void devuelveLaPrimeraDescripcion() {
gestor.agregar("Una");
assertEquals("Una", gestor.buscar(0));
}
@Test
void rechazaNull() {
assertThrows(IllegalArgumentException.class, () -> gestor.agregar(null));
}
@Test
void rechazaPosicionNegativa() {
assertThrows(IllegalArgumentException.class, () -> gestor.buscar(-1));
}
Ejecuta mvn test y comprueba el resumen. Si el caso límite falla con la validación >, cambia producción a >= y repite. En la siguiente unidad utilizaremos un test que falla como punto de partida para diagnosticar la causa, no para adivinar una corrección.