From 9d89323864493260fa6e7e51a0faae6d35fd45ff Mon Sep 17 00:00:00 2001 From: asmhatre Date: Mon, 26 Oct 2026 01:57:10 +0000 Subject: [PATCH] assertj: AssertJ 3.27.7 lab (soft assertions, recursive comparison, custom and generated assertions) Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01CifR2BE7PBvRy5hT8TS9oz --- README.md | 2 + assertj/.gitignore | 1 + assertj/README.md | 29 ++++ assertj/output/01-basic-failure-messages.txt | 12 ++ .../02-forgotten-terminal-assertion.txt | 2 + assertj/output/03-bigdecimal-and-doubles.txt | 7 + assertj/output/04-optional-and-null.txt | 4 + assertj/output/05-contains-messages.txt | 17 +++ .../output/06-extracting-tuple-failure.txt | 9 ++ assertj/output/07-allsatisfy-failure.txt | 13 ++ assertj/output/08-map-failure.txt | 7 + assertj/output/09-exception-failures.txt | 18 +++ .../10-hard-assertions-first-failure-only.txt | 3 + .../11-soft-assertions-all-failures.txt | 15 ++ assertj/output/12-forgot-assertall.txt | 2 + assertj/output/13-soft-then-npe.txt | 1 + .../output/14-isEqualTo-without-equals.txt | 3 + assertj/output/15-strict-type-checking.txt | 20 +++ .../output/16-recursive-difference-report.txt | 35 +++++ assertj/output/17-recursive-knobs.txt | 89 +++++++++++ assertj/output/18-custom-invoice-assert.txt | 10 ++ assertj/output/19-condition-failure.txt | 8 + .../output/20-generated-assert-messages.txt | 18 +++ .../output/21-generated-soft-assertions.txt | 10 ++ assertj/output/22-generator-files.txt | 16 ++ assertj/output/23-generated-isPaid.txt | 15 ++ .../24-generator-without-javax-annotation.txt | 15 ++ assertj/output/25-assertj-4-milestone.txt | 4 + assertj/pom.xml | 69 +++++++++ assertj/scripts/capture-generator.sh | 44 ++++++ assertj/scripts/run-all.sh | 8 + .../java/lab/assertj/custom/Checkout.java | 18 +++ .../lab/assertj/custom/CheckoutException.java | 12 ++ .../main/java/lab/assertj/custom/Invoice.java | 11 ++ .../main/java/lab/assertj/domain/Address.java | 4 + .../java/lab/assertj/domain/Customer.java | 44 ++++++ .../java/lab/assertj/domain/CustomerDto.java | 15 ++ .../main/java/lab/assertj/domain/Order.java | 33 ++++ .../java/lab/assertj/domain/OrderLine.java | 6 + .../java/lab/assertj/domain/OrderStatus.java | 3 + .../java/lab/assertj/BasicAssertionsTest.java | 99 ++++++++++++ .../lab/assertj/CollectionAssertionsTest.java | 110 +++++++++++++ .../lab/assertj/CustomAssertionsTest.java | 54 +++++++ .../lab/assertj/ExceptionAssertionsTest.java | 83 ++++++++++ .../lab/assertj/GeneratedAssertionsTest.java | 55 +++++++ .../test/java/lab/assertj/InvoiceAssert.java | 46 ++++++ .../test/java/lab/assertj/MyAssertions.java | 11 ++ .../lab/assertj/RecursiveComparisonTest.java | 144 ++++++++++++++++++ .../java/lab/assertj/SoftAssertionsTest.java | 116 ++++++++++++++ .../src/test/java/lab/assertj/Transcript.java | 57 +++++++ 50 files changed, 1427 insertions(+) create mode 100644 assertj/.gitignore create mode 100644 assertj/README.md create mode 100644 assertj/output/01-basic-failure-messages.txt create mode 100644 assertj/output/02-forgotten-terminal-assertion.txt create mode 100644 assertj/output/03-bigdecimal-and-doubles.txt create mode 100644 assertj/output/04-optional-and-null.txt create mode 100644 assertj/output/05-contains-messages.txt create mode 100644 assertj/output/06-extracting-tuple-failure.txt create mode 100644 assertj/output/07-allsatisfy-failure.txt create mode 100644 assertj/output/08-map-failure.txt create mode 100644 assertj/output/09-exception-failures.txt create mode 100644 assertj/output/10-hard-assertions-first-failure-only.txt create mode 100644 assertj/output/11-soft-assertions-all-failures.txt create mode 100644 assertj/output/12-forgot-assertall.txt create mode 100644 assertj/output/13-soft-then-npe.txt create mode 100644 assertj/output/14-isEqualTo-without-equals.txt create mode 100644 assertj/output/15-strict-type-checking.txt create mode 100644 assertj/output/16-recursive-difference-report.txt create mode 100644 assertj/output/17-recursive-knobs.txt create mode 100644 assertj/output/18-custom-invoice-assert.txt create mode 100644 assertj/output/19-condition-failure.txt create mode 100644 assertj/output/20-generated-assert-messages.txt create mode 100644 assertj/output/21-generated-soft-assertions.txt create mode 100644 assertj/output/22-generator-files.txt create mode 100644 assertj/output/23-generated-isPaid.txt create mode 100644 assertj/output/24-generator-without-javax-annotation.txt create mode 100644 assertj/output/25-assertj-4-milestone.txt create mode 100644 assertj/pom.xml create mode 100755 assertj/scripts/capture-generator.sh create mode 100755 assertj/scripts/run-all.sh create mode 100644 assertj/src/main/java/lab/assertj/custom/Checkout.java create mode 100644 assertj/src/main/java/lab/assertj/custom/CheckoutException.java create mode 100644 assertj/src/main/java/lab/assertj/custom/Invoice.java create mode 100644 assertj/src/main/java/lab/assertj/domain/Address.java create mode 100644 assertj/src/main/java/lab/assertj/domain/Customer.java create mode 100644 assertj/src/main/java/lab/assertj/domain/CustomerDto.java create mode 100644 assertj/src/main/java/lab/assertj/domain/Order.java create mode 100644 assertj/src/main/java/lab/assertj/domain/OrderLine.java create mode 100644 assertj/src/main/java/lab/assertj/domain/OrderStatus.java create mode 100644 assertj/src/test/java/lab/assertj/BasicAssertionsTest.java create mode 100644 assertj/src/test/java/lab/assertj/CollectionAssertionsTest.java create mode 100644 assertj/src/test/java/lab/assertj/CustomAssertionsTest.java create mode 100644 assertj/src/test/java/lab/assertj/ExceptionAssertionsTest.java create mode 100644 assertj/src/test/java/lab/assertj/GeneratedAssertionsTest.java create mode 100644 assertj/src/test/java/lab/assertj/InvoiceAssert.java create mode 100644 assertj/src/test/java/lab/assertj/MyAssertions.java create mode 100644 assertj/src/test/java/lab/assertj/RecursiveComparisonTest.java create mode 100644 assertj/src/test/java/lab/assertj/SoftAssertionsTest.java create mode 100644 assertj/src/test/java/lab/assertj/Transcript.java diff --git a/README.md b/README.md index 8324344..caa50b2 100644 --- a/README.md +++ b/README.md @@ -6,10 +6,12 @@ Runnable companion code for the testing posts on [ankurm.com](https://ankurm.com |---|---|---| | [`mockito/`](mockito) | Mockito 5 Deep Dive: Static, Final and Constructor Mocking, ArgumentCaptor and Strict Stubs | Mockito 5.24.0, JUnit 6.1.3, JDK 25 | | [`junit-6-migration/`](junit-6-migration) | JUnit 5 to 6 Migration: What Was Removed, What Changed and How to Upgrade | JUnit 5.14.4 and 6.1.3, JDK 25 | +| [`assertj/`](assertj) | AssertJ Complete Guide: Soft Assertions, Recursive Comparison and Custom Assertions | AssertJ 3.27.7, JUnit 6.1.3, JDK 25 | Every module keeps the real output of its tests in `output/`. Each test writes the transcript and asserts the same facts, so the numbers in the posts fail the build if they stop being true. ```bash cd mockito && ./scripts/run-all.sh cd ../junit-6-migration && ./scripts/run-all.sh +cd ../assertj && ./scripts/run-all.sh ``` diff --git a/assertj/.gitignore b/assertj/.gitignore new file mode 100644 index 0000000..2f7896d --- /dev/null +++ b/assertj/.gitignore @@ -0,0 +1 @@ +target/ diff --git a/assertj/README.md b/assertj/README.md new file mode 100644 index 0000000..48cc4b8 --- /dev/null +++ b/assertj/README.md @@ -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@` 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. diff --git a/assertj/output/01-basic-failure-messages.txt b/assertj/output/01-basic-failure-messages.txt new file mode 100644 index 0000000..fc0811c --- /dev/null +++ b/assertj/output/01-basic-failure-messages.txt @@ -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 diff --git a/assertj/output/02-forgotten-terminal-assertion.txt b/assertj/output/02-forgotten-terminal-assertion.txt new file mode 100644 index 0000000..88ea9c6 --- /dev/null +++ b/assertj/output/02-forgotten-terminal-assertion.txt @@ -0,0 +1,2 @@ +assertThat(1); -> threw: nothing +assertThat("any value"); -> threw: nothing diff --git a/assertj/output/03-bigdecimal-and-doubles.txt b/assertj/output/03-bigdecimal-and-doubles.txt new file mode 100644 index 0000000..05dc1d3 --- /dev/null +++ b/assertj/output/03-bigdecimal-and-doubles.txt @@ -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 diff --git a/assertj/output/04-optional-and-null.txt b/assertj/output/04-optional-and-null.txt new file mode 100644 index 0000000..125dfd3 --- /dev/null +++ b/assertj/output/04-optional-and-null.txt @@ -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 diff --git a/assertj/output/05-contains-messages.txt b/assertj/output/05-contains-messages.txt new file mode 100644 index 0000000..08a3400 --- /dev/null +++ b/assertj/output/05-contains-messages.txt @@ -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"] diff --git a/assertj/output/06-extracting-tuple-failure.txt b/assertj/output/06-extracting-tuple-failure.txt new file mode 100644 index 0000000..621c40e --- /dev/null +++ b/assertj/output/06-extracting-tuple-failure.txt @@ -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)] diff --git a/assertj/output/07-allsatisfy-failure.txt b/assertj/output/07-allsatisfy-failure.txt new file mode 100644 index 0000000..9882905 --- /dev/null +++ b/assertj/output/07-allsatisfy-failure.txt @@ -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 diff --git a/assertj/output/08-map-failure.txt b/assertj/output/08-map-failure.txt new file mode 100644 index 0000000..5c4bfea --- /dev/null +++ b/assertj/output/08-map-failure.txt @@ -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)] diff --git a/assertj/output/09-exception-failures.txt b/assertj/output/09-exception-failures.txt new file mode 100644 index 0000000..ca46ab3 --- /dev/null +++ b/assertj/output/09-exception-failures.txt @@ -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 diff --git a/assertj/output/10-hard-assertions-first-failure-only.txt b/assertj/output/10-hard-assertions-first-failure-only.txt new file mode 100644 index 0000000..8f19a79 --- /dev/null +++ b/assertj/output/10-hard-assertions-first-failure-only.txt @@ -0,0 +1,3 @@ +three wrong expectations, hard assertions: + expected: "o-1" + but was: "o-9" diff --git a/assertj/output/11-soft-assertions-all-failures.txt b/assertj/output/11-soft-assertions-all-failures.txt new file mode 100644 index 0000000..1885637 --- /dev/null +++ b/assertj/output/11-soft-assertions-all-failures.txt @@ -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) diff --git a/assertj/output/12-forgot-assertall.txt b/assertj/output/12-forgot-assertall.txt new file mode 100644 index 0000000..6f02223 --- /dev/null +++ b/assertj/output/12-forgot-assertall.txt @@ -0,0 +1,2 @@ +errors collected but not thrown: 2 +assertAll() throws: org.assertj.core.error.AssertJMultipleFailuresError diff --git a/assertj/output/13-soft-then-npe.txt b/assertj/output/13-soft-then-npe.txt new file mode 100644 index 0000000..7f44b48 --- /dev/null +++ b/assertj/output/13-soft-then-npe.txt @@ -0,0 +1 @@ +thrown: java.lang.NullPointerException diff --git a/assertj/output/14-isEqualTo-without-equals.txt b/assertj/output/14-isEqualTo-without-equals.txt new file mode 100644 index 0000000..2b393be --- /dev/null +++ b/assertj/output/14-isEqualTo-without-equals.txt @@ -0,0 +1,3 @@ +two Customers with identical fields, isEqualTo: + expected: "Customer{id=7, name=Asha Patil, email=asha@example.com, 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=asha@example.com, address=Address[street=12 MG Road, city=Pune, postcode=411001], tags=[vip, newsletter], creditLimit=5000.00} (Customer@74f827ad)" diff --git a/assertj/output/15-strict-type-checking.txt b/assertj/output/15-strict-type-checking.txt new file mode 100644 index 0000000..faea48d --- /dev/null +++ b/assertj/output/15-strict-type-checking.txt @@ -0,0 +1,20 @@ +DTO vs entity with withStrictTypeChecking(): + Expecting actual: + CustomerDto[id=7, name=Asha Patil, email=asha@example.com, 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=asha@example.com, 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=asha@example.com, 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=asha@example.com, 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 diff --git a/assertj/output/16-recursive-difference-report.txt b/assertj/output/16-recursive-difference-report.txt new file mode 100644 index 0000000..959c38b --- /dev/null +++ b/assertj/output/16-recursive-difference-report.txt @@ -0,0 +1,35 @@ +recursive comparison, four fields differ: + Expecting actual: + Customer{id=7, name=Asha Patil, email=asha.patil@example.com, 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=asha@example.com, 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 : "asha.patil@example.com" + - expected value: "asha@example.com" + 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 diff --git a/assertj/output/17-recursive-knobs.txt b/assertj/output/17-recursive-knobs.txt new file mode 100644 index 0000000..157e31d --- /dev/null +++ b/assertj/output/17-recursive-knobs.txt @@ -0,0 +1,89 @@ +no knobs: + Expecting actual: + Customer{id=99, name=Asha Patil, email=asha@example.com, 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=asha@example.com, 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=asha@example.com, 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=asha@example.com, 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=asha@example.com, 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=asha@example.com, 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 diff --git a/assertj/output/18-custom-invoice-assert.txt b/assertj/output/18-custom-invoice-assert.txt new file mode 100644 index 0000000..0d4b23f --- /dev/null +++ b/assertj/output/18-custom-invoice-assert.txt @@ -0,0 +1,10 @@ +hasNumber, wrong: + Expected invoice number to be but was +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 diff --git a/assertj/output/19-condition-failure.txt b/assertj/output/19-condition-failure.txt new file mode 100644 index 0000000..7ecf22e --- /dev/null +++ b/assertj/output/19-condition-failure.txt @@ -0,0 +1,8 @@ +allOf(upperCase, shortWord) on "Abcdef": + Expecting actual: + "Abcdef" + to be: + [✗] all of:[ + [✗] upper case, + [✗] at most 4 characters + ] diff --git a/assertj/output/20-generated-assert-messages.txt b/assertj/output/20-generated-assert-messages.txt new file mode 100644 index 0000000..0019385 --- /dev/null +++ b/assertj/output/20-generated-assert-messages.txt @@ -0,0 +1,18 @@ +hasStatus(SHIPPED): + Expecting status of: + + to be: + + but was: + +isNotPaid(): + Expecting that actual Order is not paid but is. +hasTotal(new BigDecimal("75")) against 75.00: + Expecting total of: + + to be: + <75> + but was: + <75.00> +null actual: + Expecting actual not to be null diff --git a/assertj/output/21-generated-soft-assertions.txt b/assertj/output/21-generated-soft-assertions.txt new file mode 100644 index 0000000..77df7e4 --- /dev/null +++ b/assertj/output/21-generated-soft-assertions.txt @@ -0,0 +1,10 @@ +generated SoftAssertions, one failing hasStatus: + Multiple Failures (1 failure) + -- failure 1 -- + Expecting status of: + + to be: + + but was: + + at GeneratedAssertionsTest.generatedSoftAssertionsEntryPoint(GeneratedAssertionsTest.java:48) diff --git a/assertj/output/22-generator-files.txt b/assertj/output/22-generator-files.txt new file mode 100644 index 0000000..1db4a6d --- /dev/null +++ b/assertj/output/22-generator-files.txt @@ -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 diff --git a/assertj/output/23-generated-isPaid.txt b/assertj/output/23-generated-isPaid.txt new file mode 100644 index 0000000..6a5cd76 --- /dev/null +++ b/assertj/output/23-generated-isPaid.txt @@ -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, A extends Order> extends AbstractObjectAssert { + 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; + } diff --git a/assertj/output/24-generator-without-javax-annotation.txt b/assertj/output/24-generator-without-javax-annotation.txt new file mode 100644 index 0000000..e9b4556 --- /dev/null +++ b/assertj/output/24-generator-without-javax-annotation.txt @@ -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 diff --git a/assertj/output/25-assertj-4-milestone.txt b/assertj/output/25-assertj-4-milestone.txt new file mode 100644 index 0000000..85ffa08 --- /dev/null +++ b/assertj/output/25-assertj-4-milestone.txt @@ -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) diff --git a/assertj/pom.xml b/assertj/pom.xml new file mode 100644 index 0000000..ba11128 --- /dev/null +++ b/assertj/pom.xml @@ -0,0 +1,69 @@ + + + 4.0.0 + com.ankurm.testing + assertj-lab + 1.0.0 + + + 25 + UTF-8 + 6.1.3 + 3.27.7 + + 2.2.0 + + + + + org.junit.jupiter + junit-jupiter + ${junit.version} + test + + + org.assertj + assertj-core + ${assertj.version} + test + + + + javax.annotation + javax.annotation-api + 1.3.2 + test + + + + + + + org.apache.maven.plugins + maven-surefire-plugin + 3.5.6 + + + + org.assertj + assertj-assertions-generator-maven-plugin + ${assertj.generator.plugin.version} + + + generate-assertions + + + + + lab.assertj.domain + + lab.assertj.generated + true + + + + + diff --git a/assertj/scripts/capture-generator.sh b/assertj/scripts/capture-generator.sh new file mode 100755 index 0000000..b5dba2b --- /dev/null +++ b/assertj/scripts/capture-generator.sh @@ -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 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'\s*javax\.annotation.*?','',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" diff --git a/assertj/scripts/run-all.sh b/assertj/scripts/run-all.sh new file mode 100755 index 0000000..9753224 --- /dev/null +++ b/assertj/scripts/run-all.sh @@ -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/" diff --git a/assertj/src/main/java/lab/assertj/custom/Checkout.java b/assertj/src/main/java/lab/assertj/custom/Checkout.java new file mode 100644 index 0000000..1db6ff8 --- /dev/null +++ b/assertj/src/main/java/lab/assertj/custom/Checkout.java @@ -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); + } + } +} diff --git a/assertj/src/main/java/lab/assertj/custom/CheckoutException.java b/assertj/src/main/java/lab/assertj/custom/CheckoutException.java new file mode 100644 index 0000000..4384e6c --- /dev/null +++ b/assertj/src/main/java/lab/assertj/custom/CheckoutException.java @@ -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; } +} diff --git a/assertj/src/main/java/lab/assertj/custom/Invoice.java b/assertj/src/main/java/lab/assertj/custom/Invoice.java new file mode 100644 index 0000000..0da680d --- /dev/null +++ b/assertj/src/main/java/lab/assertj/custom/Invoice.java @@ -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); + } +} diff --git a/assertj/src/main/java/lab/assertj/domain/Address.java b/assertj/src/main/java/lab/assertj/domain/Address.java new file mode 100644 index 0000000..bde22bd --- /dev/null +++ b/assertj/src/main/java/lab/assertj/domain/Address.java @@ -0,0 +1,4 @@ +package lab.assertj.domain; + +public record Address(String street, String city, String postcode) { +} diff --git a/assertj/src/main/java/lab/assertj/domain/Customer.java b/assertj/src/main/java/lab/assertj/domain/Customer.java new file mode 100644 index 0000000..911396a --- /dev/null +++ b/assertj/src/main/java/lab/assertj/domain/Customer.java @@ -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 tags; + private Instant createdAt; + private BigDecimal creditLimit; + + public Customer(Long id, String name, String email, Address address, List 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 getTags() { return tags; } + public Instant getCreatedAt() { return createdAt; } + public BigDecimal getCreditLimit() { return creditLimit; } +} diff --git a/assertj/src/main/java/lab/assertj/domain/CustomerDto.java b/assertj/src/main/java/lab/assertj/domain/CustomerDto.java new file mode 100644 index 0000000..c7092de --- /dev/null +++ b/assertj/src/main/java/lab/assertj/domain/CustomerDto.java @@ -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 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()); + } +} diff --git a/assertj/src/main/java/lab/assertj/domain/Order.java b/assertj/src/main/java/lab/assertj/domain/Order.java new file mode 100644 index 0000000..f188976 --- /dev/null +++ b/assertj/src/main/java/lab/assertj/domain/Order.java @@ -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 lines; + + public Order(String id, OrderStatus status, BigDecimal total, List 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 getLines() { return lines; } + public boolean isPaid() { return status != OrderStatus.NEW; } +} diff --git a/assertj/src/main/java/lab/assertj/domain/OrderLine.java b/assertj/src/main/java/lab/assertj/domain/OrderLine.java new file mode 100644 index 0000000..aadceb6 --- /dev/null +++ b/assertj/src/main/java/lab/assertj/domain/OrderLine.java @@ -0,0 +1,6 @@ +package lab.assertj.domain; + +import java.math.BigDecimal; + +public record OrderLine(String sku, int quantity, BigDecimal unitPrice) { +} diff --git a/assertj/src/main/java/lab/assertj/domain/OrderStatus.java b/assertj/src/main/java/lab/assertj/domain/OrderStatus.java new file mode 100644 index 0000000..9fce9d7 --- /dev/null +++ b/assertj/src/main/java/lab/assertj/domain/OrderStatus.java @@ -0,0 +1,3 @@ +package lab.assertj.domain; + +public enum OrderStatus { NEW, PAID, SHIPPED } diff --git a/assertj/src/test/java/lab/assertj/BasicAssertionsTest.java b/assertj/src/test/java/lab/assertj/BasicAssertionsTest.java new file mode 100644 index 0000000..099fe84 --- /dev/null +++ b/assertj/src/test/java/lab/assertj/BasicAssertionsTest.java @@ -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 present = Optional.of("x"); + Optional 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(); + } +} diff --git a/assertj/src/test/java/lab/assertj/CollectionAssertionsTest.java b/assertj/src/test/java/lab/assertj/CollectionAssertionsTest.java new file mode 100644 index 0000000..f807d5a --- /dev/null +++ b/assertj/src/test/java/lab/assertj/CollectionAssertionsTest.java @@ -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 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 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 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(); + } +} diff --git a/assertj/src/test/java/lab/assertj/CustomAssertionsTest.java b/assertj/src/test/java/lab/assertj/CustomAssertionsTest.java new file mode 100644 index 0000000..171562c --- /dev/null +++ b/assertj/src/test/java/lab/assertj/CustomAssertionsTest.java @@ -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 upperCase = new Condition<>(s -> s.equals(s.toUpperCase()), "upper case"); + Condition 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(); + } +} diff --git a/assertj/src/test/java/lab/assertj/ExceptionAssertionsTest.java b/assertj/src/test/java/lab/assertj/ExceptionAssertionsTest.java new file mode 100644 index 0000000..b74e199 --- /dev/null +++ b/assertj/src/test/java/lab/assertj/ExceptionAssertionsTest.java @@ -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); + } +} diff --git a/assertj/src/test/java/lab/assertj/GeneratedAssertionsTest.java b/assertj/src/test/java/lab/assertj/GeneratedAssertionsTest.java new file mode 100644 index 0000000..bb98d81 --- /dev/null +++ b/assertj/src/test/java/lab/assertj/GeneratedAssertionsTest.java @@ -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(); + } +} diff --git a/assertj/src/test/java/lab/assertj/InvoiceAssert.java b/assertj/src/test/java/lab/assertj/InvoiceAssert.java new file mode 100644 index 0000000..747fc4c --- /dev/null +++ b/assertj/src/test/java/lab/assertj/InvoiceAssert.java @@ -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 { + + 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; + } +} diff --git a/assertj/src/test/java/lab/assertj/MyAssertions.java b/assertj/src/test/java/lab/assertj/MyAssertions.java new file mode 100644 index 0000000..3e716ac --- /dev/null +++ b/assertj/src/test/java/lab/assertj/MyAssertions.java @@ -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); + } +} diff --git a/assertj/src/test/java/lab/assertj/RecursiveComparisonTest.java b/assertj/src/test/java/lab/assertj/RecursiveComparisonTest.java new file mode 100644 index 0000000..49d4ecf --- /dev/null +++ b/assertj/src/test/java/lab/assertj/RecursiveComparisonTest.java @@ -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 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", "asha@example.com", + 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", "asha.patil@example.com", + 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", "asha@example.com", + 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 actual = List.of(customer()); + List dtos = actual.stream().map(CustomerDto::from).toList(); + + assertThat(dtos).usingRecursiveFieldByFieldElementComparator() + .containsExactlyElementsOf( + actual.stream().map(CustomerDto::from).toList()); + + assertThat(dtos).usingRecursiveComparison().isEqualTo(actual); + } +} diff --git a/assertj/src/test/java/lab/assertj/SoftAssertionsTest.java b/assertj/src/test/java/lab/assertj/SoftAssertionsTest.java new file mode 100644 index 0000000..b94ff9b --- /dev/null +++ b/assertj/src/test/java/lab/assertj/SoftAssertionsTest.java @@ -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 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)"); + } +} diff --git a/assertj/src/test/java/lab/assertj/Transcript.java b/assertj/src/test/java/lab/assertj/Transcript.java new file mode 100644 index 0000000..dbfe92a --- /dev/null +++ b/assertj/src/test/java/lab/assertj/Transcript.java @@ -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 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); + } + } +}