StackPractices
intermediate By Mathias Paulenko

AssertJ Soft Assertions in JUnit5: Collect All Failures

How to use AssertJ soft assertions in JUnit5 to collect multiple assertion failures in a single test instead of stopping at the first failure.

Topics: testing

Overview

Standard JUnit5 assertions stop at the first failure. When you verify an object field by field, you see one error, fix it, rerun, and hit the next. AssertJ soft assertions collect every failure and report them together, so you get the full picture in a single test run.

When to Use

  • Verifying an object with 5+ fields where any combination might fail
  • Testing a response body with headers, status, and payload simultaneously
  • Validating a data transformation where all output properties should hold
  • You want faster debugging cycles: see all failures at once instead of one per run

When NOT to Use

  • Single-field assertions: a standard assertEquals is simpler and clearer
  • Assertions that depend on each other (if A fails, B is meaningless): use regular assertions
  • Performance-critical test suites with thousands of assertions: soft assertions add overhead

Solution

Setup with Maven

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

Setup with Gradle

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

Basic soft assertion

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();
    }
}

Using 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 on collections

@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();
}

Custom assertion messages

@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();
}

Field-by-field object comparison

@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();
}

Explanation

Every softly.assertThat(...) call goes through a proxy that catches the AssertionError and stores it in a collector instead of throwing. Nothing fails until assertAll() runs — it throws a SoftAssertionError whose message lists every collected failure in the order they happened.

Mermaid flowchart LR diagram

assertSoftly wraps the same mechanism in a lambda and calls assertAll() for you in a finally block, so you can’t forget it. SoftAssertionsExtension wires the collector into the JUnit lifecycle: all SoftAssertions fields and parameters injected into the same test share one collector, and the extension calls assertAll() automatically in afterTestExecution. Pick one mechanism per test class — mixing them creates separate collectors and splits the failure report.

One collector per test matters for unit testing: a SoftAssertions field shared across tests leaks failures from one test into the next.

Variants

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 with @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();
    }
}

Generating typed soft assertions for your domain classes

Writing hasEmail(), hasRole() style assertions by hand for every domain class is tedious. The assertj-assertions-generator-maven-plugin reads your POJOs and emits the assertion classes plus a SoftAssertions entry point for you:

<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>

During generate-test-sources, the plugin writes UserAssert, UserSoftAssertions, Assertions and friends under target/generated-test-sources/assertj-assertions for every class in the listed packages. The generated SoftAssertions extends org.assertj.core.api.SoftAssertions and overloads assertThat(User) to return your typed UserAssert:

import com.example.domain.SoftAssertions; // generated, not assertj-core's

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

Two gotchas when running it today: the latest plugin release is 2.2.0 (2019) and it emits javax.annotation.Generated, which the JDK removed in Java 11 — put javax.annotation:javax.annotation-api on the test classpath or the generated sources fail to compile. Once that’s in place, generated assertions work fine with current assertj-core (verified against 3.27.x), and the generated entry point keeps every standard assertThat overload, so typed and built-in checks share one collector.

Best Practices

  • Call softly.assertAll() at the end: without it, failures are silently swallowed
  • Use assertSoftly lambda for cleaner syntax when you don’t need to reuse the SoftAssertions object
  • Add custom messages with .as() for non-obvious assertions: they appear in the failure report
  • Don’t mix soft and hard assertions in the same test: the hard assertion stops early and hides the rest
  • Keep soft assertion blocks focused on one logical object or response

For the broader JUnit5 context — parameterized tests, extensions, lifecycle — see the JUnit5 modern testing guide. When your assertions need a real service behind them, pair them with Java Testcontainers Integration Tests.

Common Mistakes

  • Forgetting assertAll(): soft assertions without assertAll() always pass, even when checks fail.
  • Using soft assertions for independent tests: each test should verify one behavior. Soft assertions exist for a batch of checks on the same logical unit.
  • Overusing soft assertions for simple checks: with only 2 assertions, regular assertEquals reads better. Soft assertions earn their keep at 5+ checks.
  • Reusing one SoftAssertions across tests: the collector carries failures into the next test. Create it per test or per assertSoftly block.
  • Not adding descriptive messages: when 10 assertions fail, you need context to know which is which.

