Saltar al contenido principal

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:

  1. un gestor nuevo está vacío;
  2. dos altas producen cantidad 2;
  3. buscar la primera tarea devuelve su descripción;
  4. una descripción null se rechaza;
  5. una posición negativa se rechaza;
  6. 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.