JUnit 5 representa una evolución revolucionaria en el mundo de las pruebas unitarias para Java, estableciendo un nuevo estándar para escribir tests más robustos, legibles y mantenibles. Esta versión introduce una arquitectura completamente renovada que separa las APIs de escritura de tests de las APIs de descubrimiento y ejecución, proporcionando una flexibilidad sin precedentes.
En el desarrollo de software moderno, las pruebas unitarias efectivas son fundamentales para garantizar la calidad del código, facilitar el mantenimiento y acelerar los ciclos de desarrollo. JUnit 5 no solo mejora la experiencia del desarrollador con nuevas anotaciones y assertions más expresivas, además que también ofrece soporte nativo para pruebas parametrizadas, pruebas dinámicas y integración perfecta con herramientas de build modernas.
Esta guía completa te llevará desde los conceptos básicos hasta técnicas avanzadas, incluyendo ejemplos prácticos, casos de uso reales y las mejores prácticas que todo desarrollador Java debe conocer para maximizar la efectividad de sus pruebas unitarias.
¿Qué es JUnit 5?
Arquitectura Renovada
JUnit 5 está construido sobre una arquitectura completamente nueva que se divide en tres sub-proyectos principales:
JUnit Platform: Proporciona la base para lanzar frameworks de testing en la JVM. Define la API TestEngine para desarrollar frameworks de testing que se ejecuten en la plataforma.
JUnit Jupiter: Incluye el nuevo modelo de programación y extensión para escribir tests en JUnit 5. Proporciona nuevas anotaciones, assertions y assumptions.
JUnit Vintage: Proporciona compatibilidad hacia atrás con JUnit 3 y JUnit 4, permitiendo una migración gradual.
Diferencias Clave con JUnit 4
// JUnit 4 - Enfoque tradicional
public class CalculatorTest {
@BeforeClass
public static void setUpClass() {
// Configuración única para toda la clase
}
@Before
public void setUp() {
// Configuración antes de cada test
}
@Test
public void testAddition() {
Calculator calc = new Calculator();
int result = calc.add(2, 3);
assertEquals(5, result);
}
}
// JUnit 5 - Enfoque moderno
class CalculatorTest {
@BeforeAll
static void setUpAll() {
// Configuración única para toda la clase
}
@BeforeEach
void setUp() {
// Configuración antes de cada test
}
@Test
@DisplayName("Should add two positive numbers correctly")
void shouldAddTwoPositiveNumbers() {
Calculator calc = new Calculator();
int result = calc.add(2, 3);
assertThat(result).isEqualTo(5);
}
}
Importancia y Beneficios de JUnit 5
Mejoras en la Legibilidad
JUnit 5 introduce mejoras significativas en la legibilidad y expresividad de las pruebas:
Assertions mejoradas: Mensajes de error más claros y descriptivos
DisplayName: Permite nombres descriptivos para los tests
Nested Tests: Organización jerárquica de pruebas relacionadas
@ExtendWith(MockitoExtension.class)
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class UserServiceTest {
@Mock
private UserRepository userRepository;
@InjectMocks
private UserService userService;
@Test
@DisplayName("Should create user successfully")
void shouldCreateUserSuccessfully() {
// Given
User user = new User("john.doe@example.com", "John Doe");
when(userRepository.save(any(User.class))).thenReturn(user);
// When
User createdUser = userService.createUser(user);
// Then
assertAll(
() -> assertThat(createdUser.getEmail()).isEqualTo("john.doe@example.com"),
() -> assertThat(createdUser.getName()).isEqualTo("John Doe"),
() -> verify(userRepository).save(user)
);
}
}
Soporte Nativo para Pruebas Parametrizadas
@ParameterizedTest
@ValueSource(strings = {"racecar", "radar", "able was I ere I saw elba"})
@DisplayName("Should identify palindromes correctly")
void shouldIdentifyPalindromes(String candidate) {
assertTrue(StringUtils.isPalindrome(candidate));
}
@ParameterizedTest
@CsvSource({
"1, 1, 2",
"2, 3, 5",
"10, 15, 25"
})
@DisplayName("Should add numbers correctly")
void shouldAddNumbers(int first, int second, int expected) {
Calculator calculator = new Calculator();
assertEquals(expected, calculator.add(first, second));
}
Ejemplos Prácticos Paso a Paso
Configuración Inicial del Proyecto
Para comenzar con JUnit 5, necesitas agregar las dependencias necesarias a tu pom.xml
:
<dependencies>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>5.9.2</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.assertj</groupId>
<artifactId>assertj-core</artifactId>
<version>3.24.2</version>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>3.0.0-M9</version>
</plugin>
</plugins>
</build>
Ejemplo Completo: Sistema de Gestión de Biblioteca
Vamos a crear un ejemplo completo que demuestre las capacidades de JUnit 5:
// Clase bajo prueba
public class Library {
private List<Book> books;
private List<Member> members;
public Library() {
this.books = new ArrayList<>();
this.members = new ArrayList<>();
}
public void addBook(Book book) {
if (book == null) {
throw new IllegalArgumentException("Book cannot be null");
}
books.add(book);
}
public void addMember(Member member) {
if (member == null) {
throw new IllegalArgumentException("Member cannot be null");
}
members.add(member);
}
public boolean borrowBook(String isbn, String memberId) {
Book book = findBookByIsbn(isbn);
Member member = findMemberById(memberId);
if (book == null || member == null || book.isBorrowed()) {
return false;
}
book.setBorrowed(true);
book.setBorrowedBy(memberId);
return true;
}
public List<Book> findBooksByAuthor(String author) {
return books.stream()
.filter(book -> book.getAuthor().equalsIgnoreCase(author))
.collect(Collectors.toList());
}
// Métodos auxiliares...
}
Suite de Pruebas Completa
@DisplayName("Library Management System Tests")
class LibraryTest {
private Library library;
@BeforeEach
void setUp() {
library = new Library();
}
@Nested
@DisplayName("Book Management Tests")
class BookManagementTests {
@Test
@DisplayName("Should add book successfully")
void shouldAddBookSuccessfully() {
// Given
Book book = new Book("978-0134685991", "Effective Java", "Joshua Bloch");
// When
library.addBook(book);
// Then
assertThat(library.getBooks()).hasSize(1);
assertThat(library.getBooks().get(0)).isEqualTo(book);
}
@Test
@DisplayName("Should throw exception when adding null book")
void shouldThrowExceptionWhenAddingNullBook() {
// When & Then
assertThatThrownBy(() -> library.addBook(null))
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("Book cannot be null");
}
@ParameterizedTest
@CsvSource({
"Joshua Bloch, 1",
"Robert Martin, 2",
"Kent Beck, 1"
})
@DisplayName("Should find books by author")
void shouldFindBooksByAuthor(String author, int expectedCount) {
// Given
library.addBook(new Book("123", "Effective Java", "Joshua Bloch"));
library.addBook(new Book("456", "Clean Code", "Robert Martin"));
library.addBook(new Book("789", "Clean Architecture", "Robert Martin"));
library.addBook(new Book("101", "Test Driven Development", "Kent Beck"));
// When
List<Book> books = library.findBooksByAuthor(author);
// Then
assertThat(books).hasSize(expectedCount);
}
}
@Nested
@DisplayName("Member Management Tests")
class MemberManagementTests {
@Test
@DisplayName("Should add member successfully")
void shouldAddMemberSuccessfully() {
// Given
Member member = new Member("M001", "John Doe", "john@example.com");
// When
library.addMember(member);
// Then
assertThat(library.getMembers()).hasSize(1);
assertThat(library.getMembers().get(0)).isEqualTo(member);
}
}
@Nested
@DisplayName("Borrowing System Tests")
class BorrowingSystemTests {
@BeforeEach
void setUpBorrowingTests() {
// Configuración específica para pruebas de préstamo
library.addBook(new Book("978-0134685991", "Effective Java", "Joshua Bloch"));
library.addMember(new Member("M001", "John Doe", "john@example.com"));
}
@Test
@DisplayName("Should allow member to borrow available book")
void shouldAllowMemberToBorrowAvailableBook() {
// When
boolean result = library.borrowBook("978-0134685991", "M001");
// Then
assertThat(result).isTrue();
Book borrowedBook = library.findBookByIsbn("978-0134685991");
assertThat(borrowedBook.isBorrowed()).isTrue();
assertThat(borrowedBook.getBorrowedBy()).isEqualTo("M001");
}
@Test
@DisplayName("Should not allow borrowing already borrowed book")
void shouldNotAllowBorrowingAlreadyBorrowedBook() {
// Given
library.borrowBook("978-0134685991", "M001");
library.addMember(new Member("M002", "Jane Smith", "jane@example.com"));
// When
boolean result = library.borrowBook("978-0134685991", "M002");
// Then
assertThat(result).isFalse();
}
@Test
@DisplayName("Should handle multiple borrowing scenarios")
void shouldHandleMultipleBorrowingScenarios() {
// Given
library.addBook(new Book("978-0135166307", "Clean Code", "Robert Martin"));
library.addMember(new Member("M002", "Jane Smith", "jane@example.com"));
// When & Then
assertAll(
() -> assertTrue(library.borrowBook("978-0134685991", "M001")),
() -> assertTrue(library.borrowBook("978-0135166307", "M002")),
() -> assertFalse(library.borrowBook("978-0134685991", "M002")),
() -> assertFalse(library.borrowBook("nonexistent", "M001"))
);
}
}
}
Casos de Uso Comunes en Entornos Reales
Pruebas de Integración con Base de Datos
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
@TestMethodOrder(OrderAnnotation.class)
@ExtendWith(SpringExtension.class)
@DataJpaTest
class UserRepositoryIntegrationTest {
@Autowired
private TestEntityManager entityManager;
@Autowired
private UserRepository userRepository;
@Test
@Order(1)
@DisplayName("Should save user and generate ID")
void shouldSaveUserAndGenerateId() {
// Given
User user = new User("test@example.com", "Test User");
// When
User savedUser = userRepository.save(user);
// Then
assertThat(savedUser.getId()).isNotNull();
assertThat(savedUser.getEmail()).isEqualTo("test@example.com");
}
@Test
@Order(2)
@DisplayName("Should find user by email")
void shouldFindUserByEmail() {
// Given
User user = new User("findme@example.com", "Find Me");
entityManager.persistAndFlush(user);
// When
Optional<User> foundUser = userRepository.findByEmail("findme@example.com");
// Then
assertThat(foundUser).isPresent();
assertThat(foundUser.get().getName()).isEqualTo("Find Me");
}
}
Pruebas de APIs REST
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@TestMethodOrder(OrderAnnotation.class)
class UserControllerIntegrationTest {
@Autowired
private TestRestTemplate restTemplate;
@LocalServerPort
private int port;
private String createURLWithPort(String uri) {
return "http://localhost:" + port + uri;
}
@Test
@Order(1)
@DisplayName("Should create user via REST API")
void shouldCreateUserViaRestAPI() {
// Given
User user = new User("api@example.com", "API User");
// When
ResponseEntity<User> response = restTemplate.postForEntity(
createURLWithPort("/api/users"),
user,
User.class
);
// Then
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED);
assertThat(response.getBody().getEmail()).isEqualTo("api@example.com");
}
@Test
@Order(2)
@DisplayName("Should return validation error for invalid email")
void shouldReturnValidationErrorForInvalidEmail() {
// Given
User invalidUser = new User("invalid-email", "Invalid User");
// When
ResponseEntity<String> response = restTemplate.postForEntity(
createURLWithPort("/api/users"),
invalidUser,
String.class
);
// Then
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
assertThat(response.getBody()).contains("Invalid email format");
}
}
Pruebas de Rendimiento y Timeout
class PerformanceTest {
@Test
@Timeout(value = 2, unit = TimeUnit.SECONDS)
@DisplayName("Should complete operation within time limit")
void shouldCompleteOperationWithinTimeLimit() {
// Given
DataProcessor processor = new DataProcessor();
List<Integer> largeDataSet = IntStream.range(0, 10000)
.boxed()
.collect(Collectors.toList());
// When & Then
assertDoesNotThrow(() -> processor.processData(largeDataSet));
}
@RepeatedTest(value = 10, name = "Performance test {currentRepetition}/{totalRepetitions}")
@DisplayName("Should maintain consistent performance")
void shouldMaintainConsistentPerformance() {
// Given
long startTime = System.currentTimeMillis();
// When
complexOperation();
// Then
long duration = System.currentTimeMillis() - startTime;
assertThat(duration).isLessThan(1000); // Should complete within 1 second
}
}
Errores Comunes y Cómo Evitarlos
Error 1: Mal Uso de Assertions
Problema Común:
@Test
void testUserCreationIncorrect() {
User user = new User("test@example.com", "Test User");
// ❌ Assertions separadas que no proporcionan contexto claro
assertEquals("test@example.com", user.getEmail());
assertEquals("Test User", user.getName());
assertNotNull(user.getId());
}
Solución Correcta:
@Test
@DisplayName("Should create user with correct properties")
void shouldCreateUserWithCorrectProperties() {
// Given
String email = "test@example.com";
String name = "Test User";
// When
User user = new User(email, name);
// Then
assertAll("User creation",
() -> assertThat(user.getEmail()).isEqualTo(email),
() -> assertThat(user.getName()).isEqualTo(name),
() -> assertThat(user.getId()).isNotNull(),
() -> assertThat(user.getCreatedAt()).isNotNull()
);
}
Error 2: Dependencias Entre Tests
Problema Común:
class OrderServiceTest {
private static Order sharedOrder;
@Test
void testCreateOrder() {
sharedOrder = orderService.createOrder(customer, items);
assertNotNull(sharedOrder.getId());
}
@Test
void testProcessOrder() {
// ❌ Depende del estado del test anterior
orderService.processOrder(sharedOrder);
assertEquals(OrderStatus.PROCESSED, sharedOrder.getStatus());
}
}
Solución Correcta:
class OrderServiceTest {
@Test
@DisplayName("Should create order successfully")
void shouldCreateOrderSuccessfully() {
// Given
Customer customer = createTestCustomer();
List<OrderItem> items = createTestItems();
// When
Order order = orderService.createOrder(customer, items);
// Then
assertThat(order.getId()).isNotNull();
assertThat(order.getStatus()).isEqualTo(OrderStatus.CREATED);
}
@Test
@DisplayName("Should process order correctly")
void shouldProcessOrderCorrectly() {
// Given - Cada test es independiente
Customer customer = createTestCustomer();
List<OrderItem> items = createTestItems();
Order order = orderService.createOrder(customer, items);
// When
orderService.processOrder(order);
// Then
assertThat(order.getStatus()).isEqualTo(OrderStatus.PROCESSED);
}
}
Error 3: Mocks Excesivos
Problema Común:
@Test
void testBusinessLogicWithExcessiveMocks() {
// ❌ Demasiados mocks hacen el test frágil
UserRepository userRepo = mock(UserRepository.class);
EmailService emailService = mock(EmailService.class);
AuditService auditService = mock(AuditService.class);
NotificationService notificationService = mock(NotificationService.class);
// Configuración excesiva de mocks...
when(userRepo.findById(anyLong())).thenReturn(Optional.of(new User()));
when(emailService.sendEmail(any())).thenReturn(true);
// ... más configuraciones
}
Solución Correcta:
@Test
@DisplayName("Should handle user registration business logic")
void shouldHandleUserRegistrationBusinessLogic() {
// Given - Solo mock lo esencial
UserRepository userRepo = mock(UserRepository.class);
EmailService emailService = mock(EmailService.class);
UserService userService = new UserService(userRepo, emailService);
User newUser = new User("test@example.com", "Test User");
when(userRepo.save(any(User.class))).thenReturn(newUser);
// When
User registeredUser = userService.registerUser(newUser);
// Then
assertThat(registeredUser.getEmail()).isEqualTo("test@example.com");
verify(emailService).sendWelcomeEmail(newUser);
}
Buenas Prácticas y Consejos Avanzados
1. Estructura de Pruebas: Given-When-Then
@Test
@DisplayName("Should calculate discount correctly for premium members")
void shouldCalculateDiscountForPremiumMembers() {
// Given - Configuración del escenario
Member premiumMember = new Member("M001", "Premium Member", MemberType.PREMIUM);
Product product = new Product("P001", "Laptop", new BigDecimal("1000.00"));
ShoppingCart cart = new ShoppingCart();
cart.addItem(product, 1);
// When - Ejecución de la acción
BigDecimal totalWithDiscount = discountService.calculateTotal(cart, premiumMember);
// Then - Verificación del resultado
assertThat(totalWithDiscount).isEqualTo(new BigDecimal("900.00")); // 10% discount
}
2. Uso de Test Fixtures y Builders
class TestDataBuilder {
public static User.Builder userBuilder() {
return User.builder()
.email("default@example.com")
.name("Default User")
.active(true)
.createdAt(LocalDateTime.now());
}
public static Product.Builder productBuilder() {
return Product.builder()
.name("Default Product")
.price(new BigDecimal("100.00"))
.category("Electronics")
.inStock(true);
}
}
@Test
@DisplayName("Should apply bulk discount for large orders")
void shouldApplyBulkDiscountForLargeOrders() {
// Given
User user = TestDataBuilder.userBuilder()
.memberType(MemberType.REGULAR)
.build();
Product product = TestDataBuilder.productBuilder()
.price(new BigDecimal("50.00"))
.build();
Order order = new Order();
order.addItem(product, 25); // Orden grande
// When
BigDecimal total = pricingService.calculateTotal(order, user);
// Then
assertThat(total).isLessThan(new BigDecimal("1250.00")); // Descuento aplicado
}
3. Pruebas de Condiciones de Borde
@ParameterizedTest
@ValueSource(ints = {0, 1, 99, 100, 101})
@DisplayName("Should handle edge cases for percentage validation")
void shouldHandleEdgeCasesForPercentageValidation(int percentage) {
// Given
DiscountValidator validator = new DiscountValidator();
// When & Then
if (percentage >= 0 && percentage <= 100) {
assertDoesNotThrow(() -> validator.validatePercentage(percentage));
} else {
assertThrows(IllegalArgumentException.class,
() -> validator.validatePercentage(percentage));
}
}
4. Pruebas de Concurrencia
@Test
@DisplayName("Should handle concurrent access to shared resource")
void shouldHandleConcurrentAccessToSharedResource() throws InterruptedException {
// Given
CountDownLatch latch = new CountDownLatch(1);
ExecutorService executor = Executors.newFixedThreadPool(10);
AtomicInteger counter = new AtomicInteger(0);
List<Future<Void>> futures = new ArrayList<>();
// When
for (int i = 0; i < 100; i++) {
futures.add(executor.submit(() -> {
latch.await();
sharedService.incrementCounter();
counter.incrementAndGet();
return null;
}));
}
latch.countDown(); // Libera todos los hilos simultáneamente
// Then
for (Future<Void> future : futures) {
future.get(); // Espera a que todos terminen
}
assertThat(sharedService.getCounter()).isEqualTo(100);
executor.shutdown();
}
5. Extensiones Personalizadas
public class DatabaseExtension implements BeforeEachCallback, AfterEachCallback {
@Override
public void beforeEach(ExtensionContext context) throws Exception {
// Configurar base de datos antes de cada test
DatabaseTestUtils.clearDatabase();
DatabaseTestUtils.insertTestData();
}
@Override
public void afterEach(ExtensionContext context) throws Exception {
// Limpiar después de cada test
DatabaseTestUtils.clearDatabase();
}
}
@ExtendWith(DatabaseExtension.class)
class UserRepositoryTest {
@Test
@DisplayName("Should find user by email")
void shouldFindUserByEmail() {
// La base de datos está limpia y con datos de prueba
Optional<User> user = userRepository.findByEmail("test@example.com");
assertThat(user).isPresent();
}
}
Métricas y Reporting Avanzado
Configuración de Cobertura con JaCoCo
<plugin>
<groupId>org.jacoco</groupId>
<artifactId>jacoco-maven-plugin</artifactId>
<version>0.8.8</version>
<executions>
<execution>
<goals>
<goal>prepare-agent</goal>
</goals>
</execution>
<execution>
<id>report</id>
<phase>test</phase>
<goals>
<goal>report</goal>
</goals>
</execution>
<execution>
<id>check</id>
<goals>
<goal>check</goal>
</goals>
<configuration>
<rules>
<rule>
<element>PACKAGE</element>
<limits>
<limit>
<counter>LINE</counter>
<value>COVEREDRATIO</value>
<minimum>0.80</minimum>
</limit>
</limits>
</rule>
</rules>
</configuration>
</execution>
</executions>
</plugin>
Configuración de Reporting Detallado
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>3.0.0-M9</version>
<configuration>
<properties>
<configurationParameters>
junit.jupiter.execution.parallel.enabled=true
junit.jupiter.execution.parallel.mode.default=concurrent
junit.jupiter.displayname.generator.default=org.junit.jupiter.api.DisplayNameGenerator$ReplaceUnderscores
</configurationParameters>
</properties>
</configuration>
</plugin>
JUnit 5 representa una evolución significativa en el ecosistema de pruebas unitarias para Java, ofreciendo herramientas más potentes, flexibles y expresivas para escribir tests de alta calidad. Primeramente hemos explorado desde los conceptos fundamentales hasta técnicas avanzadas que todo desarrollador Java debe dominar.
Las mejoras en la arquitectura, las nuevas anotaciones como @DisplayName
y @Nested
, el soporte nativo para pruebas parametrizadas y las capacidades de extensión hacen de JUnit 5 una herramienta indispensable para el desarrollo moderno. La implementación de buenas prácticas como la estructura Given-When-Then, el uso adecuado de mocks y la organización jerárquica de tests contribuyen significativamente a crear suites de pruebas mantenibles y efectivas.
Recordemos que las pruebas unitarias efectivas no solo detectan errores, sino que también sirven como documentación viva del código, facilitan el refactoring y aumentan la confianza en los cambios. Con JUnit 5, tenemos las herramientas necesarias para escribir tests que verdaderamente agreguen valor a nuestros proyectos.
Te animo a aplicar estos conceptos en tus proyectos actuales, experimentar con las diferentes funcionalidades y adaptar las técnicas mostradas a tus necesidades específicas. La inversión en pruebas de calidad siempre se traduce en software más robusto y mantenible.
¿Te ha resultado útil esta guía?
🔗 Síguenos en nuestras redes sociales para mantenerte actualizado con el último contenido sobre desarrollo de software.