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
assertEqualsis 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.
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
assertSoftlylambda for cleaner syntax when you don’t need to reuse theSoftAssertionsobject - 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 withoutassertAll()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
assertEqualsreads better. Soft assertions earn their keep at 5+ checks. - Reusing one
SoftAssertionsacross tests: the collector carries failures into the next test. Create it per test or perassertSoftlyblock. - 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: preferassertSoftlyorSoftAssertionsExtensionso it can’t be forgotten. - Only one failure appears in the report: a hard
assertThator an exception inside the block stops the test early. Move that check after the soft block, or route everything throughsoftly. - Custom assertion failures aren’t collected: assertions built on the static
Assertions.assertThatthrow immediately. Call them throughsoftly.assertThat(...)or extendAbstractSoftAssertionsso the entry point is proxied. @RegisterExtensiondoesn’t inject the parameter: the parameter type must implementSoftAssertionsProvider:SoftAssertionsandBDDSoftAssertionswork 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
assertSoftlyblocks, one per logical section.
Further Reading
- AssertJ documentation: the official reference for
SoftAssertions,assertSoftly, andSoftAssertionsExtension. - SoftAssertions Javadoc: every proxied
assertThatoverload and theassertAllcontract. - JUnit 5 User Guide:
assertAll,@RegisterExtension, and parameterized tests. - Complete guide to JUnit5 modern testing: lifecycle, extensions, and test organization on this site.
- Runnable companion project: the examples in this recipe as a buildable Maven project.
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. PreferassertSoftlyorSoftAssertionsExtensionso it’s automatic.- One collector per test: don’t share a
SoftAssertionsinstance across tests or mix the instance approach with the extension. - Route every check through
softly.: a staticassertThatthrows 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.
Related Resources
Unit Testing
How to write fast, deterministic unit tests with mocks and assertions in Python, JavaScript, and Java.
RecipeJava Testcontainers Integration Tests
How to use Testcontainers in JUnit5 to spin up real Postgres, Redis, and Kafka containers for integration tests that are reliable and reproducible.
RecipeStub External HTTP APIs with WireMock in Java
Use WireMock in Java tests to stub external HTTP services. Covers response templating, delay simulation, stateful stubs, and request verification.
GuideJUnit 5: Extensions, Parameterized Tests, Dynamic Tests
Master JUnit 5 for modern Java testing: extensions model, parameterized tests, dynamic tests, test interfaces, lifecycle, conditional execution, and JUnit Platform integration.