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:
@@ -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