StackPractices
intermediate Por Mathias Paulenko

Soft Assertions con AssertJ en JUnit5: todos los fallos

Cómo usar AssertJ soft assertions en JUnit5 para recolectar múltiples fallos de aserción en un solo test en lugar de detenerse en el primer fallo.

Temas: testing

Descripción General

Las aserciones estándar de JUnit5 se detienen en el primer fallo. Cuando verificas un objeto campo por campo, ves un error, lo corriges, vuelves a ejecutar y aparece el siguiente. Las soft assertions de AssertJ recolectan todos los fallos y los reportan juntos, así que obtienes el panorama completo en una sola ejecución.

Cuándo Usar

  • Verificar un objeto con 5+ campos donde cualquier combinación puede fallar
  • Probar el cuerpo de una respuesta con headers, status y payload a la vez
  • Validar una transformación de datos donde todas las propiedades de salida deben cumplirse
  • Quieres ciclos de depuración más rápidos: ver todos los fallos de una vez en lugar de uno por ejecución

Cuándo NO Usar

  • Aserciones de un solo campo: un assertEquals estándar es más simple y claro
  • Aserciones que dependen entre sí (si A falla, B no tiene sentido): usa aserciones normales
  • Suites con miles de aserciones donde el rendimiento es crítico: las soft assertions añaden overhead

Solución

Setup con Maven

<dependency>
    <groupId>org.assertj</groupId>
    <artifactId>assertj-core</artifactId>
    <version>3.27.7</version>
    <scope>test</scope>
</dependency>

Setup con Gradle

testImplementation 'org.assertj:assertj-core:3.27.7'

Soft assertion básico

import org.assertj.core.api.SoftAssertions;
import org.junit.jupiter.api.Test;

class UserTest {

    @Test
    void should_validate_all_user_fields() {
        User user = userService.findById(1);

        SoftAssertions softly = new SoftAssertions();
        softly.assertThat(user.getId()).isEqualTo(1);
        softly.assertThat(user.getEmail()).isEqualTo("alice@example.com");
        softly.assertThat(user.getRole()).isEqualTo("admin");
        softly.assertThat(user.isActive()).isTrue();
        softly.assertThat(user.getCreatedAt()).isNotNull();
        softly.assertAll();
    }
}

Usando assertSoftly lambda

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.SoftAssertions.assertSoftly;

import java.time.LocalDate;
import org.junit.jupiter.api.Test;

class OrderTest {

    @Test
    void should_validate_order_response() {
        OrderResponse response = orderService.placeOrder(request);

        assertSoftly(softly -> {
            softly.assertThat(response.getStatusCode()).isEqualTo(201);
            softly.assertThat(response.getOrderId()).isNotNull();
            softly.assertThat(response.getTotal()).isEqualByComparingTo("99.99");
            softly.assertThat(response.getItems()).hasSize(3);
            softly.assertThat(response.getEstimatedDelivery()).isAfterOrEqualTo(LocalDate.now().plusDays(1));
        });
    }
}

Soft assertions en colecciones

@Test
void should_validate_all_products() {
    List<Product> products = productRepository.findAll();

    SoftAssertions softly = new SoftAssertions();
    softly.assertThat(products).hasSize(50);

    for (Product p : products) {
        softly.assertThat(p.getName()).isNotBlank();
        softly.assertThat(p.getPrice()).isPositive();
        softly.assertThat(p.getSku()).matches("^[A-Z]{3}-\\d{4}$");
    }
    softly.assertAll();
}

Mensajes de aserción personalizados

@Test
void should_validate_with_custom_messages() {
    Config config = configService.load("production");

    SoftAssertions softly = new SoftAssertions();
    softly.assertThat(config.getTimeout())
        .as("Production timeout must be at least 30s")
        .isGreaterThanOrEqualTo(30);
    softly.assertThat(config.getRetries())
        .as("Production retries must be between 1 and 5")
        .isBetween(1, 5);
    softly.assertThat(config.getFeatureFlags())
        .as("Feature flags must include 'monitoring'")
        .containsKey("monitoring");
    softly.assertAll();
}

