Pruebas unitarias con JUnit 5
En esta lección
Hasta ahora, para saber si un método funcionaba, lo llamabas desde un main, mirabas lo que salía por consola y decidías a ojo si estaba bien. Eso sirve una vez, pero cada vez que cambias algo tendrías que repetirlo todo. Las pruebas unitarias son pequeños programas que comprueban tu código de forma automática y te avisan en cuanto algo se rompe. En esta lección aprenderás a escribirlas con JUnit 5, la biblioteca de pruebas más usada en Java: preparar el proyecto, escribir tests con @Test, usar aserciones, probar excepciones, repetir un test con muchos datos y dar tus primeros pasos con TDD.
Por qué probar el código automáticamente
Una prueba unitaria (o test) comprueba una pieza pequeña del programa, normalmente un método, de forma aislada: “si añado dos productos al carrito, el total debería ser 158 €”. Tiene tres ventajas claras:
- Detecta errores pronto. Ejecutas cientos de pruebas en un par de segundos cada vez que cambias algo.
- Te da confianza para mejorar el código. Si reorganizas un método y todas las pruebas siguen en verde, no has roto nada.
- Documenta. Un test llamado
noAdmiteUnidadesNegativasexplica mejor que un comentario qué debe hacer la clase.
Preparar el proyecto
JUnit no viene con el JDK: es una dependencia externa que tiene que descargar tu herramienta de construcción. Las dos más usadas en Java son Maven y Gradle. Las dos siguen la misma estructura de carpetas, que separa el código de la aplicación del código de las pruebas:
tienda/
├── pom.xml (o build.gradle.kts si usas Gradle)
└── src/
├── main/java/ el código de la aplicación
│ └── Carrito.java
└── test/java/ las pruebas
└── CarritoTest.java
Por convención, las pruebas de la clase Carrito van en CarritoTest, en la misma carpeta de paquetes pero dentro de src/test/java. Así los tests pueden acceder a lo que tenga visibilidad de paquete, pero no se incluyen en el programa final.
Con Maven
En el pom.xml añades JUnit como dependencia con <scope>test</scope> (solo se usa al probar) y el plugin que ejecuta las pruebas:
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>es.devschool</groupId>
<artifactId>tienda</artifactId>
<version>1.0</version>
<properties>
<maven.compiler.release>21</maven.compiler.release>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<dependencies>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>5.12.2</version>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>3.5.3</version>
</plugin>
</plugins>
</build>
</project>
Las pruebas se lanzan desde la carpeta del proyecto con:
mvn test
Con Gradle
En build.gradle.kts (el formato actual, escrito en Kotlin):
plugins {
java
}
repositories {
mavenCentral()
}
dependencies {
testImplementation(platform("org.junit:junit-bom:5.12.2"))
testImplementation("org.junit.jupiter:junit-jupiter")
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
}
tasks.test {
useJUnitPlatform()
}
Y se ejecutan con:
gradle test
Consejo: IntelliJ IDEA, VS Code (con el paquete de extensiones de Java), Eclipse y NetBeans crean proyectos Maven o Gradle con JUnit ya configurado y muestran un botón verde junto a cada test para ejecutarlo. Consulta en Maven Central la última versión de JUnit y usa esa.
Nota: en 2025 apareció JUnit 6. Mantiene el mismo paquete
org.junit.jupitery las mismas anotaciones, así que todo lo que aprendes aquí sirve igual. Verás en código antiguo JUnit 4, conimport org.junit.Testy@Beforeen lugar de@BeforeEach.
La clase que vamos a probar
Vamos a probar el carrito de una tienda de escalada. Usa un record privado para cada línea y lanza excepciones si le pasan datos incorrectos:
import java.util.ArrayList;
import java.util.List;
public class Carrito {
private record Linea(String producto, double precio, int unidades) {}
private final List<Linea> lineas = new ArrayList<>();
private int descuento = 0; // porcentaje
public void anadir(String producto, double precio, int unidades) {
if (unidades <= 0) {
throw new IllegalArgumentException("Las unidades deben ser positivas");
}
lineas.add(new Linea(producto, precio, unidades));
}
public boolean estaVacio() {
return lineas.isEmpty();
}
public int unidades() {
int total = 0;
for (Linea l : lineas) {
total += l.unidades();
}
return total;
}
public void aplicarDescuento(int porcentaje) {
if (porcentaje < 0 || porcentaje > 50) {
throw new IllegalArgumentException("Descuento no válido: " + porcentaje);
}
descuento = porcentaje;
}
public double total() {
double suma = 0;
for (Linea l : lineas) {
suma += l.precio() * l.unidades();
}
return suma * (100 - descuento) / 100;
}
public List<String> productos() {
return lineas.stream().map(Linea::producto).toList();
}
}
Tu primer test: @Test
Un test es un método void, sin parámetros, marcado con la anotación @Test. Las anotaciones son etiquetas que empiezan por @ y dan información extra a Java o a una biblioteca; ya conoces @Override.
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
class CarritoTest {
@Test
void calculaElTotal() {
// Preparar
Carrito carrito = new Carrito();
carrito.anadir("Cuerda 60 m", 120.0, 1);
carrito.anadir("Mosquetón", 9.5, 4);
// Actuar
double total = carrito.total();
// Comprobar
assertEquals(158.0, total, 0.001);
}
}
Fíjate en varias cosas:
- No hay
main. JUnit busca los métodos con@Testy los ejecuta uno a uno. - La clase y los métodos no necesitan ser
publicen JUnit 5. - El
import staticpermite escribirassertEqualsen lugar deAssertions.assertEquals. - El nombre del método describe lo que se comprueba. Es lo que verás cuando falle.
La estructura Preparar, Actuar, Comprobar (en inglés Arrange, Act, Assert) es la forma habitual de organizar un test: creas los datos, llamas al método que pruebas y compruebas el resultado.
Qué pasa cuando un test falla
Si alguien estropea total() y olvida multiplicar por las unidades, el test se pone en rojo con un mensaje como este:
org.opentest4j.AssertionFailedError: expected: <158.0> but was: <129.5>
at CarritoTest.calculaElTotal(CarritoTest.java:16)
Te dice qué esperabas, qué ha salido y en qué línea. Cuando todo va bien, Maven resume algo así:
[INFO] Tests run: 7, Failures: 0, Errors: 0, Skipped: 0
[INFO] BUILD SUCCESS
Aserciones
Una aserción es la comprobación que decide si el test pasa o falla. Todas están en la clase Assertions:
| Aserción | Comprueba que… |
|---|---|
assertEquals(esperado, real) | los dos valores son iguales (con equals) |
assertEquals(esperado, real, margen) | dos double se diferencian menos que el margen |
assertNotEquals(a, b) | son distintos |
assertTrue(condicion) / assertFalse(condicion) | la condición es true / false |
assertNull(x) / assertNotNull(x) | x es / no es null |
assertSame(a, b) | son el mismo objeto (==) |
assertThrows(Tipo.class, codigo) | el código lanza esa excepción |
assertAll(...) | todas las comprobaciones, aunque falle alguna |
El orden importa: primero el valor esperado y después el real. Si los cambias, el test funciona igual, pero el mensaje de error dirá lo contrario de lo que pasa y te confundirá.
Todas admiten un último parámetro opcional con un mensaje para cuando fallen:
assertEquals(5, carrito.unidades(), "El carrito debería tener 5 unidades");
Cuidado: nunca compares
doublesin margen. Por cómo se guardan los decimales,0.1 + 0.2vale0.30000000000000004, yassertEquals(0.3, 0.1 + 0.2)falla. Usa siempre la versión con margen:assertEquals(0.3, 0.1 + 0.2, 0.001).
Varias comprobaciones a la vez: assertAll
Si un test tiene varias aserciones seguidas y falla la primera, las demás no se ejecutan y no sabes si estaban bien. assertAll las ejecuta todas y te informa de todos los fallos juntos. Cada comprobación va en una lambda:
@Test
void guardaLosProductosEnOrden() {
carrito.anadir("Magnesio", 6.0, 2);
carrito.anadir("Arnés", 55.0, 1);
assertAll(
() -> assertEquals(List.of("Magnesio", "Arnés"), carrito.productos()),
() -> assertEquals(3, carrito.unidades()),
() -> assertEquals(67.0, carrito.total(), 0.001)
);
}
Preparar antes de cada test: @BeforeEach
Seguro que has notado que todos los tests empiezan creando un carrito. Para no repetirlo, se usa un método marcado con @BeforeEach, que JUnit ejecuta antes de cada test:
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
class CarritoTest {
private Carrito carrito;
@BeforeEach
void prepararCarrito() {
carrito = new Carrito(); // cada test empieza con un carrito vacío
}
@Test
void unCarritoNuevoEstaVacio() {
assertTrue(carrito.estaVacio());
assertEquals(0, carrito.unidades());
}
@Test
void sumaLasUnidadesDeTodosLosProductos() {
carrito.anadir("Cuerda 60 m", 120.0, 1);
carrito.anadir("Mosquetón", 9.5, 4);
assertEquals(5, carrito.unidades());
assertFalse(carrito.estaVacio());
}
}
Como el carrito se crea de nuevo cada vez, lo que haga un test no afecta a los demás. Esto es fundamental: los tests deben ser independientes y poder ejecutarse en cualquier orden, porque JUnit no garantiza ninguno.
Otras anotaciones del mismo estilo:
| Anotación | Se ejecuta… |
|---|---|
@BeforeEach | antes de cada test |
@AfterEach | después de cada test (cerrar ficheros, borrar datos temporales) |
@BeforeAll | una vez antes de todos (método static; p. ej., abrir una conexión) |
@AfterAll | una vez después de todos (método static) |
@DisplayName("...") | cambia el nombre que se muestra en el informe |
@Disabled | desactiva un test temporalmente |
Probar excepciones: assertThrows
Que el código falle cuando debe también hay que probarlo. assertThrows recibe el tipo de excepción esperado y una lambda con el código que debería lanzarla. Si no se lanza, o se lanza otra distinta, el test falla. Además, devuelve la excepción, para que puedas revisar su mensaje:
@Test
void noAdmiteUnidadesNegativasNiCero() {
IllegalArgumentException error = assertThrows(
IllegalArgumentException.class,
() -> carrito.anadir("Casco", 45.0, 0));
assertEquals("Las unidades deben ser positivas", error.getMessage());
assertTrue(carrito.estaVacio());
}
@Test
void rechazaDescuentosDeMasDelCincuentaPorCiento() {
assertThrows(IllegalArgumentException.class, () -> carrito.aplicarDescuento(80));
assertDoesNotThrow(() -> carrito.aplicarDescuento(50));
}
Fíjate en que también probamos el límite: 50 sí vale, 80 no. Los errores suelen esconderse en los bordes (un < que debería ser <=), así que prueba siempre los valores justo en el límite y justo al lado.
Consejo: no metas un
try/catchen el test para comprobar una excepción.assertThrowses más corto y, si la excepción no salta, el test falla solo.
Muchos datos, un test: @ParameterizedTest
Imagina que quieres probar un validador de DNI con diez casos. Escribir diez métodos casi iguales es pesado. Con un test parametrizado escribes el método una vez y JUnit lo ejecuta con cada dato.
La clase a probar comprueba que el DNI tiene 8 cifras y una letra, y que la letra es la que corresponde (el resto de dividir el número entre 23 indica la posición en una tabla de letras):
public class Dni {
private static final String LETRAS = "TRWAGMYFPDXBNJZSQVHLCKE";
public static boolean esValido(String dni) {
if (dni == null || !dni.matches("\\d{8}[A-Z]")) {
return false;
}
int numero = Integer.parseInt(dni.substring(0, 8));
return dni.charAt(8) == LETRAS.charAt(numero % 23);
}
}
Con @ValueSource le pasas una lista de valores de un mismo tipo, y cada uno llega como parámetro del método:
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
import org.junit.jupiter.params.provider.ValueSource;
import static org.junit.jupiter.api.Assertions.*;
class DniTest {
@ParameterizedTest
@ValueSource(strings = {"12345678Z", "00000000T", "99999999R"})
void aceptaDnisCorrectos(String dni) {
assertTrue(Dni.esValido(dni), dni + " debería ser válido");
}
@ParameterizedTest
@ValueSource(strings = {"12345678A", "1234567Z", "12345678z", "ABCDEFGHZ", ""})
void rechazaDnisIncorrectos(String dni) {
assertFalse(Dni.esValido(dni), dni + " no debería ser válido");
}
}
Cuando necesitas varios datos por caso (una entrada y su resultado esperado), usa @CsvSource: cada texto es una fila con valores separados por comas, y JUnit los convierte al tipo de cada parámetro:
@ParameterizedTest
@CsvSource({
"12345678Z, true",
"12345678A, false",
"00000023T, true",
"00000001R, true"
})
void compruebaLaLetra(String dni, boolean esperado) {
assertEquals(esperado, Dni.esValido(dni));
}
Cada fila aparece en el informe como un test independiente, así que si falla una sabes exactamente cuál. @ValueSource también admite ints, doubles, longs, etc.
TDD: primero el test
TDD (Test-Driven Development, desarrollo guiado por pruebas) es una forma de trabajar en la que escribes el test antes que el código. Se repite un ciclo de tres pasos muy cortos:
- Rojo: escribe un test para algo que aún no existe. Falla (o ni compila).
- Verde: escribe el código mínimo para que pase.
- Refactorizar: mejora el código sin cambiar lo que hace. Los tests te avisan si rompes algo.
Vamos a probarlo con el precio de la entrada a un museo: los menores de 4 años entran gratis, los menores de 18 pagan 5 €, los de 65 o más pagan 4 € y el resto, 10 €.
Ciclo 1. Empezamos por el caso más simple:
@Test
void unAdultoPagaDiezEuros() {
assertEquals(10, Entrada.precio(30), 0.001);
}
No compila porque Entrada no existe: rojo. Escribimos lo mínimo para ponerlo en verde, aunque parezca hacer trampa:
public class Entrada {
public static double precio(int edad) {
return 10;
}
}
Ciclo 2. Un nuevo test nos obliga a mejorar el código:
@Test
void unMenorPagaCincoEuros() {
assertEquals(5, Entrada.precio(12), 0.001);
}
Rojo: devuelve 10. Añadimos if (edad < 18) return 5; y los dos tests pasan.
Ciclos siguientes. Seguimos igual con los bebés, los jubilados y las edades negativas, y al final reunimos los casos en un test parametrizado que prueba todos los límites:
class EntradaTest {
@ParameterizedTest
@CsvSource({
"0, 0", "3, 0", // bebés
"4, 5", "17, 5", // menores
"18, 10", "64, 10", // adultos
"65, 4", "90, 4" // jubilados
})
void precioSegunLaEdad(int edad, double esperado) {
assertEquals(esperado, Entrada.precio(edad), 0.001);
}
@Test
void unaEdadNegativaEsUnError() {
assertThrows(IllegalArgumentException.class, () -> Entrada.precio(-1));
}
}
El código final que hace pasar todas las pruebas:
public class Entrada {
public static double precio(int edad) {
if (edad < 0) {
throw new IllegalArgumentException("Edad no válida: " + edad);
}
if (edad < 4) {
return 0;
}
if (edad < 18) {
return 5;
}
if (edad >= 65) {
return 4;
}
return 10;
}
}
La ventaja de TDD es que nunca escribes código que no esté probado, y te obliga a pensar en qué debe hacer el método antes de pensar en cómo. No hace falta usarlo siempre, pero es un buen ejercicio para aprender a diseñar.
Errores frecuentes
- Poner la clase de test en
src/main/java. Maven y Gradle no la encontrarán como test y además irá dentro del programa final. Va ensrc/test/java. - Importar
org.junit.Test(JUnit 4) en lugar deorg.junit.jupiter.api.Test. El test no se ejecuta o no compila. Revisa los imports que sugiere el IDE. - Cambiar el orden de
assertEquals. Primero el esperado, después el real; si no, los mensajes de error engañan. - Comparar
doublesin margen. Usa siempreassertEquals(esperado, real, 0.001). - Tests que dependen unos de otros. Si un test necesita que otro se haya ejecutado antes, fallará al azar. Prepara todo en
@BeforeEach. - Un test sin aserciones. Si no comprueba nada, siempre pasa y no sirve para nada.
- Probar solo el caso feliz. Los fallos suelen estar en los límites y en los datos incorrectos.
- Probar getters triviales o código de Java. Céntrate en tu lógica: cálculos, validaciones y reglas. Y separa esa lógica de la entrada y salida (teclado, consola), que es difícil de probar.
Resumen
| Concepto | Código |
|---|---|
| Dependencia | org.junit.jupiter:junit-jupiter con scope test |
| Ejecutar pruebas | mvn test o gradle test |
| Test | @Test void nombreDescriptivo() { ... } |
| Comprobar igualdad | assertEquals(esperado, real) |
| Decimales | assertEquals(esperado, real, 0.001) |
| Condiciones | assertTrue, assertFalse, assertNull, assertNotNull |
| Varias a la vez | assertAll(() -> ..., () -> ...) |
| Preparar cada test | @BeforeEach |
| Excepciones | assertThrows(Tipo.class, () -> ...) |
| Muchos datos | @ParameterizedTest + @ValueSource o @CsvSource |
| TDD | Rojo, verde, refactorizar |
En la siguiente lección juntarás todo lo aprendido en el curso (enums, records, colecciones y streams) para programar una baraja de póker que reparte y evalúa manos: proyecto: cartas.
Pon a prueba lo que has aprendido
¿Te ha quedado claro? Márcala y verás tu progreso en el explorador.