Troubleshooting

  • Test passes but assertions failed: assertAll() was never called. Without it, collected failures are discarded silently: prefer assertSoftly or SoftAssertionsExtension so it can’t be forgotten.
  • Only one failure appears in the report: a hard assertThat or an exception inside the block stops the test early. Move that check after the soft block, or route everything through softly.
  • Custom assertion failures aren’t collected: assertions built on the static Assertions.assertThat throw immediately. Call them through softly.assertThat(...) or extend AbstractSoftAssertions so the entry point is proxied.
  • @RegisterExtension doesn’t inject the parameter: the parameter type must implement SoftAssertionsProvider: SoftAssertions and BDDSoftAssertions work out of the box; a custom class has to extend it with a default constructor.
  • The report is unreadable at 50+ failures: cap soft blocks around 20–30 checks and split a large test into several assertSoftly blocks, one per logical section.

Further Reading

Production Notes

  • Keep each soft block under ~30 assertions: the aggregated report gets hard to scan long before the proxy overhead matters.
  • Check how your CI renders SoftAssertionError. Surefire lists every failure on its own line, but some dashboards truncate the message: .as() descriptions keep failures identifiable in logs.
  • Don’t tag soft-assertion tests out of CI runs. If they’re slow, the cause is usually thousands of proxied checks inside loops: aggregate the data first and assert once.
  • Soft assertions over shared state (a test database, a stub server) still need per-test isolation: a leftover record makes the report misleading, not just flaky.

Key Takeaways

  • assertAll() is the whole mechanism: without it, soft assertions silently pass. Prefer assertSoftly or SoftAssertionsExtension so it’s automatic.
  • One collector per test: don’t share a SoftAssertions instance across tests or mix the instance approach with the extension.
  • Route every check through softly.: a static assertThat throws on the first failure and defeats the collection.
  • .as() descriptions decide whether a 10-failure report is actionable or a wall of stack traces.

Frequently Asked Questions

What is the difference between AssertJ soft assertions and JUnit5 assertAll?

Both collect every failure instead of stopping at the first one. assertAll runs standard JUnit5 lambdas, so you get plain AssertionFailedError output. AssertJ soft assertions give you the fluent assertThat API — richer matchers, better diffs, and .as() descriptions in the aggregated report.

Can I use soft assertions with custom assertions?

Yes. Extend AbstractAssert to write your own assertion class, or extend AbstractSoftAssertions for a custom soft entry point, then call it through the softly proxy:

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

Keep the custom classes in a shared test package so every suite validates the same domain rules. If you'd rather not write them by hand, the assertj-assertions-generator-maven-plugin generates them from your POJOs — see the generator variant above.

Do soft assertions work with exceptions?

No. Soft assertions collect assertion failures, not exceptions — if the code under test throws, the test fails right there. Assert exceptions separately with assertThatThrownBy, which is a hard assertion by design.

How do I see all soft assertion failures in CI?

AssertJ prints every collected failure when assertAll() runs — Surefire and Gradle both forward it to the test report. The message lists each failed check with its .as() description, which is why custom messages pay off in CI logs.

How do I use soft assertions with parameterized tests?

Wrap each invocation in its own assertSoftly block or SoftAssertions instance — don't share one across parameters, since the collector would carry failures from earlier invocations. A failing input then fails only its own invocation, not the whole test.

How do I combine soft assertions with Hamcrest matchers?

Call the matcher overload inside the soft block: softly.assertThat(actual, matcher). That lets you migrate a Hamcrest suite to AssertJ gradually — one test at a time instead of a rewrite.

What is the performance impact of soft assertions?

Each check allocates a proxy and stores any failure in a list; assertAll() iterates that list once. For a typical test with a dozen checks you won't notice the cost. Inside loops over thousands of items, skip the proxies — collect values into a list and assert on it once instead.

Can I use soft assertions with Kotlin?

Yes, AssertJ works from Kotlin. SoftAssertions().apply { assertThat(x).isEqualTo(y) }.assertAll() gets the job done, but if Kotlin is your main test language, assertk's assertAll { assert(x).isEqualTo(y) } reads more idiomatically.

How do I migrate from JUnit4 Assert to JUnit5 soft assertions?

Add assertj-core (3.27.x), replace Assert.assertEquals(expected, actual) with softly.assertThat(actual).isEqualTo(expected), and end each migrated test with softly.assertAll(). The migration is mechanical — keep old and new assertions running side by side for a sprint to catch behavior drift before deleting the originals.

How do I use soft assertions with JSON responses?

Deserialize to a POJO with ObjectMapper.readValue(...) when you can, then assert on fields like any other object. For raw JsonNode, pull each field out and check it through softly — softly.assertThat(node.get("status").asText()).isEqualTo("ok"). On large arrays, iterate arrayNode.elements() and assert per element so one bad item doesn't hide the rest.