Saltar al contenido principal

Capítulo 2: Pruebas Unitarias con JUnit 5

Introducción​

JUnit 5 es el framework de pruebas estándar para Java. Es la base de todas sus pruebas en Spring Boot. Este capítulo le enseña a utilizarlo de manera eficaz.


🎯 Objetivos del Capítulo​

Al final de este capítulo, usted sabrá:

  1. ✅ Utilizar las anotaciones de JUnit 5
  2. ✅ Escribir aserciones claras
  3. ✅ Probar excepciones
  4. ✅ Crear pruebas parametrizadas
  5. ✅ Gestionar el ciclo de vida de las pruebas

1. Estructura de una Prueba​

Anatomía Básica​

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

class CalculatorTest {

@Test // ← Indica un método de prueba
void shouldAddTwoNumbers() {
// Arrange (Preparar)
Calculator calc = new Calculator();

// Act (Ejecutar)
int result = calc.add(2, 3);

// Assert (Verificar)
assertEquals(5, result);
}
}

Convenciones​

class ProductServiceTest {  // ← Nombre: ClaseProbada + Test

@Test
void shouldReturnProductById() { // ← Método descriptivo
// ...
}

@Test
void shouldThrowExceptionWhenProductNotFound() {
// ...
}
}

2. Anotaciones Principales​

@Test​

import org.junit.jupiter.api.Test;

@Test
void simpleTest() {
assertTrue(1 + 1 == 2);
}

@DisplayName​

@Test
@DisplayName("Debe calcular el precio con IVA al 20%")
void shouldCalculatePriceWithVAT() {
double result = priceService.calculateWithVAT(100.0, 0.20);
assertEquals(120.0, result);
}

@Disabled​

@Test
@Disabled("En espera de la corrección del bug #123")
void brokenTest() {
// Esta prueba será ignorada
}

@BeforeEach y @AfterEach​

class ProductServiceTest {

private ProductService productService;

@BeforeEach
void setUp() {
// Ejecutado ANTES de cada prueba
productService = new ProductService();
}

@AfterEach
void tearDown() {
// Ejecutado DESPUÉS de cada prueba (limpieza)
productService = null;
}

@Test
void test1() { /* productService está nuevo */ }

@Test
void test2() { /* productService está nuevo */ }
}

@BeforeAll y @AfterAll​

class DatabaseTest {

@BeforeAll
static void setUpAll() {
// Ejecutado UNA VEZ antes de todas las pruebas
// ¡Debe ser static!
System.out.println("Inicialización global...");
}

@AfterAll
static void tearDownAll() {
// Ejecutado UNA VEZ después de todas las pruebas
System.out.println("Limpieza global...");
}
}

Orden de Ejecución​

@BeforeAll (1 vez)
│
├── @BeforeEach
│ └── @Test test1()
│ └── @AfterEach
│
├── @BeforeEach
│ └── @Test test2()
│ └── @AfterEach
│
└── @AfterAll (1 vez)

3. Aserciones​

Aserciones Básicas​

import static org.junit.jupiter.api.Assertions.*;

@Test
void demonstrateAssertions() {
// Igualdad
assertEquals(5, calculator.add(2, 3));
assertEquals("Hello", greeting.getMessage());
assertEquals(3.14, Math.PI, 0.01); // Con delta para double

// Booleanos
assertTrue(user.isActive());
assertFalse(list.isEmpty());

// Nulo
assertNull(optionalValue.orElse(null));
assertNotNull(service.findById(1L));

// Referencias
assertSame(expected, actual); // Mismo objeto
assertNotSame(obj1, obj2); // Objetos diferentes
}

Aserciones sobre Colecciones​

@Test
void testCollections() {
List<String> fruits = List.of("Apple", "Banana", "Cherry");

// Tamaño
assertEquals(3, fruits.size());

// Contenido
assertTrue(fruits.contains("Apple"));
assertFalse(fruits.contains("Grape"));

// Con AssertJ (más legible)
assertThat(fruits)
.hasSize(3)
.contains("Apple", "Banana")
.doesNotContain("Grape")
.startsWith("Apple")
.endsWith("Cherry");
}

