Unit test bien hecho: nombres, estructura y cobertura
1. Descripción general
Las unit tests bien hechas no son solo aquellas que “pasan”. Deben ser fáciles de leer, fáciles de mantener, y cuando fallan, deben indicarte exactamente dónde está el problema sin necesidad de pasar horas debuggueando. Este artículo trata tres pilares: cómo nombrar tests de forma que se auto-expliquen, cómo estructurar tests según AAA (Arrange-Act-Assert), y cómo entender correctamente la cobertura (coverage) para no caer en la trampa de “100% coverage pero inútil”.
Después de leerlo, tendrás un checklist específico para revisar tus tests antes de crear un pull request.
2. Dependencias necesarias
Los ejemplos de este artículo usan JUnit 5 y AssertJ. Si tu proyecto es Spring Boot, spring-boot-starter-test ya incluye ambos.
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
3. Nombrado de tests: el nombre debe contar una historia
El nombre del test es el primer documento que otros leen cuando falla. Un buen nombre responde tres preguntas: qué se está testeando, bajo qué condición, y qué se espera.
3.1. Patrón methodName_condition_expectedResult
class OrderServiceTest {
@Test
void calculateTotal_whenCartIsEmpty_returnsZero() {
OrderService service = new OrderService();
Order order = new Order(List.of());
BigDecimal total = service.calculateTotal(order);
assertThat(total).isEqualTo(BigDecimal.ZERO);
}
@Test
void calculateTotal_whenCartHasDiscountedItems_appliesDiscount() {
OrderService service = new OrderService();
Order order = new Order(List.of(
new Item("SKU-1", BigDecimal.valueOf(100), 0.1)
));
BigDecimal total = service.calculateTotal(order);
assertThat(total).isEqualTo(BigDecimal.valueOf(90.0));
}
}
Explicación:
- El nombre comienza con el método siendo testeado (
calculateTotal), luegowhen...establece la condición, y finalmente el resultado esperado. - Cuando el test falla en un reporte CI, leyendo el nombre sabes inmediatamente en qué rama lógica está el problema — no necesitas abrir el código.
Nota: evita nombres como
test1(),testCalculateTotal(). No dicen nada sobre el contexto, y obligan al lector a entrar en el cuerpo del test para entender qué se está verificando.
3.2. @DisplayName cuando el nombre del método no es suficiente
Con reglas de negocio complejas, los nombres de método largos son difíciles de leer. Usa @DisplayName para escribir una oración más natural, manteniendo el nombre del método corto para la navegación del código.
@Test
@DisplayName("Cuando el cliente es VIP y el pedido es mayor a 1 millón, aplicar descuento adicional del 5%")
void calculateTotal_vipCustomerLargeOrder_extraDiscount() {
// ...
}
Al ejecutar con Maven Surefire o tu IDE, la salida mostrará la oración @DisplayName en lugar del nombre del método, más fácil de leer en el reporte.
4. Estructura AAA: Arrange – Act – Assert
Cada test debe tener tres partes claras, en el orden correcto, sin mezclar. Esto permite al lector escanear rápidamente: qué líneas preparan los datos, cuál línea ejecuta el comportamiento, cuál verifica el resultado.
@Test
void withdraw_whenBalanceIsSufficient_deductsAmount() {
// Arrange
Account account = new Account("ACC-001", BigDecimal.valueOf(500));
// Act
account.withdraw(BigDecimal.valueOf(200));
// Assert
assertThat(account.getBalance()).isEqualTo(BigDecimal.valueOf(300));
}
Explicación:
Arrangecrea el objetoAccountcon un saldo inicial.Actejecuta exactamente una acción siendo testeada (withdraw).Assertverifica el resultado, usando AssertJ para que el assert se lea como prosa fluida.
4.1. Un test, un comportamiento
Cuantas más cosas hace un test, más difícil es debuggear cuando falla. Evita escribir tests así:
@Test
void accountOperations() {
Account account = new Account("ACC-001", BigDecimal.valueOf(500));
account.deposit(BigDecimal.valueOf(100));
assertThat(account.getBalance()).isEqualTo(BigDecimal.valueOf(600));
account.withdraw(BigDecimal.valueOf(200));
assertThat(account.getBalance()).isEqualTo(BigDecimal.valueOf(400));
account.withdraw(BigDecimal.valueOf(1000)); // error: insufficient funds
assertThat(account.getBalance()).isEqualTo(BigDecimal.valueOf(400));
}
Si el primer assert falla, los dos comportamientos siguientes no se ejecutan — pierdes información. Separa en tres tests: deposit_..., withdraw_sufficientBalance_..., withdraw_insufficientBalance_throwsException.
4.2. Tests de excepciones deben verificar el tipo y el mensaje correctos
@Test
void withdraw_whenBalanceIsInsufficient_throwsIllegalStateException() {
Account account = new Account("ACC-001", BigDecimal.valueOf(100));
assertThatThrownBy(() -> account.withdraw(BigDecimal.valueOf(500)))
.isInstanceOf(IllegalStateException.class)
.hasMessageContaining("Insufficient balance");
}
La línea assertThatThrownBy ejecuta la acción y captura la excepción en un paso. No uses la antigua forma @Test(expected = ...) porque no permite verificar el mensaje o el estado después de la excepción.
5. Usa @ParameterizedTest para reducir código duplicado
Cuando múltiples inputs diferentes verifican la misma lógica, usa parameterized test en lugar de copiar-pegar varios métodos.
@ParameterizedTest
@CsvSource({
"0, 0",
"100, 10",
"1000, 100",
"-50, 0"
})
void calculateShippingFee_variousOrderAmounts_returnsExpectedFee(
BigDecimal orderAmount, BigDecimal expectedFee) {
ShippingCalculator calculator = new ShippingCalculator();
BigDecimal fee = calculator.calculateFee(orderAmount);
assertThat(fee).isEqualTo(expectedFee);
}
Explicación:
@CsvSourceproporciona conjuntos de entrada/salida, cada línea es un caso de test independiente.- El reporte mostrará cada caso por separado, sabrás exactamente cuál falló sin tener que adivinar.
calculateShippingFee_variousOrderAmounts_returnsExpectedFee(BigDecimal, BigDecimal)
✓ [1] 0, 0
✓ [2] 100, 10
✗ [3] 1000, 100
✓ [4] -50, 0
6. Mock al nivel correcto, no hagas overmocking
Usa mock para dependencias externas (base de datos, cliente HTTP, otros servicios), no para el objeto siendo testeado.
@ExtendWith(MockitoExtension.class)
class PaymentServiceTest {
@Mock
private PaymentGateway paymentGateway;
@InjectMocks
private PaymentService paymentService;
@Test
void processPayment_whenGatewayApproves_marksOrderAsPaid() {
Order order = new Order("ORD-01", BigDecimal.valueOf(200));
when(paymentGateway.charge(order.getAmount())).thenReturn(ApprovalResult.approved());
PaymentResult result = paymentService.processPayment(order);
assertThat(result.isSuccess()).isTrue();
verify(paymentGateway).charge(BigDecimal.valueOf(200));
}
}
Explicación:
@Mockcrea un objeto simulado paraPaymentGateway— la parte que llama hacia afuera (podría ser una API real).@InjectMocksinyecta el mock en elPaymentServicesiendo testeado.verifyconfirma quechargefue llamado con los parámetros correctos, no solo verifica el valor retornado.
Nota: si tienes que hacer mock de más de 3-4 dependencias para un test, eso indica que la clase está haciendo demasiadas cosas. Considera refactorizar la clase antes de refactorizar el test.
7. Cobertura (coverage): métrica de referencia, no es un objetivo
Una cobertura alta no significa tests de calidad. Por ejemplo, este test logra 100% de cobertura de líneas para el método divide pero no detecta un bug:
public int divide(int a, int b) {
return a / b;
}
@Test
void divide_returnsQuotient() {
Calculator calculator = new Calculator();
int result = calculator.divide(10, 2);
assertThat(result).isEqualTo(5);
}
Este test ejecuta todas las líneas de código (100% de cobertura) pero no testea el caso b = 0 — donde se lanzaría ArithmeticException en producción. La cobertura solo mide qué líneas se ejecutaron, no si se verificaron correctamente.
7.1. Usa cobertura para encontrar puntos ciegos, no para reportes bonitos
<plugin>
<groupId>org.jacoco</groupId>
<artifactId>jacoco-maven-plugin</artifactId>
<version>0.8.12</version>
<executions>
<execution>
<goals><goal>prepare-agent</goal></goals>
</execution>
<execution>
<id>report</id>
<phase>test</phase>
<goals><goal>report</goal></goals>
</execution>
</executions>
</plugin>
Ejecuta mvn test, JaCoCo genera un reporte HTML en target/site/jacoco/index.html. Usa este reporte para responder “¿qué rama no ha sido tocada por tests?”, luego pregúntate “¿esa rama tiene lógica importante?” — no sigas números de % absolutos.
Element Missed Instructions Cov.
Calculator 12 of 40 70%
divide() 4 of 8 50% ← no testea el caso b=0
7.2. Casos límite (edge cases) importan más que el porcentaje %
Agrega un test para el caso omitido arriba:
@Test
void divide_whenDivisorIsZero_throwsArithmeticException() {
Calculator calculator = new Calculator();
assertThatThrownBy(() -> calculator.divide(10, 0))
.isInstanceOf(ArithmeticException.class);
}
Una línea de test como esta vale mucho más que escribir 5 tests adicionales que repitan el caso normal solo para aumentar el % de cobertura.
8. Checklist de auto-revisión
Antes de crear un pull request, verifica:
- ¿El nombre del test explica claramente la condición y la expectativa, o es solo
test1,testX? - ¿Cada test tiene exactamente un comportamiento, una razón para fallar?
- ¿Hay casos límite: null, empty, 0, valores negativos, límites superiores?
- ¿Hay tests de errores (excepciones), o solo el caso feliz?
- ¿Dónde la cobertura es baja — es lógica importante omitida, o solo getters/setters que no necesitan test?
9. Conclusión
Un unit test bien hecho es aquel donde, cuando falla, sabes exactamente dónde está el problema sin necesidad de debuggear. Nombra según el patrón method_condition_expected, mantén la estructura AAA clara con un comportamiento por test, y usa cobertura como herramienta para encontrar puntos ciegos en lugar de un objetivo a alcanzar — esos son tres principios simples pero suficientes para elevar la calidad de tu suite de tests a otro nivel. La parte difícil no es escribir asserts, sino dedicar tiempo a pensar en los casos límite que tu código podría encontrar en producción.