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 |
| [`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.
```bash
cd mockito && ./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);
}
}
}