Repository: assertj/assertj

Problem statement:
Add building block to create custom precise assertions
#### Check List:
* Unit tests : YES
* Javadoc with a code example (on API only) : YES
* PR meets the [contributing guidelines](https://github.com/assertj/assertj-core/blob/main/CONTRIBUTING.md)

The goal is to be able to make precise soft assertions on complex objects (i.e. where we don't need to assert the equality of the full object) and to have easy to read test code and assertion error messages.

Example:

```java
assertThat(customer).is(
    customer(
        name(
            first("Boris"),
            last("Johnson")),
            address(
                firstLine("10, Downing Street"),
                postcode("SW2A 2AA")
         )
    )
)
``` 

and on failure get an error message like:

```
[✗] customer:[
   [✗] name:[
      [✓] first: Boris,
      [✗] last: Johnson but was Johnstone
   ],
   [✗] address:[
      [✓] first line: 10, Downing Street,
      [✗] postcode: SW2A 2AA but was SW1A 2AA
   ]
```

I tried using custom soft assertions, but I found the error message to be less readable (and the implementation more complex).
Maybe there is already another way to achieve this?

Also `NestableCondition` is not a great name :relaxed:.

Looking forward to getting your feedback.

Thanks!


Hints:

Patch:
diff --git a/assertj-core/src/main/java/org/assertj/core/condition/NestableCondition.java b/assertj-core/src/main/java/org/assertj/core/condition/NestableCondition.java
new file mode 100644
--- /dev/null
+++ b/assertj-core/src/main/java/org/assertj/core/condition/NestableCondition.java
@@ -0,0 +1,179 @@
+/*
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on
+ * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations under the License.
+ *
+ * Copyright 2012-2022 the original author or authors.
+ */
+package org.assertj.core.condition;
+
+import static java.util.Arrays.stream;
+import static java.util.stream.Collectors.toList;
+
+import java.util.List;
+import java.util.function.Function;
+import java.util.stream.Stream;
+
+import org.assertj.core.api.Condition;
+import org.assertj.core.description.Description;
+
+/**
+ * Building block to define a precise soft assertion about a complex object.
+ * It allows to create readable assertions and produces beautiful assertion error messages.
+ * <p>
+ * Example:
+ * <pre><code class='java'> class Customer {
+ *    final String name;
+ *    final Address address;
+ *
+ *    Customer(String name, Address address) {
+ *      this.name = name;
+ *      this.address = address;
+ *    }
+ * }
+ *
+ * class Address {
+ *    final String firstLine;
+ *    final String postcode;
+ *
+ *    Address(String firstLine, String postcode) {
+ *      this.firstLine = firstLine;
+ *      this.postcode = postcode;
+ *    }
+ *  }
+ *
+ * static Condition&lt;Customer&gt; name(String expected) {
+ *   return new Condition&lt;&gt;(
+ *      it -> expected.equals(it.name),
+ *      "name: " + expected
+ *   );
+ * }
+ *
+ * static Condition&lt;Customer&gt; customer(Condition&lt;Customer&gt;... conditions) {
+ *   return nestable("person", conditions);
+ * }
+ *
+ * static Condition&lt;Address&gt; firstLine(String expected) {
+ *   return new Condition&lt;&gt;(
+ *      it -> expected.equals(it.firstLine),
+ *      "first line: " + expected
+ *   );
+ * }
+ *
+ * static Condition&lt;Address&gt; postcode(String expected) {
+ *   return new Condition&lt;&gt;(
+ *      it -> expected.equals(it.postcode),
+ *      "postcode: " + expected
+ *   );
+ * }
+ *
+ * static Condition&lt;Customer&gt; address(Condition&lt;Address&gt;... conditions) {
+ *   return nestable(
+ *      "address",
+ *      customer -> customer.address,
+ *      conditions
+ *   );
+ * }</code></pre>
+ *
+ *   And assertions can be written like:
+ * <pre><code class='java'> assertThat(customer).is(
+ *   customer(
+ *     name("John"),
+ *     address(
+ *       firstLine("3"),
+ *       postcode("KM3 8SP")
+ *     )
+ *   )
+ * ); </code></pre>
+ * which leads to an easy-to-read assertion error:
+ * <pre><code class='text'> Expecting actual:
+ *   org.assertj.core.condition.Customer@27ff5d15
+ * to be:
+ *   [✗] person:[
+ *       [✓] name: John,
+ *       [✗] address:[
+ *         [✗] first line: 3,
+ *         [✓] postcode: KM3 8SP
+ *       ]
+ *   ]</code></pre>
+ * For an even better assertion error, see <code>{@link VerboseCondition}</code>.
+ *
+ * @param <ACTUAL> the type of object this condition accepts ({@literal Customer} in the example)
+ * @param <NESTED> the type of object nested into {@literal ACTUAL} ({@literal Address} in the example)
+ *
+ * @author Alessandro Ciccimarra
+ */
+public class NestableCondition<ACTUAL, NESTED> extends Join<ACTUAL> {
+  private final String descriptionPrefix;
+
+  /**
+   * Creates a new <code>{@link NestableCondition}</code>
+   * @param descriptionPrefix the prefix to use to build the description
+   * @param extractor a function to extract the nested object of type {@literal T} from an object fo type {@literal K}
+   * @param conditions conditions to be checked
+   * @return the nestable condition
+   * @param <ACTUAL> the type of object the resulting condition accepts
+   * @param <NESTED> the type of object nested into {@literal K}
+   */
+  @SafeVarargs
+  public static <ACTUAL, NESTED> Condition<ACTUAL> nestable(String descriptionPrefix, Function<ACTUAL, NESTED> extractor,
+                                                            Condition<NESTED>... conditions) {
+    return new NestableCondition<>(descriptionPrefix, stream(conditions), extractor);
+  }
+
+  /**
+   * Creates a new <code>{@link NestableCondition}</code>
+   * @param descriptionPrefix the prefix to use to build the description
+   * @param conditions conditions to be checked
+   * @return the nestable condition
+   * @param <ACTUAL> the type of object the resulting condition accepts
+   */
+  @SafeVarargs
+  public static <ACTUAL> Condition<ACTUAL> nestable(String descriptionPrefix, Condition<ACTUAL>... conditions) {
+    return new NestableCondition<>(descriptionPrefix, stream(conditions));
+  }
+
+  private NestableCondition(String descriptionPrefix, Stream<Condition<NESTED>> conditions, Function<ACTUAL, NESTED> extractor) {
+    super(compose(conditions, extractor));
+    this.descriptionPrefix = descriptionPrefix;
+  }
+
+  private NestableCondition(String descriptionPrefix, Stream<Condition<ACTUAL>> conditions) {
+    super(conditions.collect(toList()));
+    this.descriptionPrefix = descriptionPrefix;
+  }
+
+  @Override
+  public boolean matches(ACTUAL value) {
+    return conditions.stream().allMatch(condition -> condition.matches(value));
+  }
+
+  @Override
+  public String descriptionPrefix() {
+    return descriptionPrefix;
+  }
+
+  private static <ACTUAL, NESTED> List<Condition<ACTUAL>> compose(Stream<Condition<NESTED>> conditions,
+                                                                  Function<ACTUAL, NESTED> extractor) {
+    return conditions.map(condition -> compose(condition, extractor)).collect(toList());
+  }
+
+  private static <ACTUAL, NESTED> Condition<ACTUAL> compose(Condition<NESTED> condition, Function<ACTUAL, NESTED> extractor) {
+    return new Condition<ACTUAL>() {
+      @Override
+      public boolean matches(ACTUAL value) {
+        return condition.matches(extractor.apply(value));
+      }
+
+      @Override
+      public Description conditionDescriptionWithStatus(ACTUAL actual) {
+        return condition.conditionDescriptionWithStatus(extractor.apply(actual));
+      }
+    };
+  }
+}


Test patch:
diff --git a/assertj-core/src/test/java/org/assertj/core/condition/NestableConditionFixtures.java b/assertj-core/src/test/java/org/assertj/core/condition/NestableConditionFixtures.java
new file mode 100644
--- /dev/null
+++ b/assertj-core/src/test/java/org/assertj/core/condition/NestableConditionFixtures.java
@@ -0,0 +1,110 @@
+/*
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on
+ * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations under the License.
+ *
+ * Copyright 2012-2022 the original author or authors.
+ */
+package org.assertj.core.condition;
+
+import static org.assertj.core.condition.NestableCondition.nestable;
+import static org.assertj.core.condition.VerboseCondition.verboseCondition;
+
+import org.assertj.core.api.Condition;
+
+class NestableConditionFixtures {
+  static Condition<Name> first(String expected) {
+    return verboseCondition(name -> expected.equals(name.first),
+                            "first: " + expected,
+                            name -> " but was " + name.first);
+  }
+
+  static Condition<Name> last(String expected) {
+    return verboseCondition(name -> expected.equals(name.last),
+                            "last: " + expected,
+                            name -> " but was " + name.last);
+  }
+
+  static Condition<Address> firstLine(String expected) {
+    return verboseCondition(address -> expected.equals(address.firstLine),
+                            "first line: " + expected,
+                            address -> " but was " + address.firstLine);
+  }
+
+  static Condition<Address> postcode(String expected) {
+    return verboseCondition(address -> expected.equals(address.postcode),
+                            "postcode: " + expected,
+                            address -> " but was " + address.postcode);
+  }
+
+  static Condition<Country> name(String expected) {
+    return verboseCondition(country -> expected.equals(country.name),
+                            "name: " + expected,
+                            country -> " but was " + country.name);
+  }
+
+  @SafeVarargs
+  static Condition<Customer> address(Condition<Address>... conditions) {
+    return nestable("address", customer -> customer.address, conditions);
+  }
+
+  @SafeVarargs
+  static Condition<Customer> name(Condition<Name>... conditions) {
+    return nestable("name", it -> it.name, conditions);
+  }
+
+  @SafeVarargs
+  static Condition<Customer> customer(Condition<Customer>... conditions) {
+    return nestable("customer", conditions);
+  }
+
+  @SafeVarargs
+  static Condition<Address> country(Condition<Country>... conditions) {
+    return nestable("country", address -> address.country, conditions);
+  }
+}
+
+class Customer {
+  final Name name;
+  final Address address;
+
+  Customer(Name name, Address address) {
+    this.name = name;
+    this.address = address;
+  }
+}
+
+class Name {
+  final String first;
+  final String last;
+
+  Name(String first, String last) {
+    this.first = first;
+    this.last = last;
+  }
+}
+
+class Address {
+  final String firstLine;
+  final String postcode;
+  final Country country;
+
+  Address(String firstLine, String postcode, Country country) {
+    this.firstLine = firstLine;
+    this.postcode = postcode;
+    this.country = country;
+  }
+}
+
+class Country {
+  final String name;
+
+  Country(String name) {
+    this.name = name;
+  }
+}
diff --git a/assertj-core/src/test/java/org/assertj/core/condition/NestableCondition_assertionMessage_Test.java b/assertj-core/src/test/java/org/assertj/core/condition/NestableCondition_assertionMessage_Test.java
new file mode 100644
--- /dev/null
+++ b/assertj-core/src/test/java/org/assertj/core/condition/NestableCondition_assertionMessage_Test.java
@@ -0,0 +1,88 @@
+/*
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on
+ * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations under the License.
+ *
+ * Copyright 2012-2022 the original author or authors.
+ */
+package org.assertj.core.condition;
+
+import static java.lang.String.format;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.BDDAssertions.then;
+import static org.assertj.core.condition.NestableConditionFixtures.address;
+import static org.assertj.core.condition.NestableConditionFixtures.country;
+import static org.assertj.core.condition.NestableConditionFixtures.customer;
+import static org.assertj.core.condition.NestableConditionFixtures.first;
+import static org.assertj.core.condition.NestableConditionFixtures.firstLine;
+import static org.assertj.core.condition.NestableConditionFixtures.last;
+import static org.assertj.core.condition.NestableConditionFixtures.name;
+import static org.assertj.core.condition.NestableConditionFixtures.postcode;
+import static org.assertj.core.util.AssertionsUtil.expectAssertionError;
+
+import org.assertj.core.api.Condition;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Tests for <code>{@link NestableCondition#toString()}</code>.
+ * 
+ * @author Alessandro Ciccimarra
+ */
+class NestableCondition_assertionMessage_Test {
+  private final Customer boris = new Customer(new Name("Boris", "Johnson"),
+                                              new Address("10, Downing Street",
+                                                          "SW1A 2AA",
+                                                          new Country("United Kingdom")));
+
+  @Test
+  void should_show_correct_error_message_with_two_nested_objects() {
+    // GIVEN
+    Condition<Customer> condition = customer(
+                                             name(
+                                                  first("Boris"),
+                                                  last("Johnson")),
+                                             address(
+                                                     firstLine("10, Downing Street"),
+                                                     postcode("SW2A 2AA")));
+    // WHEN
+    AssertionError assertionError = expectAssertionError(() -> assertThat(boris).is(condition));
+
+    // THEN
+    then(assertionError).hasMessageContaining(format("[✗] customer:[%n" +
+                                                     "   [✓] name:[%n" +
+                                                     "      [✓] first: Boris,%n" +
+                                                     "      [✓] last: Johnson%n" +
+                                                     "   ],%n" +
+                                                     "   [✗] address:[%n" +
+                                                     "      [✓] first line: 10, Downing Street,%n" +
+                                                     "      [✗] postcode: SW2A 2AA but was SW1A 2AA%n" +
+                                                     "   ]%n" +
+                                                     "]"));
+  }
+
+  @Test
+  void should_show_correct_error_message_with_two_levels_of_nesting() {
+    // GIVEN
+    Condition<Customer> condition = customer(
+                                             address(
+                                                     firstLine("10, Downing Street"),
+                                                     country(name("Gibraltar"))));
+    // WHEN
+    AssertionError assertionError = expectAssertionError(() -> assertThat(boris).is(condition));
+
+    // THEN
+    then(assertionError).hasMessageContaining(format("[✗] customer:[%n" +
+                                                       "   [✗] address:[%n" +
+                                                       "      [✓] first line: 10, Downing Street,%n" +
+                                                       "      [✗] country:[%n" +
+                                                       "         [✗] name: Gibraltar but was United Kingdom%n" +
+                                                       "      ]%n" +
+                                                       "   ]%n" +
+                                                       "]"));
+  }
+}
diff --git a/assertj-core/src/test/java/org/assertj/core/condition/NestableCondition_matches_Test.java b/assertj-core/src/test/java/org/assertj/core/condition/NestableCondition_matches_Test.java
new file mode 100644
--- /dev/null
+++ b/assertj-core/src/test/java/org/assertj/core/condition/NestableCondition_matches_Test.java
@@ -0,0 +1,78 @@
+/*
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on
+ * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations under the License.
+ *
+ * Copyright 2012-2022 the original author or authors.
+ */
+package org.assertj.core.condition;
+
+import static org.assertj.core.api.BDDAssertions.then;
+import static org.assertj.core.condition.NestableConditionFixtures.address;
+import static org.assertj.core.condition.NestableConditionFixtures.customer;
+import static org.assertj.core.condition.NestableConditionFixtures.first;
+import static org.assertj.core.condition.NestableConditionFixtures.firstLine;
+import static org.assertj.core.condition.NestableConditionFixtures.name;
+import static org.assertj.core.condition.NestableConditionFixtures.postcode;
+
+import org.assertj.core.api.Condition;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Tests for <code>{@link NestableCondition#matches(Object)}</code>.
+ * 
+ * @author Alessandro Ciccimarra
+ */
+class NestableCondition_matches_Test {
+  private final Customer boris = new Customer(new Name("Boris", "Johnson"),
+                                              new Address("10, Downing Street",
+                                                          "SW1A 2AA",
+                                                          new Country("United Kingdom")));
+
+  @Test
+  void should_match_if_all_conditions_match() {
+    // GIVEN
+    Condition<Customer> condition = customer(
+                                             name(
+                                                  first("Boris")),
+                                             address(
+                                                     firstLine("10, Downing Street"),
+                                                     postcode("SW1A 2AA")
+                                             ));
+    // THEN
+    then(condition.matches(boris)).isTrue();
+  }
+
+  @Test
+  void should_not_match_if_any_condition_at_top_level_does_not_match() {
+    // GIVEN
+    Condition<Customer> condition = customer(
+                                             name(
+                                                  first("Matt")),
+                                             address(
+                                                     firstLine("10, Downing Street"),
+                                                     postcode("SW1A 2AA")));
+
+    // THEN
+    then(condition.matches(boris)).isFalse();
+  }
+
+  @Test
+  void should_not_match_if_any_condition_in_nested_level_does_not_match() {
+    // GIVEN
+    Condition<Customer> condition = customer(
+                                             name(
+                                                  first("Boris")),
+                                             address(
+                                                     firstLine("11, Downing Street"),
+                                                     postcode("SW1A 2AA")));
+
+    // THEN
+    then(condition.matches(boris)).isFalse();
+  }
+}