Mensajes Personalizados​

@Test
void testWithMessage() {
int age = user.getAge();

assertTrue(age >= 18, "El usuario debe ser mayor de edad");
assertEquals(25, age, () -> "La edad esperada era 25 pero era " + age);
}

assertAll (Aserciones Agrupadas)​

@Test
void testUserProperties() {
User user = new User("John", "john@email.com", 25);

// Todas las aserciones se ejecutan, incluso si una falla
assertAll("Propiedades del usuario",
() -> assertEquals("John", user.getName()),
() -> assertEquals("john@email.com", user.getEmail()),
() -> assertEquals(25, user.getAge()),
() -> assertTrue(user.isActive())
);
}

4. Probar Excepciones​

assertThrows​

@Test
void shouldThrowExceptionWhenDividingByZero() {
Calculator calc = new Calculator();

// Verifica que se lanza la excepción
assertThrows(ArithmeticException.class, () -> {
calc.divide(10, 0);
});
}

@Test
void shouldThrowExceptionWithMessage() {
// Capturar la excepción para verificar el mensaje
IllegalArgumentException exception = assertThrows(
IllegalArgumentException.class,
() -> userService.createUser(null)
);

assertEquals("Username cannot be null", exception.getMessage());
}

assertDoesNotThrow​

@Test
void shouldNotThrowException() {
assertDoesNotThrow(() -> {
userService.createUser("validUser");
});
}

5. Pruebas Parametrizadas​

@ParameterizedTest con @ValueSource​

import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;

@ParameterizedTest
@ValueSource(strings = {"John", "Jane", "Bob"})
void shouldAcceptValidNames(String name) {
assertTrue(validator.isValidName(name));
}

@ParameterizedTest
@ValueSource(ints = {1, 2, 3, 4, 5})
void shouldBePositive(int number) {
assertTrue(number > 0);
}

@CsvSource​

@ParameterizedTest
@CsvSource({
"1, 1, 2",
"2, 3, 5",
"10, 20, 30",
"-1, 1, 0"
})
void shouldAddNumbers(int a, int b, int expected) {
assertEquals(expected, calculator.add(a, b));
}

@ParameterizedTest
@CsvSource({
"john@email.com, true",
"invalid-email, false",
"test@domain.org, true",
"'', false"
})
void shouldValidateEmail(String email, boolean expected) {
assertEquals(expected, validator.isValidEmail(email));
}

@MethodSource​

@ParameterizedTest
@MethodSource("provideUsers")
void shouldValidateUser(User user, boolean expectedValid) {
assertEquals(expectedValid, validator.isValid(user));
}

static Stream<Arguments> provideUsers() {
return Stream.of(
Arguments.of(new User("John", 25), true),
Arguments.of(new User("", 25), false),
Arguments.of(new User("Jane", -1), false),
Arguments.of(new User("Bob", 18), true)
);
}

@EnumSource​

@ParameterizedTest
@EnumSource(Status.class)
void shouldHandleAllStatuses(Status status) {
assertDoesNotThrow(() -> processor.process(status));
}

@ParameterizedTest
@EnumSource(value = Status.class, names = {"ACTIVE", "PENDING"})
void shouldHandleActiveStatuses(Status status) {
assertTrue(status == Status.ACTIVE || status == Status.PENDING);
}

6. Pruebas Condicionales​

@EnabledOnOs / @DisabledOnOs​

@Test
@EnabledOnOs(OS.WINDOWS)
void windowsOnlyTest() {
// Solo se ejecuta en Windows
}

@Test
@DisabledOnOs(OS.MAC)
void notOnMacTest() {
// No se ejecuta en Mac
}

@EnabledIfEnvironmentVariable​

@Test
@EnabledIfEnvironmentVariable(named = "ENV", matches = "dev")
void onlyInDevEnvironment() {
// Se ejecuta solo si ENV=dev
}

