assertj: AssertJ 3.27.7 lab (soft assertions, recursive comparison, custom and generated assertions)

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01CifR2BE7PBvRy5hT8TS9oz
This commit is contained in:
2026-10-26 01:57:10 +00:00
co-authored by Claude Sonnet 5.5
parent 08e063d22e
commit 9d89323864
50 changed files with 1427 additions and 0 deletions
+2
View File
@@ -6,10 +6,12 @@ Runnable companion code for the testing posts on [ankurm.com](https://ankurm.com
|---|---|---| |---|---|---|
| [`mockito/`](mockito) | Mockito 5 Deep Dive: Static, Final and Constructor Mocking, ArgumentCaptor and Strict Stubs | Mockito 5.24.0, JUnit 6.1.3, JDK 25 | | [`mockito/`](mockito) | Mockito 5 Deep Dive: Static, Final and Constructor Mocking, ArgumentCaptor and Strict Stubs | Mockito 5.24.0, JUnit 6.1.3, JDK 25 |
| [`junit-6-migration/`](junit-6-migration) | JUnit 5 to 6 Migration: What Was Removed, What Changed and How to Upgrade | JUnit 5.14.4 and 6.1.3, JDK 25 | | [`junit-6-migration/`](junit-6-migration) | JUnit 5 to 6 Migration: What Was Removed, What Changed and How to Upgrade | JUnit 5.14.4 and 6.1.3, JDK 25 |
| [`assertj/`](assertj) | AssertJ Complete Guide: Soft Assertions, Recursive Comparison and Custom Assertions | AssertJ 3.27.7, JUnit 6.1.3, JDK 25 |
Every module keeps the real output of its tests in `output/`. Each test writes the transcript and asserts the same facts, so the numbers in the posts fail the build if they stop being true. Every module keeps the real output of its tests in `output/`. Each test writes the transcript and asserts the same facts, so the numbers in the posts fail the build if they stop being true.
```bash ```bash
cd mockito && ./scripts/run-all.sh cd mockito && ./scripts/run-all.sh
cd ../junit-6-migration && ./scripts/run-all.sh cd ../junit-6-migration && ./scripts/run-all.sh
cd ../assertj && ./scripts/run-all.sh
``` ```
+1
View File
@@ -0,0 +1 @@
target/
+29
View File
@@ -0,0 +1,29 @@
# assertj
Companion code for the ankurm.com post *AssertJ Complete Guide: Soft Assertions, Recursive Comparison and Custom Assertions*.
| Component | Version |
|---|---|
| AssertJ Core | 3.27.7 (GA, 2026-01-24; `4.0.0-M1` also exists on Maven Central but is a milestone) |
| JUnit | 6.1.3 |
| assertj-assertions-generator-maven-plugin | 2.2.0 (last release, 2019) |
| JDK | 25 (Temurin 25.0.5+8) |
```bash
./scripts/run-all.sh # runs every test and regenerates output/*.txt
```
Each test writes a transcript to `output/` and asserts the same facts, so every message quoted in the post fails the build if it stops being true. Two things in the output are not stable between runs: `Customer@<hash>` identity hashes in `14-isEqualTo-without-equals.txt`.
| Test | Output files | Topic |
|---|---|---|
| `BasicAssertionsTest` | 01-04 | failure messages, forgotten terminal call, BigDecimal and double |
| `CollectionAssertionsTest` | 05-08 | contains family, extracting, tuples, allSatisfy, maps |
| `ExceptionAssertionsTest` | 09 | assertThatThrownBy, cause chain, wrong-message output |
| `SoftAssertionsTest` | 10-13 | hard vs soft, forgotten `assertAll()`, NPE after a soft failure, JUnit extension |
| `RecursiveComparisonTest` | 14-17 | no-equals problem, DTO vs entity, difference report, ignoring and comparators |
| `CustomAssertionsTest` | 18-19 | hand-written `InvoiceAssert`, `Condition` |
| `GeneratedAssertionsTest` | 20-24 | generated `OrderAssert`, generated soft entry point, javax.annotation failure |
| `scripts/capture-generator.sh` | 22-25 | generator file list, javax.annotation compile error, the same tests on `4.0.0-M1` |
The `pom.xml` runs `assertj-assertions-generator-maven-plugin` on `lab.assertj.domain` before tests compile. It still emits `@javax.annotation.Generated`, so `javax.annotation-api` is a test dependency; `scripts/capture-generator.sh` removes it in a scratch copy to record the compiler error.
@@ -0,0 +1,12 @@
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
@@ -0,0 +1,2 @@
assertThat(1); -> threw: nothing
assertThat("any value"); -> threw: nothing
@@ -0,0 +1,7 @@
BigDecimal 2.00 isEqualTo 2.0:
expected: 2.0
but was: 2.00
double 0.1+0.2 isEqualTo 0.3:
expected: 0.3
but was: 0.30000000000000004
0.1 + 0.2 = 0.30000000000000004
+4
View File
@@ -0,0 +1,4 @@
Optional.empty() isPresent:
Expecting Optional to contain a value but it was empty.
null String startsWith:
Expecting actual not to be null
+17
View File
@@ -0,0 +1,17 @@
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"]
@@ -0,0 +1,9 @@
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)]
+13
View File
@@ -0,0 +1,13 @@
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
+7
View File
@@ -0,0 +1,7 @@
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)]
+18
View File
@@ -0,0 +1,18 @@
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
@@ -0,0 +1,3 @@
three wrong expectations, hard assertions:
expected: "o-1"
but was: "o-9"
@@ -0,0 +1,15 @@
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)
+2
View File
@@ -0,0 +1,2 @@
errors collected but not thrown: 2
assertAll() throws: org.assertj.core.error.AssertJMultipleFailuresError
+1
View File
@@ -0,0 +1 @@
thrown: java.lang.NullPointerException
@@ -0,0 +1,3 @@
two Customers with identical fields, isEqualTo:
expected: "Customer{id=7, name=Asha Patil, [email protected], address=Address[street=12 MG Road, city=Pune, postcode=411001], tags=[vip, newsletter], creditLimit=5000.00} (Customer@73c3cd09)"
but was: "Customer{id=7, name=Asha Patil, [email protected], address=Address[street=12 MG Road, city=Pune, postcode=411001], tags=[vip, newsletter], creditLimit=5000.00} (Customer@74f827ad)"
@@ -0,0 +1,20 @@
DTO vs entity with withStrictTypeChecking():
Expecting actual:
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]
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:
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
The recursive comparison was performed with this configuration:
- 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.nio.file.Path -> lexicographic comparator (Path natural order)
- actual and expected objects and their fields were considered different when of incompatible types (i.e. expected type does not extend actual's type) even if all their fields match, for example a Person instance will never match a PersonDto (call strictTypeChecking(false) to change that behavior).
- the introspection strategy used was: DefaultRecursiveComparisonIntrospectionStrategy
@@ -0,0 +1,35 @@
recursive comparison, four fields differ:
Expecting actual:
Customer{id=7, name=Asha Patil, [email protected], address=Address[street=12 MG Road, city=Mumbai, postcode=411001], tags=[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 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
The recursive comparison was performed with this configuration:
- 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.nio.file.Path -> lexicographic comparator (Path natural order)
- actual and expected objects and their fields were compared field by field recursively even if they were not of the same type, this allows for example to compare a Person to a PersonDto (call strictTypeChecking(true) to change that behavior).
- the introspection strategy used was: DefaultRecursiveComparisonIntrospectionStrategy
+89
View File
@@ -0,0 +1,89 @@
no knobs:
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 5 differences:
field/property 'createdAt' differ:
- actual value : 2026-10-26T01:00:00Z
- expected value: 2026-10-01T10:00:00Z
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 'id' differ:
- actual value : 99L
- expected value: 7L
Compared objects have java types and were thus compared with equals method
field/property 'tags[0]' differ:
- actual value : "newsletter"
- expected value: "vip"
Compared objects have java types and were thus compared with equals method
field/property 'tags[1]' differ:
- actual value : "vip"
- expected value: "newsletter"
Compared objects have java types and were thus compared with equals method
The recursive comparison was performed with this configuration:
- 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.nio.file.Path -> lexicographic comparator (Path natural order)
- actual and expected objects and their fields were compared field by field recursively even if they were not of the same type, this allows for example to compare a Person to a PersonDto (call strictTypeChecking(true) to change that behavior).
- the introspection strategy used was: DefaultRecursiveComparisonIntrospectionStrategy
everything but ignoringCollectionOrder():
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 2 differences:
field/property 'tags[0]' differ:
- actual value : "newsletter"
- expected value: "vip"
Compared objects have java types and were thus compared with equals method
field/property 'tags[1]' differ:
- actual value : "vip"
- expected value: "newsletter"
Compared objects have java types and were thus compared with equals method
The recursive comparison was performed with this configuration:
- 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)
- actual and expected objects and their fields were compared field by field recursively even if they were not of the same type, this allows for example to compare a Person to a PersonDto (call strictTypeChecking(true) to change that behavior).
- the introspection strategy used was: DefaultRecursiveComparisonIntrospectionStrategy
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
Compared objects have java types and were thus compared with equals method
The recursive comparison was performed with this configuration:
- 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)
- collection order was ignored in all fields in the comparison
- 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.nio.file.Path -> lexicographic comparator (Path natural order)
- actual and expected objects and their fields were compared field by field recursively even if they were not of the same type, this allows for example to compare a Person to a PersonDto (call strictTypeChecking(true) to change that behavior).
- the introspection strategy used was: DefaultRecursiveComparisonIntrospectionStrategy
@@ -0,0 +1,10 @@
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
+8
View File
@@ -0,0 +1,8 @@
allOf(upperCase, shortWord) on "Abcdef":
Expecting actual:
"Abcdef"
to be:
[✗] all of:[
[✗] upper case,
[✗] at most 4 characters
]
@@ -0,0 +1,18 @@
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
@@ -0,0 +1,10 @@
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)
+16
View File
@@ -0,0 +1,16 @@
# files generated into target/generated-test-sources/assertj-assertions/
lab/assertj/domain/AbstractAddressAssert.java
lab/assertj/domain/AbstractCustomerAssert.java
lab/assertj/domain/AbstractCustomerDtoAssert.java
lab/assertj/domain/AbstractOrderAssert.java
lab/assertj/domain/AbstractOrderLineAssert.java
lab/assertj/domain/AbstractOrderStatusAssert.java
lab/assertj/domain/AddressAssert.java
lab/assertj/domain/CustomerAssert.java
lab/assertj/domain/CustomerDtoAssert.java
lab/assertj/domain/OrderAssert.java
lab/assertj/domain/OrderLineAssert.java
lab/assertj/domain/OrderStatusAssert.java
lab/assertj/generated/Assertions.java
lab/assertj/generated/BddAssertions.java
lab/assertj/generated/SoftAssertions.java
+15
View File
@@ -0,0 +1,15 @@
# 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;
}
@@ -0,0 +1,15 @@
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
target/generated-test-sources/assertj-assertions/lab/assertj/domain/AbstractOrderAssert.java:[10,18] package javax.annotation does not exist
target/generated-test-sources/assertj-assertions/lab/assertj/domain/AbstractOrderLineAssert.java:[8,18] package javax.annotation does not exist
target/generated-test-sources/assertj-assertions/lab/assertj/domain/AbstractOrderStatusAssert.java:[8,18] package javax.annotation does not exist
target/generated-test-sources/assertj-assertions/lab/assertj/domain/AddressAssert.java:[9,18] package javax.annotation does not exist
target/generated-test-sources/assertj-assertions/lab/assertj/domain/CustomerAssert.java:[9,18] package javax.annotation does not exist
target/generated-test-sources/assertj-assertions/lab/assertj/domain/CustomerDtoAssert.java:[9,18] package javax.annotation does not exist
target/generated-test-sources/assertj-assertions/lab/assertj/domain/OrderAssert.java:[9,18] package javax.annotation does not exist
target/generated-test-sources/assertj-assertions/lab/assertj/domain/OrderLineAssert.java:[9,18] package javax.annotation does not exist
target/generated-test-sources/assertj-assertions/lab/assertj/domain/OrderStatusAssert.java:[9,18] package javax.annotation does not exist
target/generated-test-sources/assertj-assertions/lab/assertj/generated/Assertions.java:[7,18] package javax.annotation does not exist
target/generated-test-sources/assertj-assertions/lab/assertj/generated/BddAssertions.java:[6,18] package javax.annotation does not exist
target/generated-test-sources/assertj-assertions/lab/assertj/generated/SoftAssertions.java:[6,18] package javax.annotation does not exist
@@ -0,0 +1,4 @@
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)
+69
View File
@@ -0,0 +1,69 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.ankurm.testing</groupId>
<artifactId>assertj-lab</artifactId>
<version>1.0.0</version>
<properties>
<maven.compiler.release>25</maven.compiler.release>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<junit.version>6.1.3</junit.version>
<assertj.version>3.27.7</assertj.version>
<!-- last release of the generator plugin (2019); see output/12-generator-javax-annotation.txt -->
<assertj.generator.plugin.version>2.2.0</assertj.generator.plugin.version>
</properties>
<dependencies>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>${junit.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.assertj</groupId>
<artifactId>assertj-core</artifactId>
<version>${assertj.version}</version>
<scope>test</scope>
</dependency>
<!-- The generator still stamps @javax.annotation.Generated on its output, and that package left
the JDK in Java 11. Without this the generated sources do not compile on JDK 25. -->
<dependency>
<groupId>javax.annotation</groupId>
<artifactId>javax.annotation-api</artifactId>
<version>1.3.2</version>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>3.5.6</version>
</plugin>
<!-- Generates OrderAssert, CustomerAssert ... for every class in lab.assertj.domain into
target/generated-test-sources, before the tests compile. -->
<plugin>
<groupId>org.assertj</groupId>
<artifactId>assertj-assertions-generator-maven-plugin</artifactId>
<version>${assertj.generator.plugin.version}</version>
<executions>
<execution>
<goals><goal>generate-assertions</goal></goals>
</execution>
</executions>
<configuration>
<packages>
<param>lab.assertj.domain</param>
</packages>
<entryPointClassPackage>lab.assertj.generated</entryPointClassPackage>
<generateSoftAssertions>true</generateSoftAssertions>
</configuration>
</plugin>
</plugins>
</build>
</project>
+44
View File
@@ -0,0 +1,44 @@
#!/usr/bin/env bash
# Captures what the assertions-generator produced, and what happens WITHOUT the javax.annotation-api dependency.
set -euo pipefail
cd "$(dirname "$0")/.."
G=target/generated-test-sources/assertj-assertions/lab/assertj
{
echo "# files generated into target/generated-test-sources/assertj-assertions/"
(cd target/generated-test-sources/assertj-assertions && find . -name '*.java' | sort | sed 's#^\./##')
} > output/22-generator-files.txt
{
echo "# AbstractOrderAssert.java: annotation line, class declaration and the isPaid() method, verbatim"
grep -n -E '^@javax.annotation.Generated|^public abstract class' $G/domain/AbstractOrderAssert.java | sed -E 's/^[0-9]+://'
sed -n '/public S isPaid()/,/^ }/p' $G/domain/AbstractOrderAssert.java
} > output/23-generated-isPaid.txt
# Same build, dependency removed: copy the module, delete the javax.annotation-api <dependency> block.
TMP=$(mktemp -d)
cp -r pom.xml src "$TMP"/
python3 - "$TMP/pom.xml" <<'PY'
import re,sys
p=sys.argv[1]; s=open(p).read()
s=re.sub(r'<dependency>\s*<groupId>javax\.annotation</groupId>.*?</dependency>','',s,flags=re.S)
open(p,'w').write(s)
PY
(cd "$TMP" && mvn -B -q test-compile 2>&1 | grep -E '^\[ERROR\] /.*(package javax.annotation does not exist)' | sed -E "s#^\[ERROR\] $TMP/##" | sort -u) > output/24-generator-without-javax-annotation.txt || true
rm -rf "$TMP"
# The same 27 tests against the 4.0.0-M1 milestone, in a scratch copy (the module itself stays on 3.27.7).
TMP=$(mktemp -d)
cp -r pom.xml src "$TMP"/
(cd "$TMP" && mvn -B test -Dassertj.version=4.0.0-M1 2>&1 | grep -E '^\[(INFO|ERROR|WARNING)\] Tests run:.*[0-9]$' | tail -1 | sed 's/^\[[A-Z]*\] //') > "$TMP/summary.txt"
{
echo "assertj-core 4.0.0-M1 (milestone), same sources:"
cat "$TMP/summary.txt"
echo "transcripts whose text differs from the 3.27.7 run (excluding the generator captures):"
for f in output/0*.txt output/1*.txt output/20*.txt output/21*.txt; do
b=$(basename "$f")
if ! diff -q <(sed -E 's/@[0-9a-f]{6,8}/@HASH/g' "$f") <(sed -E 's/@[0-9a-f]{6,8}/@HASH/g' "$TMP/output/$b") >/dev/null; then echo " $b"; fi
done
echo " (end of list; identity hashes like Customer@73c3cd09 are ignored in the comparison)"
} > output/25-assertj-4-milestone.txt
rm -rf "$TMP"
+8
View File
@@ -0,0 +1,8 @@
#!/usr/bin/env bash
# Regenerates every file in assertj/output/ (tests write the transcripts and assert the same facts).
set -euo pipefail
cd "$(dirname "$0")/.."
rm -f output/*.txt
mvn -B -q clean test
./scripts/capture-generator.sh
echo "done: $(ls output | wc -l) files in output/"
@@ -0,0 +1,18 @@
package lab.assertj.custom;
import java.io.IOException;
public class Checkout {
/** Fails with a wrapped cause chain, so exception assertions have something to walk. */
public void pay(String orderId) {
try {
try {
throw new IOException("connection reset by gateway");
} catch (IOException io) {
throw new IllegalStateException("gateway unavailable", io);
}
} catch (IllegalStateException e) {
throw new CheckoutException("payment failed for order " + orderId, "PAY-503", e);
}
}
}
@@ -0,0 +1,12 @@
package lab.assertj.custom;
public class CheckoutException extends RuntimeException {
private final String code;
public CheckoutException(String message, String code, Throwable cause) {
super(message, cause);
this.code = code;
}
public String code() { return code; }
}
@@ -0,0 +1,11 @@
package lab.assertj.custom;
import java.math.BigDecimal;
/** Target of the hand-written InvoiceAssert in src/test. Business rule: gross = net + tax. */
public record Invoice(String number, BigDecimal net, BigDecimal tax, String currency) {
public BigDecimal gross() {
return net.add(tax);
}
}
@@ -0,0 +1,4 @@
package lab.assertj.domain;
public record Address(String street, String city, String postcode) {
}
@@ -0,0 +1,44 @@
package lab.assertj.domain;
import java.math.BigDecimal;
import java.time.Instant;
import java.util.List;
/**
* A plain mutable entity. It deliberately has NO equals/hashCode, which is the situation in most
* real codebases and the reason recursive comparison exists.
*/
public class Customer {
private Long id;
private String name;
private String email;
private Address address;
private List<String> tags;
private Instant createdAt;
private BigDecimal creditLimit;
public Customer(Long id, String name, String email, Address address, List<String> tags,
Instant createdAt, BigDecimal creditLimit) {
this.id = id;
this.name = name;
this.email = email;
this.address = address;
this.tags = tags;
this.createdAt = createdAt;
this.creditLimit = creditLimit;
}
@Override
public String toString() {
return "Customer{id=" + id + ", name=" + name + ", email=" + email + ", address=" + address
+ ", tags=" + tags + ", creditLimit=" + creditLimit + "}";
}
public Long getId() { return id; }
public String getName() { return name; }
public String getEmail() { return email; }
public Address getAddress() { return address; }
public List<String> getTags() { return tags; }
public Instant getCreatedAt() { return createdAt; }
public BigDecimal getCreditLimit() { return creditLimit; }
}
@@ -0,0 +1,15 @@
package lab.assertj.domain;
import java.math.BigDecimal;
import java.time.Instant;
import java.util.List;
/** What a REST layer would return for a {@link Customer}: same shape, different type. */
public record CustomerDto(Long id, String name, String email, Address address, List<String> tags,
Instant createdAt, BigDecimal creditLimit) {
public static CustomerDto from(Customer c) {
return new CustomerDto(c.getId(), c.getName(), c.getEmail(), c.getAddress(), c.getTags(),
c.getCreatedAt(), c.getCreditLimit());
}
}
@@ -0,0 +1,33 @@
package lab.assertj.domain;
import java.math.BigDecimal;
import java.util.List;
/**
* A JavaBean on purpose: the assertj-assertions-generator reads getters (getX/isX), so this class
* gets an OrderAssert generated for it at build time.
*/
public class Order {
private final String id;
private final OrderStatus status;
private final BigDecimal total;
private final List<OrderLine> lines;
public Order(String id, OrderStatus status, BigDecimal total, List<OrderLine> lines) {
this.id = id;
this.status = status;
this.total = total;
this.lines = lines;
}
@Override
public String toString() {
return "Order{id=" + id + ", status=" + status + ", total=" + total + "}";
}
public String getId() { return id; }
public OrderStatus getStatus() { return status; }
public BigDecimal getTotal() { return total; }
public List<OrderLine> getLines() { return lines; }
public boolean isPaid() { return status != OrderStatus.NEW; }
}
@@ -0,0 +1,6 @@
package lab.assertj.domain;
import java.math.BigDecimal;
public record OrderLine(String sku, int quantity, BigDecimal unitPrice) {
}
@@ -0,0 +1,3 @@
package lab.assertj.domain;
public enum OrderStatus { NEW, PAID, SHIPPED }
@@ -0,0 +1,99 @@
package lab.assertj;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.byLessThan;
import static org.assertj.core.api.Assertions.catchThrowable;
import static org.assertj.core.api.Assertions.within;
import java.math.BigDecimal;
import java.util.Optional;
import org.junit.jupiter.api.Test;
/** Section: the smallest thing that works, and what the failure message looks like. */
class BasicAssertionsTest {
@Test
void chainAndFailureMessage() {
String greeting = "Hello, AssertJ";
// one actual value, many checks, one readable chain
assertThat(greeting)
.isNotBlank()
.startsWith("Hello")
.endsWith("AssertJ")
.hasSize(14)
.containsIgnoringCase("assertj");
Throwable t = catchThrowable(() -> assertThat(greeting).startsWith("Goodbye"));
Throwable n = catchThrowable(() -> assertThat(41 + 1).isEqualTo(43));
Throwable d = catchThrowable(() -> assertThat(42).as("answer to everything").isEqualTo(43));
new Transcript("01-basic-failure-messages.txt")
.block("startsWith failure:", t.getMessage())
.block("isEqualTo failure:", n.getMessage())
.block("as(...) failure:", d.getMessage())
.write();
assertThat(t).isInstanceOf(AssertionError.class).hasMessageContaining("to start with");
assertThat(d.getMessage()).startsWith("[answer to everything]");
}
@Test
void forgottenTerminalAssertionPassesSilently() {
// assertThat(x) on its own is only an *assertion object*. Nothing is checked until a
// method such as isEqualTo is called on it. This compiles, runs and passes.
assertThat(1);
assertThat("any value at all");
Throwable t = catchThrowable(() -> {
assertThat(1);
});
assertThat(t).isNull();
new Transcript("02-forgotten-terminal-assertion.txt")
.line("assertThat(1); -> threw: %s", t == null ? "nothing" : t)
.line("assertThat(\"any value\"); -> threw: nothing")
.write();
}
@Test
void bigDecimalAndDoubles() {
BigDecimal price = new BigDecimal("2.00");
Throwable eq = catchThrowable(() -> assertThat(price).isEqualTo(new BigDecimal("2.0")));
// isEqualByComparingTo ignores scale (compareTo); isEqualTo uses equals (scale matters)
assertThat(price).isEqualByComparingTo(new BigDecimal("2.0"));
assertThat(price).isEqualByComparingTo("2");
assertThat(0.1 + 0.2).isNotEqualTo(0.3);
assertThat(0.1 + 0.2).isCloseTo(0.3, within(1e-9));
assertThat(0.1 + 0.2).isCloseTo(0.3, byLessThan(1e-9));
Throwable close = catchThrowable(() -> assertThat(0.1 + 0.2).isEqualTo(0.3));
new Transcript("03-bigdecimal-and-doubles.txt")
.block("BigDecimal 2.00 isEqualTo 2.0:", eq.getMessage())
.block("double 0.1+0.2 isEqualTo 0.3:", close.getMessage())
.line("0.1 + 0.2 = %s", 0.1 + 0.2)
.write();
assertThat(eq).isInstanceOf(AssertionError.class);
}
@Test
void optionalAndNull() {
Optional<String> present = Optional.of("x");
Optional<String> empty = Optional.empty();
assertThat(present).isPresent().contains("x").hasValue("x");
assertThat(empty).isEmpty();
Throwable t = catchThrowable(() -> assertThat(empty).isPresent());
Throwable nul = catchThrowable(() -> assertThat((String) null).startsWith("a"));
new Transcript("04-optional-and-null.txt")
.block("Optional.empty() isPresent:", t.getMessage())
.block("null String startsWith:", nul.getMessage())
.write();
}
}
@@ -0,0 +1,110 @@
package lab.assertj;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.catchThrowable;
import static org.assertj.core.api.Assertions.entry;
import static org.assertj.core.api.Assertions.tuple;
import java.math.BigDecimal;
import java.util.List;
import java.util.Map;
import lab.assertj.domain.Order;
import lab.assertj.domain.OrderLine;
import lab.assertj.domain.OrderStatus;
import org.junit.jupiter.api.Test;
/** Section: collections, which is where AssertJ earns its keep. */
class CollectionAssertionsTest {
private static final List<OrderLine> LINES = List.of(
new OrderLine("BOOK-1", 2, new BigDecimal("12.50")),
new OrderLine("PEN-7", 10, new BigDecimal("1.20")),
new OrderLine("BAG-3", 1, new BigDecimal("40.00")));
@Test
void containsFamilyAndTheirMessages() {
List<String> skus = List.of("BOOK-1", "PEN-7", "BAG-3");
assertThat(skus).hasSize(3).contains("PEN-7").doesNotContain("X")
.containsExactlyInAnyOrder("BAG-3", "BOOK-1", "PEN-7")
.containsExactly("BOOK-1", "PEN-7", "BAG-3")
.startsWith("BOOK-1")
.doesNotHaveDuplicates();
Throwable order = catchThrowable(() -> assertThat(skus).containsExactly("PEN-7", "BOOK-1", "BAG-3"));
Throwable missing = catchThrowable(() -> assertThat(skus).containsExactlyInAnyOrder("BOOK-1", "PEN-7", "HAT-9"));
new Transcript("05-contains-messages.txt")
.block("containsExactly, wrong order:", order.getMessage())
.block("containsExactlyInAnyOrder, one missing and one unexpected:", missing.getMessage())
.write();
}
@Test
void extractingAndFiltering() {
// extracting by property/field name, or by lambda (type-safe, preferred)
assertThat(LINES).extracting(OrderLine::sku).containsExactly("BOOK-1", "PEN-7", "BAG-3");
assertThat(LINES).extracting("sku").contains("PEN-7");
// several properties at once -> tuples
assertThat(LINES)
.extracting(OrderLine::sku, OrderLine::quantity)
.containsExactly(tuple("BOOK-1", 2), tuple("PEN-7", 10), tuple("BAG-3", 1));
// filter first, then assert on what is left
assertThat(LINES)
.filteredOn(l -> l.quantity() > 1)
.extracting(OrderLine::sku)
.containsExactly("BOOK-1", "PEN-7");
assertThat(LINES).filteredOn("sku", "BAG-3").hasSize(1);
Throwable t = catchThrowable(() -> assertThat(LINES)
.extracting(OrderLine::sku, OrderLine::quantity)
.containsExactly(tuple("BOOK-1", 2), tuple("PEN-7", 11), tuple("BAG-3", 1)));
new Transcript("06-extracting-tuple-failure.txt")
.block("extracting(sku, quantity) with one wrong quantity:", t.getMessage())
.write();
}
@Test
void elementConditions() {
assertThat(LINES).allSatisfy(l -> assertThat(l.unitPrice()).isPositive());
assertThat(LINES).anySatisfy(l -> assertThat(l.sku()).startsWith("BAG"));
assertThat(LINES).noneMatch(l -> l.quantity() == 0);
// satisfiesExactly: one assertion block per element, in order
assertThat(LINES).satisfiesExactly(
first -> assertThat(first.sku()).isEqualTo("BOOK-1"),
second -> assertThat(second.quantity()).isEqualTo(10),
third -> assertThat(third.unitPrice()).isEqualByComparingTo("40"));
Throwable t = catchThrowable(() -> assertThat(LINES)
.allSatisfy(l -> assertThat(l.quantity()).as("quantity of " + l.sku()).isLessThan(5)));
new Transcript("07-allsatisfy-failure.txt")
.blockWithoutFrames("allSatisfy, one element violates the rule:", t.getMessage())
.write();
}
@Test
void mapsAndOrdering() {
Map<String, Integer> stock = Map.of("BOOK-1", 4, "PEN-7", 0);
assertThat(stock)
.containsEntry("BOOK-1", 4)
.contains(entry("PEN-7", 0))
.containsKeys("BOOK-1", "PEN-7")
.doesNotContainKey("HAT-9")
.hasSize(2);
assertThat(List.of(1, 2, 3, 5)).isSorted();
Order order = new Order("o-1", OrderStatus.PAID, new BigDecimal("75.00"), LINES);
assertThat(order.getLines()).hasSize(3);
Throwable t = catchThrowable(() -> assertThat(stock).containsEntry("PEN-7", 5));
new Transcript("08-map-failure.txt")
.block("containsEntry with wrong value:", t.getMessage())
.write();
}
}
@@ -0,0 +1,54 @@
package lab.assertj;
import static lab.assertj.MyAssertions.assertThat;
import static org.assertj.core.api.Assertions.allOf;
import static org.assertj.core.api.Assertions.catchThrowable;
import java.math.BigDecimal;
import java.util.List;
import lab.assertj.custom.Invoice;
import org.assertj.core.api.Condition;
import org.junit.jupiter.api.Test;
/** Section: custom assertions, written by hand and as Condition. */
class CustomAssertionsTest {
private final Invoice invoice = new Invoice("INV-2026-001", new BigDecimal("100.00"), new BigDecimal("18.00"), "INR");
@Test
void handWrittenAssertReadsLikeTheDomain() {
assertThat(invoice)
.hasNumber("INV-2026-001")
.isArithmeticallyConsistent(new BigDecimal("118"))
.isIn("INR");
// standard assertThat(String) still resolves, because MyAssertions extends Assertions
assertThat("plain strings still work").startsWith("plain");
Throwable wrongNumber = catchThrowable(() -> assertThat(invoice).hasNumber("INV-2026-002"));
Throwable wrongGross = catchThrowable(() -> assertThat(invoice).isArithmeticallyConsistent(new BigDecimal("120")));
Throwable wrongCurrency = catchThrowable(() -> assertThat(invoice).as("billing currency").isIn("USD"));
Throwable nullInvoice = catchThrowable(() -> assertThat((Invoice) null).hasNumber("x"));
new Transcript("18-custom-invoice-assert.txt")
.block("hasNumber, wrong:", wrongNumber.getMessage())
.block("isArithmeticallyConsistent, wrong:", wrongGross.getMessage())
.block("as(\"billing currency\").isIn(\"USD\"):", wrongCurrency.getMessage())
.block("null actual:", nullInvoice.getMessage())
.write();
}
@Test
void conditionsAreTheLightweightAlternative() {
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");
assertThat(List.of("ABC", "XY")).are(allOf(upperCase, shortWord));
assertThat("ABC").is(upperCase).has(shortWord);
Throwable t = catchThrowable(() -> assertThat("Abcdef").is(allOf(upperCase, shortWord)));
new Transcript("19-condition-failure.txt")
.block("allOf(upperCase, shortWord) on \"Abcdef\":", t.getMessage())
.write();
}
}
@@ -0,0 +1,83 @@
package lab.assertj;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatCode;
import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
import static org.assertj.core.api.Assertions.assertThatNoException;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.assertj.core.api.Assertions.catchThrowable;
import static org.assertj.core.api.Assertions.catchThrowableOfType;
import java.io.IOException;
import lab.assertj.custom.Checkout;
import lab.assertj.custom.CheckoutException;
import org.junit.jupiter.api.Test;
/** Section: exceptions, including the cause chain. */
class ExceptionAssertionsTest {
private final Checkout checkout = new Checkout();
@Test
void thrownBy() {
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");
// given-when-then style: the throwing call is the "when"
assertThatExceptionOfType(CheckoutException.class)
.isThrownBy(() -> checkout.pay("o-43"))
.withMessageContaining("o-43")
.withCauseInstanceOf(IllegalStateException.class);
assertThatIllegalArgumentException().isThrownBy(() -> Integer.parseInt("abc"))
.isInstanceOf(NumberFormatException.class)
.withMessage("For input string: \"abc\"");
}
@Test
void inspectCustomFieldsViaCatchThrowableOfType() {
// catchThrowableOfType hands you the typed exception so you can read your own fields
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");
}
@Test
void nothingThrownAndTheNothingThrownTrap() {
assertThatNoException().isThrownBy(() -> Integer.parseInt("12"));
assertThatCode(() -> Integer.parseInt("12")).doesNotThrowAnyException();
// The trap: assertThatThrownBy passes only if something IS thrown. If the code under test
// stops throwing, the failure message says so.
Throwable noThrow = catchThrowable(() -> assertThatThrownBy(() -> Integer.parseInt("12"))
.isInstanceOf(NumberFormatException.class));
// And a wrong message shows expected vs actual.
Throwable wrongMessage = catchThrowable(() -> assertThatThrownBy(() -> checkout.pay("o-45"))
.hasMessage("payment failed"));
Throwable wrongType = catchThrowable(() -> assertThatThrownBy(() -> checkout.pay("o-45"))
.isInstanceOf(IllegalStateException.class));
new Transcript("09-exception-failures.txt")
.blockWithoutFrames("expected exception, nothing thrown:", noThrow.getMessage())
.blockWithoutFrames("wrong message:", wrongMessage.getMessage())
.blockWithoutFrames("wrong type:", wrongType.getMessage())
.write();
assertThat(noThrow).isInstanceOf(AssertionError.class);
}
}
@@ -0,0 +1,55 @@
package lab.assertj;
import static lab.assertj.generated.Assertions.assertThat;
import static org.assertj.core.api.Assertions.catchThrowable;
import java.math.BigDecimal;
import java.util.List;
import lab.assertj.domain.Order;
import lab.assertj.domain.OrderLine;
import lab.assertj.domain.OrderStatus;
import org.junit.jupiter.api.Test;
/**
* Uses OrderAssert, which nobody wrote: assertj-assertions-generator-maven-plugin generates it into
* target/generated-test-sources at build time from the getters of lab.assertj.domain.Order.
*/
class GeneratedAssertionsTest {
private final Order order = new Order("o-1", OrderStatus.PAID, new BigDecimal("75.00"),
List.of(new OrderLine("BOOK-1", 2, new BigDecimal("12.50"))));
@Test
void generatedAssertionsReadLikeProperties() {
assertThat(order)
.hasId("o-1")
.hasStatus(OrderStatus.PAID)
.isPaid()
.hasLines(new OrderLine("BOOK-1", 2, new BigDecimal("12.50")));
Throwable status = catchThrowable(() -> assertThat(order).hasStatus(OrderStatus.SHIPPED));
Throwable notPaid = catchThrowable(() -> assertThat(order).isNotPaid());
// The generated hasTotal(..) uses equals(), so BigDecimal scale matters: 75 is not 75.00.
Throwable scale = catchThrowable(() -> assertThat(order).hasTotal(new BigDecimal("75")));
Throwable nul = catchThrowable(() -> assertThat((Order) null).hasId("o-1"));
new Transcript("20-generated-assert-messages.txt")
.block("hasStatus(SHIPPED):", status.getMessage())
.block("isNotPaid():", notPaid.getMessage())
.block("hasTotal(new BigDecimal(\"75\")) against 75.00:", scale.getMessage())
.block("null actual:", nul.getMessage())
.write();
}
@Test
void generatedSoftAssertionsEntryPoint() {
lab.assertj.generated.SoftAssertions softly = new lab.assertj.generated.SoftAssertions();
softly.assertThat(order).hasId("o-1").isPaid();
softly.assertThat(order).hasStatus(OrderStatus.SHIPPED); // collected, not thrown
Throwable t = catchThrowable(softly::assertAll);
new Transcript("21-generated-soft-assertions.txt")
.block("generated SoftAssertions, one failing hasStatus:", t.getMessage())
.write();
}
}
@@ -0,0 +1,46 @@
package lab.assertj;
import java.math.BigDecimal;
import lab.assertj.custom.Invoice;
import org.assertj.core.api.AbstractAssert;
/**
* A hand-written custom assertion: extends AbstractAssert, takes the actual value in the
* constructor, and exposes domain-language checks that call failWithMessage / isNotNull first.
*/
public class InvoiceAssert extends AbstractAssert<InvoiceAssert, Invoice> {
private InvoiceAssert(Invoice actual) {
super(actual, InvoiceAssert.class);
}
public static InvoiceAssert assertThat(Invoice actual) {
return new InvoiceAssert(actual);
}
public InvoiceAssert hasNumber(String expected) {
isNotNull(); // fail with a clear message instead of an NPE when actual is null
if (!actual.number().equals(expected)) {
failWithMessage("Expected invoice number to be <%s> but was <%s>", expected, actual.number());
}
return this;
}
/** Domain rule, not a getter: gross must equal net + tax, ignoring BigDecimal scale. */
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;
}
public InvoiceAssert isIn(String currency) {
isNotNull();
// 'info' carries any as("...") description set by the caller into the failure message
objects.assertEqual(info, actual.currency(), currency);
return this;
}
}
@@ -0,0 +1,11 @@
package lab.assertj;
import lab.assertj.custom.Invoice;
import org.assertj.core.api.Assertions;
/** One static-import entry point: standard assertThat overloads plus our own. */
public class MyAssertions extends Assertions {
public static InvoiceAssert assertThat(Invoice actual) {
return InvoiceAssert.assertThat(actual);
}
}
@@ -0,0 +1,144 @@
package lab.assertj;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.catchThrowable;
import java.math.BigDecimal;
import java.time.Instant;
import java.util.List;
import lab.assertj.domain.Address;
import lab.assertj.domain.Customer;
import lab.assertj.domain.CustomerDto;
import org.junit.jupiter.api.Test;
/** Section: comparing objects field by field - the DTO problem. */
class RecursiveComparisonTest {
/** A named comparator, so the recursive-comparison report prints something readable. */
static final java.util.Comparator<BigDecimal> BY_VALUE = new java.util.Comparator<>() {
@Override public int compare(BigDecimal a, BigDecimal b) { return a.compareTo(b); }
@Override public String toString() { return "BigDecimal compared with compareTo (scale ignored)"; }
};
private static final Instant T0 = Instant.parse("2026-10-01T10:00:00Z");
private static Customer customer() {
return new Customer(7L, "Asha Patil", "[email protected]",
new Address("12 MG Road", "Pune", "411001"),
List.of("vip", "newsletter"), T0, new BigDecimal("5000.00"));
}
@Test
void isEqualToUsesEqualsAndEqualsIsIdentityHere() {
Customer a = customer();
Customer b = customer();
Throwable t = catchThrowable(() -> assertThat(a).isEqualTo(b));
new Transcript("14-isEqualTo-without-equals.txt")
.block("two Customers with identical fields, isEqualTo:", t.getMessage())
.write();
assertThat(t).isInstanceOf(AssertionError.class);
// same two objects, recursive comparison
assertThat(a).usingRecursiveComparison().isEqualTo(b);
}
@Test
void entityVersusDtoDifferentTypes() {
Customer entity = customer();
CustomerDto dto = CustomerDto.from(entity);
// different classes, same fields: passes, because recursive comparison compares fields,
// not types, by default
assertThat(dto).usingRecursiveComparison().isEqualTo(entity);
// opt in to type checking and the same call fails
Throwable t = catchThrowable(() -> assertThat(dto).usingRecursiveComparison()
.withStrictTypeChecking().isEqualTo(entity));
new Transcript("15-strict-type-checking.txt")
.block("DTO vs entity with withStrictTypeChecking():", t.getMessage())
.write();
}
@Test
void differenceReport() {
Customer expected = customer();
Customer actual = new Customer(7L, "Asha Patil", "[email protected]",
new Address("12 MG Road", "Mumbai", "411001"),
List.of("vip"), T0, new BigDecimal("5000.0"));
Throwable t = catchThrowable(() -> assertThat(actual).usingRecursiveComparison().isEqualTo(expected));
new Transcript("16-recursive-difference-report.txt")
.block("recursive comparison, four fields differ:", t.getMessage())
.write();
assertThat(t).hasMessageContaining("address.city").hasMessageContaining("email")
.hasMessageContaining("tags").hasMessageContaining("creditLimit");
}
@Test
void ignoringAndComparatorsAndOrder() {
Customer a = customer();
Customer b = new Customer(99L, "Asha Patil", "[email protected]",
new Address("12 MG Road", "Pune", "411001"),
List.of("newsletter", "vip"), Instant.parse("2026-10-26T01:00:00Z"), new BigDecimal("5000.0"));
// each knob removes one of the 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);
// the opposite: look at only the fields you care about
assertThat(b).usingRecursiveComparison()
.comparingOnlyFields("name", "email", "address")
.isEqualTo(a);
// regex and type based ignoring
assertThat(b).usingRecursiveComparison()
.ignoringFieldsMatchingRegexes("id|createdAt")
.ignoringCollectionOrder()
.ignoringFieldsOfTypes(BigDecimal.class)
.isEqualTo(a);
// each default, shown by what happens when the knob is missing
Throwable noIgnore = catchThrowable(() -> assertThat(b).usingRecursiveComparison().isEqualTo(a));
Throwable noOrder = catchThrowable(() -> assertThat(b).usingRecursiveComparison()
.ignoringFields("id", "createdAt")
.withComparatorForType(BY_VALUE, BigDecimal.class)
.isEqualTo(a));
Throwable noComparator = catchThrowable(() -> assertThat(b).usingRecursiveComparison()
.ignoringFields("id", "createdAt").ignoringCollectionOrder().isEqualTo(a));
new Transcript("17-recursive-knobs.txt")
.block("no knobs:", noIgnore.getMessage())
.block("everything but ignoringCollectionOrder():", noOrder.getMessage())
.block("everything but the BigDecimal comparator:", noComparator.getMessage())
.write();
}
@Test
void nullsAndExpectedOnlyFields() {
Customer full = customer();
Customer partial = new Customer(null, "Asha Patil", null, null, null, null, null);
// "actual may have extra data, expected is a template": ignore actual's nulls
assertThat(partial).usingRecursiveComparison().ignoringActualNullFields().isEqualTo(full);
// the other direction: ignore the *expected* object's nulls instead
Throwable t = catchThrowable(() -> assertThat(full).usingRecursiveComparison()
.ignoringExpectedNullFields().isEqualTo(partial));
assertThat(t).isNull(); // expected nulls ignored, so only name is compared
}
@Test
void recursiveAssertionOnCollections() {
List<Customer> actual = List.of(customer());
List<CustomerDto> dtos = actual.stream().map(CustomerDto::from).toList();
assertThat(dtos).usingRecursiveFieldByFieldElementComparator()
.containsExactlyElementsOf(
actual.stream().map(CustomerDto::from).toList());
assertThat(dtos).usingRecursiveComparison().isEqualTo(actual);
}
}
@@ -0,0 +1,116 @@
package lab.assertj;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.assertj.core.api.Assertions.catchThrowable;
import java.math.BigDecimal;
import java.util.List;
import lab.assertj.domain.Order;
import lab.assertj.domain.OrderLine;
import lab.assertj.domain.OrderStatus;
import org.assertj.core.api.SoftAssertions;
import org.assertj.core.api.junit.jupiter.InjectSoftAssertions;
import org.assertj.core.api.junit.jupiter.SoftAssertionsExtension;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.opentest4j.MultipleFailuresError;
/** Section: soft assertions - report every failure, not just the first. */
@ExtendWith(SoftAssertionsExtension.class)
class SoftAssertionsTest {
@InjectSoftAssertions
SoftAssertions softly;
private static Order order() {
return new Order("o-9", OrderStatus.NEW, new BigDecimal("99.00"),
List.of(new OrderLine("BOOK-1", 2, new BigDecimal("12.50"))));
}
@Test
void hardAssertionsStopAtTheFirstFailure() {
Order o = order();
Throwable t = catchThrowable(() -> {
assertThat(o.getId()).isEqualTo("o-1"); // fails here ...
assertThat(o.getStatus()).isEqualTo(OrderStatus.PAID); // ... so these two never run
assertThat(o.getTotal()).isEqualByComparingTo("75");
});
new Transcript("10-hard-assertions-first-failure-only.txt")
.block("three wrong expectations, hard assertions:", t.getMessage())
.write();
}
@Test
void assertSoftlyReportsAll() {
Order o = order();
Throwable t = catchThrowable(() -> SoftAssertions.assertSoftly(s -> {
s.assertThat(o.getId()).isEqualTo("o-1");
s.assertThat(o.getStatus()).isEqualTo(OrderStatus.PAID);
s.assertThat(o.getTotal()).isEqualByComparingTo("75");
}));
new Transcript("11-soft-assertions-all-failures.txt")
.line("exception type: %s", t.getClass().getName())
.block("message:", t.getMessage())
.write();
assertThat(t).isInstanceOf(MultipleFailuresError.class);
assertThat(((MultipleFailuresError) t).getFailures()).hasSize(3);
}
@Test
void forgettingAssertAllSwallowsFailures() {
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);
new Transcript("12-forgot-assertall.txt")
.line("errors collected but not thrown: %d", collected.size())
.line("assertAll() throws: %s", t.getClass().getName())
.write();
assertThat(collected).hasSize(2);
assertThat(t).isInstanceOf(MultipleFailuresError.class);
}
@Test
void softAssertionsDoNotProtectTheCodeThatFollows() {
// A soft failure does not stop execution, so code after it still runs against the bad value.
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
}));
new Transcript("13-soft-then-npe.txt")
.line("thrown: %s", t.getClass().getName())
.write();
assertThat(t).isInstanceOf(NullPointerException.class);
}
// The extension injects 'softly' and calls assertAll() after the test method, even if the body
// returns normally. This test passes: all four expectations hold.
@Test
void extensionInjectedSoftly() {
Order o = new Order("o-1", OrderStatus.PAID, new BigDecimal("75.00"),
List.of(new OrderLine("BOOK-1", 2, new BigDecimal("12.50"))));
softly.assertThat(o.getId()).isEqualTo("o-1");
softly.assertThat(o.getStatus()).isEqualTo(OrderStatus.PAID);
softly.assertThat(o.getTotal()).isEqualByComparingTo("75");
softly.assertThat(o.getLines()).hasSize(1);
}
@Test
void assertThatThrownByWithSoft() {
// A failing soft assertion inside assertSoftly surfaces as MultipleFailuresError (JUnit's type).
assertThatThrownBy(() -> SoftAssertions.assertSoftly(s -> s.assertThat(1).isEqualTo(2)))
.isInstanceOf(MultipleFailuresError.class)
.hasMessageContaining("Multiple Failures (1 failure)");
}
}
@@ -0,0 +1,57 @@
package lab.assertj;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
/**
* Collects lines during a test and writes them to output/NN-name.txt.
* The same test asserts the same facts, so every figure in the blog post is an assertion.
*/
final class Transcript {
private final String file;
private final List<String> lines = new ArrayList<>();
Transcript(String file) {
this.file = file;
}
Transcript line(String format, Object... args) {
lines.add(args.length == 0 ? format : String.format(format, args));
return this;
}
/** A labelled multi-line message (an AssertionError message, usually), indented, unedited. */
Transcript block(String label, String text) {
lines.add(label);
for (String l : text.strip().split("\n")) {
lines.add(" " + l.stripTrailing());
}
return this;
}
/** Like block(), but drops stack-frame lines ("at ..." and "...(n remaining lines...)"). */
Transcript blockWithoutFrames(String label, String text) {
lines.add(label + " (stack frames omitted)");
for (String l : text.strip().split("\n")) {
if (l.stripLeading().startsWith("at ") || l.stripLeading().startsWith("...")) {
continue;
}
lines.add(" " + l.stripTrailing());
}
return this;
}
void write() {
try {
Path dir = Path.of("output");
Files.createDirectories(dir);
Files.writeString(dir.resolve(file), String.join("\n", lines) + "\n");
} catch (IOException e) {
throw new UncheckedIOException(e);
}
}
}