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), luego when... 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:

  • Arrange crea el objeto Account con un saldo inicial.
  • Act ejecuta exactamente una acción siendo testeada (withdraw).
  • Assert verifica 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:

  • @CsvSource proporciona 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:

  • @Mock crea un objeto simulado para PaymentGateway — la parte que llama hacia afuera (podría ser una API real).
  • @InjectMocks inyecta el mock en el PaymentService siendo testeado.
  • verify confirma que charge fue 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.

testingjunitjavabest-practicesspring-boot