@EnabledIf​

@Test
@EnabledIf("customCondition")
void conditionalTest() {
// ...
}

boolean customCondition() {
return LocalTime.now().getHour() < 12; // Solo por la mañana
}

7. Pruebas Anidadas​

@Nested​

class UserServiceTest {

@Nested
@DisplayName("Al crear un usuario")
class CreateUser {

@Test
@DisplayName("debe tener éxito con datos válidos")
void shouldSucceedWithValidData() {
User user = userService.create("John", "john@email.com");
assertNotNull(user.getId());
}

@Test
@DisplayName("debe fallar con nombre vacío")
void shouldFailWithEmptyName() {
assertThrows(ValidationException.class,
() -> userService.create("", "john@email.com"));
}
}

@Nested
@DisplayName("Al eliminar un usuario")
class DeleteUser {

@Test
@DisplayName("debe eliminar de la base de datos")
void shouldRemoveFromDatabase() {
userService.delete(1L);
assertFalse(userService.exists(1L));
}
}
}

Visualización en el IDE​

UserServiceTest
├── Al crear un usuario
│ ├── ✓ debe tener éxito con datos válidos
│ └── ✓ debe fallar con nombre vacío
└── Al eliminar un usuario
└── ✓ debe eliminar de la base de datos

8. Timeouts​

@Timeout​

@Test
@Timeout(5) // Falla si toma más de 5 segundos
void shouldCompleteQuickly() {
// ...
}

@Test
@Timeout(value = 500, unit = TimeUnit.MILLISECONDS)
void shouldCompleteVeryQuickly() {
// ...
}

assertTimeout​

@Test
void shouldCompleteInTime() {
assertTimeout(Duration.ofSeconds(2), () -> {
// Código que debe ejecutarse en menos de 2s
Thread.sleep(1000);
});
}

9. AssertJ (Recomendado)​

AssertJ ofrece una sintaxis más fluida y legible:

import static org.assertj.core.api.Assertions.*;

@Test
void demonstrateAssertJ() {
// Cadenas
assertThat("Hello World")
.startsWith("Hello")
.endsWith("World")
.contains("lo Wo")
.hasSize(11);

// Números
assertThat(42)
.isPositive()
.isGreaterThan(40)
.isBetween(40, 50);

// Colecciones
assertThat(List.of("a", "b", "c"))
.hasSize(3)
.contains("a", "b")
.doesNotContain("d")
.containsExactly("a", "b", "c");

// Objetos
assertThat(user)
.isNotNull()
.hasFieldOrPropertyWithValue("name", "John")
.extracting(User::getEmail)
.isEqualTo("john@email.com");

// Excepciones
assertThatThrownBy(() -> service.riskyMethod())
.isInstanceOf(RuntimeException.class)
.hasMessageContaining("error");
}

10. Resumen​

Anotaciones Clave​

AnotaciónDescripción
@TestMarca un método de prueba
@BeforeEachAntes de cada prueba
@AfterEachDespués de cada prueba
@BeforeAllAntes de todas las pruebas (static)
@AfterAllDespués de todas las pruebas (static)
@DisplayNameNombre legible para la prueba
@DisabledDesactiva la prueba
@ParameterizedTestPrueba con varios valores
@NestedGrupo de pruebas anidadas

Aserciones Esenciales​

assertEquals(expected, actual);
assertTrue(condition);
assertFalse(condition);
assertNull(value);
assertNotNull(value);
assertThrows(Exception.class, () -> ...);
assertAll("group", () -> ..., () -> ...);

Patrón de Prueba​

@Test
void should_ExpectedBehavior_When_Condition() {
// Arrange
// ...

// Act
// ...

// Assert
// ...
}

🎯 Próximo Paso​

JUnit 5 permite escribir pruebas, pero ¿cómo probar una clase que depende de otras clases? Ahí es donde entra Mockito.

➡️ Capítulo 3: Mocking con Mockito


⬅️ Volver al Módulo 9