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:
@@ -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
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
target/
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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)]
|
||||||
@@ -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
|
||||||
@@ -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)]
|
||||||
@@ -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)
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
errors collected but not thrown: 2
|
||||||
|
assertAll() throws: org.assertj.core.error.AssertJMultipleFailuresError
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
@@ -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)
|
||||||
@@ -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>
|
||||||
Executable
+44
@@ -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"
|
||||||
Executable
+8
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user