AssertJ Complete Guide: Soft Assertions, Recursive Comparison and Custom Assertions
A beginner-first AssertJ 3.27.7 guide with real failure messages: fluent assertions, collections, exceptions, soft assertions and their traps, recursive comparison for DTOs, and custom assertions both hand-written and generated (including the javax.annotation fix the 2019 generator plugin needs on JDK 25). Every output is captured from a runnable companion module.
You change a pricing rule, the build goes red, and the failure says expected: <true> but was: <false>. Which of the nine assertTrue calls in that test was it? You open the test, add a print statement, rerun, and find that the second assertion also failed — but you never saw it, because the first one had already stopped the test. That small loss of time, repeated across a codebase, is the argument for AssertJ.
AssertJ is a library of fluent assertions for Java tests. You write assertThat(actual) and then chain checks that read like English, and when one fails the message tells you what was expected, what was found, and which collection element or object field was the difference. This guide starts with the smallest working example and then goes where the real value is: collections, exceptions, soft assertions (report every failure instead of the first), recursive comparison (compare two objects field by field when they have no equals, which is how you test a DTO), and custom assertions (your own domain-language checks, written by hand or generated). Every message you see below was captured from a real run of the companion module, and the failure modes come first because that is where the hours go.
Versions this guide was verified against. AssertJ Core 3.27.7 (the latest GA on Maven Central; its jar was published 2026-01-24 and still targets Java 8), JUnit 6.1.3, JDK 25 (Temurin 25.0.5+8). assertj-core4.0.0-M1 exists on Maven Central but is a milestone, not a release; the companion tests also pass against it, see the last section. The generator plugin used for custom assertions is 2.2.0, whose last release was in 2019; it still works, with one fix you need on JDK 11 and later. Code: java-testing-lab/assertj. JUnit 6 background is in the JUnit 5 to 6 migration guide.
A failing test should fail with a sentence, not a riddle
The whole idea fits in one picture. assertThat(actual) does not check anything; it wraps your value in an assertion object whose type depends on what you passed in — a string gets string checks, a list gets list checks. Each method you chain checks one thing and returns the same object, so you can keep going. The first check that fails throws a plain java.lang.AssertionError, which every test runner already understands.
The figure shows the one fact the rest of the guide depends on: the chain only does something when a method on the assertion object is called. Hold on to that; it pays off in the next section. To use it, add one test-scoped dependency (JUnit 6 is already there for running tests; AssertJ is not part of JUnit). The versions here are the ones from the companion module’s pom.xml.
The point of the exercise is what happens when it fails. The three failures below were provoked deliberately and captured from 01-basic-failure-messages.txt. Notice that the message states the expected and actual values without you writing a message, and that as(..) puts your own label in square brackets in front.
startsWith failure:
Expecting actual:
"Hello, AssertJ"
to start with:
"Goodbye"
isEqualTo failure:
expected: 43
but was: 42
as(...) failure:
[answer to everything]
expected: 43
but was: 42
Static-import exactly one assertThat. org.assertj.core.api.Assertions is the usual choice; a class that extends it (shown in the custom-assertions section) lets you add your own overloads and keep a single import.
The assertion that asserts nothing, and two other traps
Because assertThat(x) only builds the assertion object, a line that forgets the final check compiles, runs and passes. This is the most common AssertJ mistake and it is invisible: the test is green and verifies nothing. The test below deliberately contains two such lines; the transcript is 02-forgotten-terminal-assertion.txt.
Fingerprint of this bug. A test that stays green when you break the code it covers. The cure is mechanical: if a line starts with assertThat( it must end with a call such as isEqualTo, isTrue, isEmpty. The assertThat factories that the generator writes (later in this guide) are annotated @CheckReturnValue, which static-analysis tools can use to flag a discarded result; I did not test any particular tool, and did not check whether the core Assertions factories carry the same annotation.
Two further traps live in numbers. A BigDecimal2.00 is not equals to 2.0 because scale is part of equality, so isEqualTo fails where isEqualByComparingTo passes. And 0.1 + 0.2 is not 0.3 in binary floating point, so use isCloseTo with a tolerance. The snippet is from BasicAssertionsTest.java and the messages are in 03-bigdecimal-and-doubles.txt.
Null handling is worth knowing too: asserting on a null value does not throw a NullPointerException, it fails with a sentence (04-optional-and-null.txt):
Optional.empty() isPresent:
Expecting Optional to contain a value but it was empty.
null String startsWith:
Expecting actual not to be null
Going deeper: how to describe a failure, and what as(..) is for
Every assertion object has a description slot. as("answer to everything") (alias describedAs) fills it, and it is shown in square brackets on the first line of any failure from that chain, as in the third block of the first transcript. Use it when the same assertion runs in a loop or a helper and the bare expected/but was would not tell you which element. Custom assertions get this for free if they fail through failWithMessage or reuse the built-in objects.assertEqual(info, ..), as shown later.
Collections: say what you mean about order, then say which fields matter
Collection assertions are where AssertJ replaces the most hand-written loops. The first decision is how strict to be about which elements and what order:
Method
Passes when the list …
contains(a, b)
includes a and b, in any positions, possibly with others
containsOnly(a, b)
has no elements other than a and b (order and repeats ignored)
containsExactlyInAnyOrder(a, b)
has exactly those elements, no more, no fewer, any order
containsExactly(a, b)
has exactly those elements in exactly that order
The table is from the AssertJ documentation; the two strict rows are the ones I ran. Their failure messages are in 05-contains-messages.txt, and they are the reason to prefer these methods over comparing to a List.of(..) with isEqualTo: the message tells you which index differs, or which elements are missing and which were unexpected.
containsExactly, wrong order:
Expecting actual:
["BOOK-1", "PEN-7", "BAG-3"]
to contain exactly (and in same order):
["PEN-7", "BOOK-1", "BAG-3"]
but there were differences at these indexes:
- element at index 0: expected "PEN-7" but was "BOOK-1"
- element at index 1: expected "BOOK-1" but was "PEN-7"
containsExactlyInAnyOrder, one missing and one unexpected:
Expecting actual:
["BOOK-1", "PEN-7", "BAG-3"]
to contain exactly in any order:
["BOOK-1", "PEN-7", "HAT-9"]
elements not found:
["HAT-9"]
and elements not expected:
["BAG-3"]
The second decision is what to compare. Real lists hold objects, and you rarely want to compare whole objects. extracting(..) projects each element onto the fields you care about, and with two or more getters it produces tuples:
The figure is the whole mechanism: extract, then assert on plain values. A method reference (OrderLine::sku) is type-checked by the compiler; a string name (extracting("sku")) is looked up by reflection and fails at runtime if you misspell it. Prefer the method reference. The code is in CollectionAssertionsTest.java:
extracting(sku, quantity) with one wrong quantity:
Expecting actual:
[("BOOK-1", 2), ("PEN-7", 10), ("BAG-3", 1)]
to contain exactly (and in same order):
[("BOOK-1", 2), ("PEN-7", 11), ("BAG-3", 1)]
but some elements were not found:
[("PEN-7", 11)]
and others were not expected:
[("PEN-7", 10)]
To check a rule that every element must satisfy, use allSatisfy with a nested assertion. The nested as(..) is what tells you which element broke, because the outer message lists them all (07-allsatisfy-failure.txt; stack frames were removed by the transcript helper, the label says so):
Throwable t = catchThrowable(() -> assertThat(LINES)
.allSatisfy(l -> assertThat(l.quantity()).as("quantity of " + l.sku()).isLessThan(5)));
allSatisfy, one element violates the rule: (stack frames omitted)
Expecting all elements of:
[OrderLine[sku=BOOK-1, quantity=2, unitPrice=12.50],
OrderLine[sku=PEN-7, quantity=10, unitPrice=1.20],
OrderLine[sku=BAG-3, quantity=1, unitPrice=40.00]]
to satisfy given requirements, but these elements did not:
OrderLine[sku=PEN-7, quantity=10, unitPrice=1.20]
error: java.lang.AssertionError: [quantity of PEN-7]
Expecting actual:
10
to be less than:
5
Maps get the same treatment. containsEntry tells you the key exists but the value differs, and says what it expected (08-map-failure.txt):
containsEntry with wrong value:
Expecting map:
{"BOOK-1"=4, "PEN-7"=0}
to contain entries:
["PEN-7"=5]
but the following map entries had different values:
["PEN-7"=0 (expected: 5)]
Also in the same test class: filteredOn(..) to narrow before asserting, anySatisfy, noneMatch, satisfiesExactly (one nested assertion per element, in order), isSorted(). See CollectionAssertionsTest.java.
Exceptions: assert the failure, not just that something failed
The old way is try { call(); fail(); } catch (X e) { ... }, which is noisy and easy to get subtly wrong. AssertJ wraps the call in a lambda and gives you an assertion object for the exception it throws, so you can check the type, the message and the whole cause chain. The code under test throws a CheckoutException that wraps an IllegalStateException that wraps an IOException (Checkout.java), which is the shape real service code produces.
assertThatThrownBy(() -> checkout.pay("o-42"))
.isInstanceOf(CheckoutException.class)
.hasMessage("payment failed for order o-42")
.hasMessageContaining("o-42")
.hasMessageMatching("payment failed for order .+")
.hasCauseInstanceOf(IllegalStateException.class)
.hasRootCauseInstanceOf(IOException.class)
.hasStackTraceContaining("gateway unavailable");
Three habits make these assertions pay off. First, hasMessage is an exact match and hasMessageContaining is a substring; pick the loosest one that still proves the point, because exact messages break on every wording change. Second, check the cause when the wrapper is generic (hasRootCauseInstanceOf above). Third, when you need your own fields from the exception, take it out with catchThrowableOfType or stay fluent with asInstanceOf and returns:
CheckoutException e = catchThrowableOfType(CheckoutException.class, () -> checkout.pay("o-44"));
assertThat(e.code()).isEqualTo("PAY-503");
// or stay in the fluent chain with 'asInstanceOf' + 'returns'
assertThatThrownBy(() -> checkout.pay("o-44"))
.asInstanceOf(org.assertj.core.api.InstanceOfAssertFactories.throwable(CheckoutException.class))
.returns("PAY-503", CheckoutException::code);
// walk the whole chain
assertThat(e).hasCauseInstanceOf(IllegalStateException.class)
.rootCause().isInstanceOf(IOException.class).hasMessage("connection reset by gateway");
The failure messages are the part worth seeing, because they show what you get when the test is wrong. If the code stops throwing at all, assertThatThrownBy fails with Expecting code to raise a throwable.; a wrong message prints expected, actual and the exception that was thrown, including its causes; a wrong type says what it got instead. From 09-exception-failures.txt:
expected exception, nothing thrown: (stack frames omitted)
Expecting code to raise a throwable.
wrong message: (stack frames omitted)
Expecting message to be:
"payment failed"
but was:
"payment failed for order o-45"
Throwable that failed the check:
lab.assertj.custom.CheckoutException: payment failed for order o-45
Caused by: java.lang.IllegalStateException: gateway unavailable
Caused by: java.io.IOException: connection reset by gateway
wrong type: (stack frames omitted)
Expecting actual throwable to be an instance of:
java.lang.IllegalStateException
but was:
lab.assertj.custom.CheckoutException: payment failed for order o-45
Going deeper: AssertJ or assertThrows?
JUnit’s own assertThrows returns the exception, after which you assert with whatever you like — including AssertJ. The two compose, and the AssertJ form earns its keep when you check several properties (type, message, cause) because they chain and the failure message names the exact check that failed. For a single type check assertThrows is shorter. There is no rule beyond consistency within a codebase. For what changed in JUnit’s assertions between 5 and 6, see the migration guide.
Soft assertions: see every failure in one run
With ordinary (“hard”) assertions a test method stops at the first failed check. If three things are wrong you learn about them one fix-and-rerun at a time. Soft assertions record each failure and report them together at the end.
The figure compares the two. On the top row the first check throws, so the other two are never evaluated and the report says one failure. On the bottom row each failure is recorded, and the report is produced when assertAll() runs. Here is the same test with three wrong expectations, first hard (10-hard-assertions-first-failure-only.txt) then soft (11-soft-assertions-all-failures.txt); the source is SoftAssertionsTest.java.
three wrong expectations, hard assertions:
expected: "o-1"
but was: "o-9"
exception type: org.assertj.core.error.AssertJMultipleFailuresError
message:
Multiple Failures (3 failures)
-- failure 1 --
expected: "o-1"
but was: "o-9"
at SoftAssertionsTest.lambda$assertSoftlyReportsAll$1(SoftAssertionsTest.java:48)
-- failure 2 --
expected: PAID
but was: NEW
at SoftAssertionsTest.lambda$assertSoftlyReportsAll$1(SoftAssertionsTest.java:49)
-- failure 3 --
expected: 75
but was: 99.00
at SoftAssertionsTest.lambda$assertSoftlyReportsAll$1(SoftAssertionsTest.java:50)
The soft exception is an AssertJMultipleFailuresError, which the test asserts is also JUnit’s org.opentest4j.MultipleFailuresError, so tooling that understands JUnit’s multiple-failures type can treat it as one.
Soft assertions have two failure modes, and both produce a green or confusing test. The first is forgetting to trigger the report. new SoftAssertions() only collects. If nothing calls assertAll() the test passes with its failures sitting unread in a list (source SoftAssertionsTest.java, transcript 12-forgot-assertall.txt):
SoftAssertions s = new SoftAssertions();
s.assertThat(1).isEqualTo(2); // recorded, not thrown
s.assertThat("a").isEqualTo("b"); // recorded, not thrown
// The test would pass here if we stopped now. The failures only surface on assertAll().
List<Throwable> collected = s.errorsCollected();
Throwable t = catchThrowable(s::assertAll);
errors collected but not thrown: 2
assertAll() throws: org.assertj.core.error.AssertJMultipleFailuresError
Use a form that calls assertAll() for you.SoftAssertions.assertSoftly(s -> { .. }) does, and so does the JUnit extension (@ExtendWith(SoftAssertionsExtension.class) with an @InjectSoftAssertions SoftAssertions softly field). The companion test class uses the extension for the passing case. Reach for new SoftAssertions() only if you will call assertAll() in a finally or an @AfterEach yourself.
The second is code that runs after a soft failure on the bad value. A soft failure does not stop execution, so the next line sees the same null. The test below records a failed isNotNull() and then calls .length() on the null; what you get is a NullPointerException, not the soft report (13-soft-then-npe.txt):
String name = null;
Throwable t = catchThrowable(() -> SoftAssertions.assertSoftly(s -> {
s.assertThat(name).isNotNull(); // recorded, execution continues ...
s.assertThat(name.length()).isPositive(); // ... and this line throws NullPointerException
}));
thrown: java.lang.NullPointerException
The rule that follows: in a soft block, only assert on values that are safe to read even when an earlier check failed. If a later line depends on an earlier check, make that earlier check a hard assertion outside the block.
Going deeper: the three ways to get soft assertions, and how they work
Form
Who calls assertAll()
Use when
SoftAssertions.assertSoftly(s -> ..)
the library, when the lambda returns
a block inside a test
SoftAssertionsExtension + @InjectSoftAssertions
the JUnit extension, after the test method
the whole test is soft; JUnit 6 compatible, the companion test passes on 6.1.3
new SoftAssertions()
you
you control the lifecycle yourself
Soft assertions are implemented with generated proxies: AssertJ’s own assertion classes are subclassed at runtime so that a failing method records the error instead of throwing. Evidence I checked: assertj-core 3.27.7 declares net.bytebuddy:byte-buddy 1.18.3 as a compile dependency in its POM, its jar contains org.assertj.core.api.SoftProxies, and the generator’s class comment says its assertion class is “not final to allow Soft assertions proxy”. I did not read the proxy source, so treat the mechanism as inferred from those three facts. The practical consequence is the one the generator comment states: assertion classes you want to use through soft assertions must be non-final. Reference: AssertJ documentation, soft assertions section.
Recursive comparison: testing a DTO that has no equals
Most entities and DTOs in a codebase have no useful equals — or they have one based on the database id, which is exactly what you do not want in a test. The result is a test that fails with two objects that print identically. Here are two Customer objects built from the same values. Customer has a toString but no equals (Customer.java). As the transcript shows, AssertJ appends the identity hash when the two strings would otherwise be indistinguishable (14-isEqualTo-without-equals.txt; the hash values differ from machine to machine):
usingRecursiveComparison() is the fix, drawn on the lower half of the figure: instead of calling equals it walks the objects field by field, descending into nested objects and collections, and compares the leaves. It needs no changes to the class. The test is RecursiveComparisonTest.java:
The second payoff is the entity versus DTO case. A CustomerDto record has the same fields as Customer but is a different class. By default the comparison looks at fields, not types, so this passes (see CustomerDto.java):
When the comparison does fail, the report lists every differing path, which is what makes it worth using. Here an actual customer differs from the expected one in four places. From 16-recursive-difference-report.txt (the long configuration section that follows each report is covered in the accordion below):
when recursively comparing field by field, but found the following 4 differences:
field/property 'address.city' differ:
- actual value : "Mumbai"
- expected value: "Pune"
Compared objects have java types and were thus compared with equals method
field/property 'creditLimit' differ:
- actual value : 5000.0
- expected value: 5000.00
Compared objects have java types and were thus compared with equals method
field/property 'email' differ:
- actual value : "[email protected]"
- expected value: "[email protected]"
Compared objects have java types and were thus compared with equals method
field/property 'tags' differ:
- actual value : ["vip"]
- expected value: ["vip", "newsletter"]
actual and expected values are collections of different size, actual size=1 when expected size=2
address.city shows the dotted path into the nested record; tags shows a collection-size difference; creditLimit is the BigDecimal scale trap again. Nothing here needed a custom equals.
Real objects have fields you cannot compare literally: generated ids, timestamps, collections whose order is not guaranteed, BigDecimals. Each has a knob, and the transcript 17-recursive-knobs.txt shows the report when a knob is missing. In the test, the customer b differs from a in its id, its timestamp, the order of its tags and the scale of its credit limit, and one chain removes all four differences:
assertThat(b).usingRecursiveComparison()
.ignoringFields("id", "createdAt") // volatile fields
.ignoringCollectionOrder() // tags in another order
.withComparatorForType(BY_VALUE, BigDecimal.class) // 5000.0 vs 5000.00
.isEqualTo(a);
Take away ignoringCollectionOrder() and the report shrinks to the two tag positions; take away the comparator and exactly one difference, creditLimit, remains. This is the second report, from 17-recursive-knobs.txt:
everything but the BigDecimal comparator:
Expecting actual:
Customer{id=99, name=Asha Patil, [email protected], address=Address[street=12 MG Road, city=Pune, postcode=411001], tags=[newsletter, vip], creditLimit=5000.0}
to be equal to:
Customer{id=7, name=Asha Patil, [email protected], address=Address[street=12 MG Road, city=Pune, postcode=411001], tags=[vip, newsletter], creditLimit=5000.00}
when recursively comparing field by field, but found the following difference:
field/property 'creditLimit' differ:
- actual value : 5000.0
- expected value: 5000.00
Ignoring is a decision, not a convenience. Every ignoringFields is a field your test no longer protects. Prefer comparingOnlyFields(..) when the list of fields that matter is short, and write the ignored ones down in the test so the next reader knows they were left out on purpose. Two defaults are worth knowing before they surprise you: comparison does not require the same class (so it passes DTO against entity), and collection order matters unless you say otherwise. Both are visible in the configuration block that closes every report.
The same entry point accepts the other direction of “partial”. If the expected object is a template with nulls for fields you do not care about, ignoringExpectedNullFields() compares only what the template fills in; ignoringActualNullFields() is the mirror image. Both are exercised in RecursiveComparisonTest.java, as is usingRecursiveFieldByFieldElementComparator() for lists of DTOs.
Going deeper: reading the configuration block, and strict type checking
Every recursive-comparison failure ends with a block titled The recursive comparison was performed with this configuration. It is the most useful part once you know it is there, because it tells you what the comparison assumed. From 17-recursive-knobs.txt:
- the following fields were ignored in the comparison: id, createdAt
- no equals methods were used in the comparison EXCEPT for java JDK types since introspecting JDK types is forbidden in java 17+ (use withEqualsForType to register a specific way to compare a JDK type if you need it)
- these types were compared with the following comparators:
- java.lang.Double -> DoubleComparator[precision=1.0E-15]
- java.lang.Float -> FloatComparator[precision=1.0E-6]
- java.math.BigDecimal -> BigDecimal compared with compareTo (scale ignored)
- java.nio.file.Path -> lexicographic comparator (Path natural order)
Read the first line as: these fields were skipped, by you. The next two lines say that AssertJ calls equals only for JDK types, because, in the tool’s own words, introspecting JDK types is forbidden in Java 17+; use withEqualsForType to supply your own comparison for a JDK type if the default is wrong for you. The comparator lines list defaults for Double, Float and Path, plus any you registered — in the knobs transcript, the BigDecimal comparator appears with the name given to it by its toString, which is why the test uses a small named comparator class instead of a lambda (a lambda prints as Lambda/0x..@..).
To make the DTO-vs-entity comparison fail on type, ask for it with withStrictTypeChecking(). The failure from 15-strict-type-checking.txt says exactly why:
Top level actual and expected objects differ:
- actual value : CustomerDto[id=7, name=Asha Patil, [email protected], address=Address[street=12 MG Road, city=Pune, postcode=411001], tags=[vip, newsletter], createdAt=2026-10-01T10:00:00Z, creditLimit=5000.00]
- expected value: Customer{id=7, name=Asha Patil, [email protected], address=Address[street=12 MG Road, city=Pune, postcode=411001], tags=[vip, newsletter], creditLimit=5000.00}
actual and expected are considered different since the comparison enforces strict type check and expected type lab.assertj.domain.Customer is not a subtype of actual type lab.assertj.domain.CustomerDto
Reference: the recursive comparison section of the AssertJ documentation; it also covers comparing by field type, per-field comparators and registering equals overrides.
If you are testing an HTTP response against a DTO, this pairs well with the techniques in the RestTestClient guide.
Custom assertions: put the domain rule in one place
Repeated checks deserve a name. There are three ways to give them one, from lightest to heaviest: a Condition, a hand-written assertion class, and a generated assertion class.
A Condition wraps a predicate and a description. It composes with allOf / anyOf / not and produces a failure message that shows which part failed (CustomAssertionsTest.java, output 19-condition-failure.txt):
Condition<String> upperCase = new Condition<>(s -> s.equals(s.toUpperCase()), "upper case");
Condition<String> shortWord = new Condition<>(s -> s.length() <= 4, "at most 4 characters");
allOf(upperCase, shortWord) on "Abcdef":
Expecting actual:
"Abcdef"
to be:
[✗] all of:[
[✗] upper case,
[✗] at most 4 characters
]
A hand-written assertion class is what you reach for when a rule has a name in your business. Our invoices must satisfy gross = net + tax, a rule the record itself does not expose as a getter (Invoice.java). The class extends AbstractAssert and each method returns this:
The figure shows where the hand-written class sits: directly under AbstractAssert, which supplies isNotNull(), as(..), and the machinery that makes failures show up the same way as built-in ones. The source is InvoiceAssert.java:
public InvoiceAssert isArithmeticallyConsistent(BigDecimal declaredGross) {
isNotNull();
BigDecimal expected = actual.net().add(actual.tax());
if (expected.compareTo(declaredGross) != 0) {
failWithMessage("Expected gross <%s> (net %s + tax %s) but declared gross was <%s>",
expected, actual.net(), actual.tax(), declaredGross);
}
return this;
}
Two details matter. Call isNotNull() first, so a null invoice fails with a sentence instead of a NullPointerException inside your check. And fail with failWithMessage (or reuse a built-in such as objects.assertEqual(info, ..)), so the message honours as(..). To use it without a second import, a small class extends Assertions and adds an overload (MyAssertions.java); the test then static-imports only MyAssertions.assertThat and plain string assertions still resolve:
public static InvoiceAssert assertThat(Invoice actual) {
return InvoiceAssert.assertThat(actual);
}
hasNumber, wrong:
Expected invoice number to be <INV-2026-002> but was <INV-2026-001>
isArithmeticallyConsistent, wrong:
Expected gross <118.00> (net 100.00 + tax 18.00) but declared gross was <120>
as("billing currency").isIn("USD"):
[billing currency]
expected: "USD"
but was: "INR"
null actual:
Expecting actual not to be null
A generated assertion class is for the other case: you have many plain beans and want hasStatus(..), isPaid() and the like for each without writing them. The AssertJ assertions-generator Maven plugin reads the compiled classes in a package and writes assertion classes for their getters. This is the part I checked hardest, because the plugin’s latest release (2.2.0) dates from March 2019. It does work on JDK 25 with AssertJ 3.27.7, with one required fix, and it behaves as follows.
The plugin is wired into the build in the module’s pom.xml. It runs before test compilation, reads lab.assertj.domain, and writes into target/generated-test-sources:
Each domain class gets an AbstractXAssert (the checks) and an XAssert (a thin subclass with the assertThat factory), and three entry-point classes (Assertions, BddAssertions, SoftAssertions) collect the factories. Records and enums are included even though I only meant it for the bean; the plugin does not distinguish. A generated check is plain code, for example (23-generated-isPaid.txt):
# AbstractOrderAssert.java: annotation line, class declaration and the isPaid() method, verbatim
@javax.annotation.Generated(value="assertj-assertions-generator")
public abstract class AbstractOrderAssert<S extends AbstractOrderAssert<S, A>, A extends Order> extends AbstractObjectAssert<S, A> {
public S isPaid() {
// check that actual Order we want to make assertions on is not null.
isNotNull();
// check that property call/field access is true
if (!actual.isPaid()) {
failWithMessage("\nExpecting that actual Order is paid but is not.");
}
// return the current assertion for method chaining
return myself;
}
Note the first line of that transcript: the generator stamps @javax.annotation.Generated on everything. That annotation lived in a package that left the JDK in Java 11. Without a dependency that provides it, the generated sources do not compile, and the build stops on JDK 25 with fifteen compile errors, one per generated file. The capture below is from capture-generator.sh, which removes the dependency in a scratch copy and records the compiler output (24-generator-without-javax-annotation.txt; first three of the fifteen lines shown):
target/generated-test-sources/assertj-assertions/lab/assertj/domain/AbstractAddressAssert.java:[8,18] package javax.annotation does not exist
target/generated-test-sources/assertj-assertions/lab/assertj/domain/AbstractCustomerAssert.java:[10,18] package javax.annotation does not exist
target/generated-test-sources/assertj-assertions/lab/assertj/domain/AbstractCustomerDtoAssert.java:[8,18] package javax.annotation does not exist
The fix is the dependency in the module’s pom (javax.annotation:javax.annotation-api 1.3.2, test scope, with a comment saying why). Once it is there, the generated classes read like the hand-written one. The test GeneratedAssertionsTest.java uses OrderAssert, which no human wrote:
assertThat(order)
.hasId("o-1")
.hasStatus(OrderStatus.PAID)
.isPaid()
.hasLines(new OrderLine("BOOK-1", 2, new BigDecimal("12.50")));
Here are the generated messages (20-generated-assert-messages.txt). The third block is a trap worth reading twice: the generated hasTotal compares with equals, so for a BigDecimal the scale is part of the answer, and hasTotal(new BigDecimal("75")) fails against 75.00.
hasStatus(SHIPPED):
Expecting status of:
<Order{id=o-1, status=PAID, total=75.00}>
to be:
<SHIPPED>
but was:
<PAID>
isNotPaid():
Expecting that actual Order is not paid but is.
hasTotal(new BigDecimal("75")) against 75.00:
Expecting total of:
<Order{id=o-1, status=PAID, total=75.00}>
to be:
<75>
but was:
<75.00>
null actual:
Expecting actual not to be null
Generated assertions inherit the scale trap and nothing else. The generator does not know that your BigDecimal fields should compare by value. For money fields either add a hand-written method to a subclass (extend the generated AbstractOrderAssert, as the comment at the top of the generated OrderAssert advises) or assert on those fields with extracting(..).isEqualByComparingTo(..). Treat generated classes as build output: they live under target/, are not committed, and should not be edited.
The generator also writes a SoftAssertions entry point, so the soft-assertion pattern from earlier works with the generated checks (21-generated-soft-assertions.txt):
generated SoftAssertions, one failing hasStatus:
Multiple Failures (1 failure)
-- failure 1 --
Expecting status of:
<Order{id=o-1, status=PAID, total=75.00}>
to be:
<SHIPPED>
but was:
<PAID>
at GeneratedAssertionsTest.generatedSoftAssertionsEntryPoint(GeneratedAssertionsTest.java:48)
Going deeper: why I kept both a generated and a hand-written route
The honest comparison, from this run: the generator saved writing about a dozen methods for Order (hasId, hasStatus, isPaid, hasLines, hasTotal …) and its messages are serviceable, but it generates for every class in the package, including records and enums you did not ask for, it knows nothing about business rules such as gross = net + tax, and it is tied to a plugin whose last release is from 2019 (the local Maven repository after the build contained assertj-assertions-generator 2.2.0 and assertj-core 2.x jars alongside 3.27.7, presumably the plugin’s own toolchain). The assertions generator library also has a 3.0.0-M5 milestone on Maven Central (May 2025) and the plugin has no corresponding release, so I did not try it. If your team has dozens of simple beans and wants the boilerplate gone, the generator is reasonable. If the value is in the domain rules, hand-write a few assertion classes; they are small and nothing about them ages.
Which AssertJ: 3.27.7 today, and 4.0.0-M1 on the horizon
The latest GA on Maven Central is 3.27.7; its jar is dated 2026-01-24 and its class files are Java 8 (class-file major version 52, read with javap). Maven Central also lists 4.0.0-M1, whose classes are Java 17 (major version 61) and whose jar is dated 2025-03-09. The <release> element in assertj-core’s maven-metadata.xml points at that milestone, which is why this guide checked the version list itself instead of trusting it. A milestone is not a release: do not depend on it in production.
Curiosity is cheap here, though, so the build script reruns the same 27 tests against 4.0.0-M1 in a scratch copy and records the result (25-assertj-4-milestone.txt):
assertj-core 4.0.0-M1 (milestone), same sources:
Tests run: 27, Failures: 0, Errors: 0, Skipped: 0
transcripts whose text differs from the 3.27.7 run (excluding the generator captures):
(end of list; identity hashes like Customer@73c3cd09 are ignored in the comparison)
Nothing in this guide changed behaviour on the milestone: all 27 tests pass, and none of the transcripts differ (identity hashes are ignored in that comparison). That says the APIs used here are stable across the jump, not that the milestone has no breaking changes elsewhere; I did not read its release notes.
Should you use all of this?
Mostly yes, with three conditions. Use AssertJ chains, extracting, assertThatThrownBy and soft assertions freely: they cost nothing and the failure messages repay it. Use recursive comparison for DTOs and mapper tests, but write down what you ignore, because each ignored field is a field no longer tested. Write a custom assertion only when the same rule appears in three or more tests; before that a helper method is enough. Skip the generator unless you have many plain beans, and accept that it is stale tooling (2019) held up by one extra dependency. And whichever you choose, do not mix AssertJ with several other assertion libraries in one codebase; readers should not have to guess which assertThat they are looking at.
One thing AssertJ will not do for you: a green test with a forgotten terminal call asserts nothing. A quick review rule (“every assertThat(..) line ends in a check”) is cheaper than any tool.
No Comments yet!