Comparación de objetos campo a campo

@Test
void should_validate_user_dto() {
    UserDto user = UserDto.builder()
        .id(42)
        .name("Alice")
        .email("alice@example.com")
        .role("admin")
        .active(true)
        .build();

    SoftAssertions softly = new SoftAssertions();
    softly.assertThat(user)
        .usingRecursiveComparison()
        .ignoringFields("createdAt")
        .isEqualTo(expectedUser);
    softly.assertAll();
}

Explicación

Cada llamada a softly.assertThat(...) pasa por un proxy que captura el AssertionError y lo guarda en un colector en lugar de lanzarlo. Nada falla hasta que se ejecuta assertAll() — entonces lanza un SoftAssertionError cuyo mensaje lista todos los fallos recolectados en el orden en que ocurrieron.

Mermaid flowchart LR diagram

assertSoftly envuelve el mismo mecanismo en una lambda y llama assertAll() por ti en un bloque finally, así que no puedes olvidarlo. SoftAssertionsExtension conecta el colector al ciclo de vida de JUnit: todos los campos y parámetros SoftAssertions inyectados en el mismo test comparten un colector, y la extensión llama assertAll() automáticamente en afterTestExecution. Elige un mecanismo por clase de test — mezclarlos crea colectores separados y divide el reporte de fallos.

Un colector por test importa en unit testing: un campo SoftAssertions compartido entre tests filtra fallos de un test al siguiente.

Variantes

JUnit5 assertAll (built-in)

import static org.junit.jupiter.api.Assertions.assertAll;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;

@Test
void should_validate_with_junit5_assert_all() {
    User user = userService.findById(1);

    assertAll(
        () -> assertEquals(1, user.getId()),
        () -> assertEquals("alice@example.com", user.getEmail()),
        () -> assertTrue(user.isActive()),
        () -> assertEquals("admin", user.getRole())
    );
}

Soft assertions con @RegisterExtension

import org.assertj.core.api.junit.jupiter.SoftAssertionsExtension;
import org.assertj.core.api.SoftAssertions;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.RegisterExtension;

class ServiceTest {

    @RegisterExtension
    final SoftAssertionsExtension soft = new SoftAssertionsExtension();

    @Test
    void should_validate(SoftAssertions softly) {
        Result result = service.execute();
        softly.assertThat(result.getCode()).isEqualTo(200);
        softly.assertThat(result.getData()).isNotEmpty();
    }
}

Generar soft assertions tipadas para tus clases de dominio

Escribir a mano aserciones estilo hasEmail(), hasRole() para cada clase de dominio es tedioso. El assertj-assertions-generator-maven-plugin lee tus POJOs y emite las clases de aserción más un punto de entrada SoftAssertions por ti:

<plugin>
    <groupId>org.assertj</groupId>
    <artifactId>assertj-assertions-generator-maven-plugin</artifactId>
    <version>2.2.0</version>
    <executions>
        <execution>
            <goals>
                <goal>generate-assertions</goal>
            </goals>
            <configuration>
                <packages>
                    <param>com.example.domain</param>
                </packages>
            </configuration>
        </execution>
    </executions>
</plugin>

Durante generate-test-sources, el plugin escribe UserAssert, UserSoftAssertions, Assertions y compañía bajo target/generated-test-sources/assertj-assertions para cada clase de los paquetes listados. El SoftAssertions generado extiende org.assertj.core.api.SoftAssertions y sobrecarga assertThat(User) para devolver tu UserAssert tipado:

import com.example.domain.SoftAssertions; // generado, no el de assertj-core

SoftAssertions softly = new SoftAssertions();
softly.assertThat(user)
    .hasId(1)
    .hasEmail("alice@example.com")
    .hasRole("admin")
    .isActive();
softly.assertAll();

Dos advertencias al usarlo hoy: la última versión del plugin es 2.2.0 (2019) y emite javax.annotation.Generated, que el JDK eliminó en Java 11 — agrega javax.annotation:javax.annotation-api al classpath de test o los fuentes generados no compilan. Una vez resuelto eso, las aserciones generadas funcionan bien con assertj-core actual (verificado contra 3.27.x), y el punto de entrada generado conserva todos los overloads estándar de assertThat, así que las verificaciones tipadas y built-in comparten un mismo colector.

Buenas Prácticas

  • Llama softly.assertAll() al final: sin eso, los fallos se descartan en silencio
  • Usa la lambda assertSoftly para una sintaxis más limpia cuando no necesitas reutilizar el objeto SoftAssertions
  • Agrega mensajes personalizados con .as() en aserciones no obvias: aparecen en el reporte de fallos
  • No mezcles soft y hard assertions en el mismo test: la hard assertion se detiene antes y oculta el resto
  • Mantén los bloques de soft assertions enfocados en un objeto o respuesta lógica

Para el contexto más amplio de JUnit5 — parameterized tests, extensiones, ciclo de vida — consulta la guía de testing moderno con JUnit5. Cuando tus aserciones necesitan un servicio real detrás, combínalas con tests de integración con Testcontainers.

Errores Comunes

  • Olvidar assertAll(): las soft assertions sin assertAll() siempre pasan, incluso cuando las verificaciones fallan.
  • Usar soft assertions para tests independientes: cada test debería verificar un comportamiento. Las soft assertions existen para un lote de verificaciones sobre la misma unidad lógica.
  • Sobreusar soft assertions para checks simples: con solo 2 aserciones, assertEquals se lee mejor. Las soft assertions valen la pena a partir de 5+ checks.
  • Reutilizar un SoftAssertions entre tests: el colector arrastra fallos al siguiente test. Créalo por test o por bloque assertSoftly.
  • No agregar mensajes descriptivos: cuando fallan 10 aserciones, necesitas contexto para saber cuál es cuál.

Solución de Problemas

  • El test pasa pero las aserciones fallaron: nunca se llamó assertAll(). Sin él, los fallos recolectados se descartan en silencio: prefiere assertSoftly o SoftAssertionsExtension para que no pueda olvidarse.
  • Solo aparece un fallo en el reporte: un assertThat hard o una excepción dentro del bloque detiene el test antes de tiempo. Mueve esa verificación después del bloque soft, o enruta todo por softly.
  • Los fallos de aserciones personalizadas no se recolectan: las aserciones creadas con el Assertions.assertThat estático lanzan inmediatamente. Llámalas a través de softly.assertThat(...) o extiende AbstractSoftAssertions para que el punto de entrada sea proxied.
  • @RegisterExtension no inyecta el parámetro: el tipo del parámetro debe implementar SoftAssertionsProvider: SoftAssertions y BDDSoftAssertions funcionan directamente; una clase personalizada debe extenderlo con un constructor por defecto.
  • El reporte es ilegible con 50+ fallos: limita los bloques soft a unas 20–30 verificaciones y divide un test grande en varios bloques assertSoftly, uno por sección lógica.

Lectura Adicional

Notas de Producción

  • Mantén cada bloque soft por debajo de ~30 aserciones: el reporte agregado se vuelve difícil de leer mucho antes de que el overhead del proxy importe.
  • Revisa cómo tu CI muestra SoftAssertionError. Surefire lista cada fallo en su propia línea, pero algunos dashboards truncan el mensaje: las descripciones .as() mantienen los fallos identificables en los logs.
  • No excluyas los tests de soft assertions de las corridas de CI. Si son lentos, la causa suele ser miles de verificaciones proxied dentro de bucles: agrega los datos primero y aserta una sola vez.
  • Las soft assertions sobre estado compartido (una base de datos de test, un stub server) siguen necesitando aislamiento por test: un registro residual hace el reporte engañoso, no solo flaky.

Puntos Clave

  • assertAll() es todo el mecanismo: sin él, las soft assertions pasan en silencio. Prefiere assertSoftly o SoftAssertionsExtension para que sea automático.
  • Un colector por test: no compartas una instancia SoftAssertions entre tests ni mezcles el enfoque de instancia con la extensión.
  • Enruta cada verificación por softly.: un assertThat estático lanza en el primer fallo y anula la recolección.
  • Las descripciones .as() deciden si un reporte de 10 fallos es accionable o un muro de stack traces.

Preguntas frecuentes

¿Cuál es la diferencia entre AssertJ soft assertions y JUnit5 assertAll?

Ambos recolectan todos los fallos en lugar de detenerse en el primero. assertAll ejecuta lambdas estándar de JUnit5, así que obtienes salida plana de AssertionFailedError. Las soft assertions de AssertJ te dan la API fluida assertThat — matchers más ricos, mejores diffs y descripciones .as() en el reporte agregado.

¿Puedo usar soft assertions con aserciones personalizadas?

Sí. Extiende AbstractAssert para escribir tu propia clase de aserción, o extiende AbstractSoftAssertions para un punto de entrada soft personalizado, y luego llámalo a través del proxy softly:

softly.assertThat(user).hasValidEmail().hasActiveSubscription();

Mantén las clases personalizadas en un paquete de test compartido para que cada suite valide las mismas reglas de dominio. Si prefieres no escribirlas a mano, el assertj-assertions-generator-maven-plugin las genera desde tus POJOs — ver la variante del generador más arriba.

¿Las soft assertions funcionan con excepciones?

No. Las soft assertions recolectan fallos de aserción, no excepciones — si el código bajo test lanza, el test falla ahí mismo. Aserciona excepciones por separado con assertThatThrownBy, que es una aserción hard por diseño.

¿Cómo veo todos los fallos de soft assertions en CI?

AssertJ imprime cada fallo recolectado cuando se ejecuta assertAll() — tanto Surefire como Gradle lo reenvían al reporte de tests. El mensaje lista cada verificación fallida con su descripción .as(), razón por la que los mensajes personalizados valen la pena en los logs de CI.

¿Cómo uso soft assertions con parameterized tests?

Envuelve cada invocación en su propio bloque assertSoftly o instancia SoftAssertions — no compartas una entre parámetros, porque el colector arrastraría fallos de invocaciones anteriores. Un input que falla solo tumba su propia invocación, no todo el test.

¿Cómo combino soft assertions con matchers de Hamcrest?

Llama el overload con matcher dentro del bloque soft: softly.assertThat(actual, matcher). Eso te permite migrar una suite de Hamcrest a AssertJ gradualmente — un test a la vez en lugar de una reescritura completa.

¿Cuál es el impacto de performance de las soft assertions?

Cada verificación crea un proxy y guarda cualquier fallo en una lista; assertAll() itera esa lista una sola vez. En un test típico con una docena de verificaciones no notarás el costo. Dentro de bucles sobre miles de elementos, salta los proxies — recolecta los valores en una lista y aserta sobre ella una sola vez.

¿Puedo usar soft assertions con Kotlin?

Sí, AssertJ funciona desde Kotlin. SoftAssertions().apply { assertThat(x).isEqualTo(y) }.assertAll() hace el trabajo, pero si Kotlin es tu lenguaje principal de tests, assertAll { assert(x).isEqualTo(y) } de assertk se lee más idiomático.

¿Cómo migro de JUnit4 Assert a JUnit5 soft assertions?

Agrega assertj-core (3.27.x), reemplaza Assert.assertEquals(expected, actual) con softly.assertThat(actual).isEqualTo(expected), y termina cada test migrado con softly.assertAll(). La migración es mecánica — mantén las aserciones viejas y nuevas corriendo en paralelo durante un sprint para detectar desviaciones de comportamiento antes de borrar las originales.

¿Cómo uso soft assertions con respuestas JSON?

Deserializa a un POJO con ObjectMapper.readValue(...) cuando puedas, y luego aserta sobre los campos como cualquier otro objeto. Para JsonNode sin procesar, extrae cada campo y verifícalo a través de softly — softly.assertThat(node.get("status").asText()).isEqualTo("ok"). En arrays grandes, itera arrayNode.elements() y aserta por elemento para que un ítem malo no oculte al resto.