This is an automated email from the ASF dual-hosted git repository.
jamesbognar pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/juneau.git
The following commit(s) were added to refs/heads/master by this push:
new 1c68d340c Test modernization
1c68d340c is described below
commit 1c68d340c64750b84a37e2fcda28cbb964c0ce72
Author: James Bognar <[email protected]>
AuthorDate: Wed Sep 10 08:38:01 2025 -0400
Test modernization
---
.../java/org/apache/juneau/NestedTokenizer.java | 253 ++++++++
.../src/test/java/org/apache/juneau/TestUtils.java | 174 +-----
.../juneau/html/HtmlDocConfigAnnotation_Test.java | 2 +-
.../org/apache/juneau/junit/AssertionArgs.java | 264 ++++++++
.../apache/juneau/junit/AssertionArgs_Test.java | 508 +++++++++++++++
.../java/org/apache/juneau/junit/Assertions2.java | 696 +++++++++++++++++++++
.../test/java/org/apache/juneau/junit/Utils.java | 114 +++-
.../apache/juneau/objecttools/ObjectRest_Test.java | 41 +-
8 files changed, 1886 insertions(+), 166 deletions(-)
diff --git a/juneau-utest/src/test/java/org/apache/juneau/NestedTokenizer.java
b/juneau-utest/src/test/java/org/apache/juneau/NestedTokenizer.java
new file mode 100644
index 000000000..8cec94e8e
--- /dev/null
+++ b/juneau-utest/src/test/java/org/apache/juneau/NestedTokenizer.java
@@ -0,0 +1,253 @@
+package org.apache.juneau;
+
+import static org.apache.juneau.NestedTokenizer.ParseState.*;
+import static java.util.Collections.*;
+import static java.util.stream.Collectors.*;
+
+import java.util.*;
+import java.util.function.*;
+
+/**
+ * Splits a nested comma-delimited string into a list of Token objects using a
state machine parser.
+ *
+ * <p>This class parses complex nested structures with support for escaping
and arbitrary nesting depth.
+ * The parser uses a finite state machine to handle different contexts during
parsing.</p>
+ *
+ * <h5 class='section'>Supported Syntax:</h5>
+ * <ul>
+ * <li><js>"foo"</js> - Single value token</li>
+ * <li><js>"foo,bar"</js> - Multiple value tokens</li>
+ * <li><js>"foo{a,b},bar"</js> - Token with nested values</li>
+ * <li><js>"foo{a{a1,a2}},bar"</js> - Recursively nested values</li>
+ * <li><js>"foo\\,bar"</js> - Escaped comma in value</li>
+ * <li><js>"foo\\{bar\\}"</js> - Escaped braces in value</li>
+ * </ul>
+ *
+ * <h5 class='section'>State Machine:</h5>
+ * <p>The parser operates in several states:</p>
+ * <ul>
+ * <li><b>PARSING_VALUE:</b> Reading a token value</li>
+ * <li><b>PARSING_NESTED:</b> Reading nested content within braces</li>
+ * <li><b>IN_ESCAPE:</b> Processing escaped character</li>
+ * </ul>
+ *
+ * <h5 class='section'>Usage Examples:</h5>
+ * <p class='bjava'>
+ * <jc>// Simple tokens</jc>
+ * var tokens = NestedTokenizer.splitNested(<js>"foo,bar,baz"</js>);
+ * <jc>// tokens = [Token{value="foo"}, Token{value="bar"},
Token{value="baz"}]</jc>
+ *
+ * <jc>// Nested tokens</jc>
+ * var nested =
NestedTokenizer.splitNested(<js>"user{name,email},config{timeout,retries}"</js>);
+ * <jc>// nested[0] = Token{value="user", nested=[Token{value="name"},
Token{value="email"}]}</jc>
+ * <jc>// nested[1] = Token{value="config",
nested=[Token{value="timeout"}, Token{value="retries"}]}</jc>
+ * </p>
+ */
+public class NestedTokenizer {
+
+ /**
+ * Parser states for the finite state machine.
+ */
+ enum ParseState {
+ /** Parsing a token value outside of nested braces */
+ PARSING_VALUE,
+ /** Parsing nested content within braces */
+ PARSING_NESTED,
+ /** Processing an escaped character */
+ IN_ESCAPE
+ }
+
+ public static List<Token> tokenize(String in) {
+ if (in == null) throw new IllegalArgumentException("Input was
null.");
+ if (in.isBlank()) throw new IllegalArgumentException("Input was
empty.");
+
+ var length = in.length();
+ var pos = 0;
+ var state = PARSING_VALUE;
+ var currentValue = new StringBuilder();
+ var nestedDepth = 0;
+ var nestedStart = -1;
+ var tokens = new ArrayList<Token>();
+ var lastWasComma = false;
+ var justCompletedNested = false;
+
+ while (pos < length) {
+ var c = in.charAt(pos);
+
+ if (state == PARSING_VALUE) {
+ if (c == '\\') {
+ state = IN_ESCAPE;
+ } else if (c == ',') {
+ var value =
currentValue.toString().trim();
+ // Add token unless it's empty and we
just completed a nested token
+ if (!value.isEmpty() ||
tokens.isEmpty() || !justCompletedNested) {
+ tokens.add(new Token(value));
+ }
+ currentValue.setLength(0);
+ nestedStart = -1;
+ lastWasComma = true;
+ justCompletedNested = false;
+ pos = skipWhitespace(in, pos);
+ } else if (c == '{') {
+ nestedStart = pos + 1;
+ nestedDepth = 1;
+ state = PARSING_NESTED;
+ } else {
+ currentValue.append(c);
+ lastWasComma = false;
+ justCompletedNested = false;
+ }
+ } else if (state == PARSING_NESTED) {
+ if (c == '\\') {
+ state = IN_ESCAPE;
+ } else if (c == '{') {
+ nestedDepth++;
+ } else if (c == '}') {
+ nestedDepth--;
+ if (nestedDepth == 0) {
+ var value =
currentValue.toString().trim();
+ var nestedContent =
in.substring(nestedStart, pos);
+ var token = new Token(value);
+ if
(!nestedContent.trim().isEmpty()) {
+
token.setNested(tokenize(nestedContent));
+ }
+ tokens.add(token);
+ currentValue.setLength(0);
+ nestedStart = -1;
+ lastWasComma = false; // Reset
since we've completed a token
+ justCompletedNested = true; //
Flag that we just completed a nested token
+ pos = skipWhitespace(in, pos);
+ state = PARSING_VALUE;
+ }
+ }
+ } else if (state == IN_ESCAPE) {
+ // Add the escaped character to current value
only if we're parsing the main token value
+ if (nestedDepth == 0) {
+ currentValue.append(c);
+ }
+ state = (nestedDepth > 0) ? PARSING_NESTED :
PARSING_VALUE;
+ }
+
+ pos++;
+ }
+
+ // Add final token if we have content, or if input ended with
comma, or if no tokens yet
+ var finalValue = currentValue.toString().trim();
+ if (!finalValue.isEmpty() || lastWasComma || tokens.isEmpty()) {
+ tokens.add(new Token(finalValue));
+ }
+
+ return tokens;
+ }
+
+ private static int skipWhitespace(String input, int position) {
+ var length = input.length();
+ while (position + 1 < length &&
Character.isWhitespace(input.charAt(position + 1))) {
+ position++;
+ }
+ return position;
+ }
+
+ /**
+ * Represents a parsed token with optional nested sub-tokens.
+ *
+ * <p>A Token contains a string value and may have nested tokens
representing
+ * the content inside braces. Tokens support deep nesting for complex
hierarchical structures.</p>
+ *
+ * <h5 class='section'>Structure:</h5>
+ * <ul>
+ * <li><b>value:</b> The main token value (part before any
braces)</li>
+ * <li><b>nested:</b> Optional list of nested tokens (content
within braces)</li>
+ * </ul>
+ *
+ * <h5 class='section'>Examples:</h5>
+ * <ul>
+ * <li><js>"foo"</js> → <js>Token{value="foo",
nested=null}</js></li>
+ * <li><js>"foo{a,b}"</js> → <js>Token{value="foo",
nested=[Token{value="a"}, Token{value="b"}]}</js></li>
+ * </ul>
+ */
+ public static class Token {
+
+ /** The main value of this token */
+ private final String value;
+
+ /** Nested tokens if this token has braced content, null
otherwise */
+ private List<Token> nested;
+
+ /**
+ * Creates a new token with the specified value.
+ *
+ * @param value The token value
+ */
+ public Token(String value) {
+ this.value = value != null ? value : "";
+ }
+
+ /**
+ * Returns the main value of this token.
+ *
+ * @return The token value
+ */
+ public String getValue() {
+ return value;
+ }
+
+ /**
+ * Returns true if this token has nested content.
+ *
+ * @return true if nested tokens exist
+ */
+ public boolean hasNested() {
+ return nested != null && !nested.isEmpty();
+ }
+
+ /**
+ * Returns an unmodifiable view of the nested tokens.
+ *
+ * @return unmodifiable list of nested tokens, or empty list if
none
+ */
+ public List<Token> getNested() {
+ return nested != null ? unmodifiableList(nested) :
emptyList();
+ }
+
+ /**
+ * Sets the nested tokens for this token (package-private for
tokenizer use).
+ *
+ * @param nested The list of nested tokens
+ */
+ void setNested(List<Token> nested) {
+ this.nested = nested;
+ }
+
+ @Override
+ public String toString() {
+ return hasNested() ?
nested.stream().map(Object::toString).collect(joining(",",value + "{","}")) :
value;
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ return (o instanceof Token o2) && eq(this, o2,
(x,y)->x.value.equals(y.value) && eq(x.nested, y.nested));
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(value, nested);
+ }
+ }
+
+
//---------------------------------------------------------------------------------------------
+ // Helper methods.
+
//---------------------------------------------------------------------------------------------
+
+ private static <T,U> boolean eq(T o1, U o2, BiPredicate<T,U> test) {
+ if (o1 == null) { return o2 == null; }
+ if (o2 == null) { return false; }
+ if (o1 == o2) { return true; }
+ return test.test(o1, o2);
+ }
+
+ @SuppressWarnings("unlikely-arg-type")
+ private static <T,U> boolean eq(T o1, U o2) {
+ return Objects.equals(o1, o2);
+ }
+}
diff --git a/juneau-utest/src/test/java/org/apache/juneau/TestUtils.java
b/juneau-utest/src/test/java/org/apache/juneau/TestUtils.java
index 1f6f06e84..7344e6e1a 100644
--- a/juneau-utest/src/test/java/org/apache/juneau/TestUtils.java
+++ b/juneau-utest/src/test/java/org/apache/juneau/TestUtils.java
@@ -34,7 +34,6 @@ import org.apache.juneau.rest.mock.*;
import org.apache.juneau.serializer.*;
import org.apache.juneau.xml.*;
import org.junit.jupiter.api.*;
-import org.opentest4j.*;
/**
* Comprehensive utility class for Bean-Centric Tests (BCT) and general
testing operations.
@@ -314,14 +313,7 @@ public class TestUtils extends Utils2 {
* @see BasicBeanConverter
*/
public static void assertBean(Object actual, String fields, String
expected) {
- assertBean(BasicBeanConverter.DEFAULT, actual, fields,
expected);
- }
-
- public static void assertBean(BeanConverter converter, Object actual,
String fields, String expected) {
- assertNotNull(actual, "Value was null.");
- assertArgNotNull("fields", fields);
- assertArgNotNull("expected", expected);
- assertEquals(expected, tokenize(fields).stream().map(x ->
converter.getNested(actual, x)).collect(joining(",")));
+ Assertions2.assertBean(actual, fields, expected);
}
private static List<NestedTokenizer.Token> tokenize(String fields) {
@@ -386,70 +378,9 @@ public class TestUtils extends Utils2 {
* @see #assertBean(Object, String, String)
*/
public static void assertBeans(Object actual, String fields,
String...expected) {
- assertBeans(BasicBeanConverter.DEFAULT, actual, fields,
expected);
+ Assertions2.assertBeans(actual, fields, expected);
}
- public static void assertBeans(BasicBeanConverter converter, Object
actual, String fields, String...expected) {
- assertNotNull(actual, "Value was null.");
- assertArgNotNull("fields", fields);
- assertArgNotNull("expected", expected);
-
- var tokens = tokenize(fields);
- var errors = new ArrayList<AssertionFailedError>();
- var actualList = converter.listify(actual);
-
- if (ne(expected.length, actualList.size())) {
- errors.add(assertionFailed(expected.length,
actualList.size(), "Wrong number of beans."));
- } else {
- for (var i = 0; i < actualList.size(); i++) {
- var i2 = i;
- var a = tokens.stream().map(x ->
converter.getNested(actualList.get(i2), x)).collect(joining(","));
- if (ne(r(expected[i]), a)) {
-
errors.add(assertionFailed(r(expected[i]), a, "Bean at row {0} did not match.",
i));
- }
- }
- }
-
- if (errors.isEmpty()) return;
-
- var actualStrings = new ArrayList<String>();
- for (var o : actualList) {
- actualStrings.add(tokens.stream().map(x ->
converter.getNested(o, x)).collect(joining(",")));
- }
-
- if (errors.size() == 1) throw errors.get(0);
- throw assertionFailed(
-
Stream.of(expected).map(TestUtils::escapeForJava).collect(joining("\", \"",
"\"", "\"")),
-
actualStrings.stream().map(TestUtils::escapeForJava).collect(joining("\", \"",
"\"", "\"")),
- "{0} bean assertions failed: {1}", errors.size(),
errors.stream().map(x -> x.getMessage()).collect(joining("\n"))
- );
- }
-
- private static AssertionFailedError assertionFailed(Object expected,
Object actual, String message, Object...args) {
- return new AssertionFailedError(f(message, args) + f(" ==>
expected: <{0}> but was: <{1}>", expected, actual), expected, actual);
- }
-
- private static String escapeForJava(String s) {
- StringBuilder sb = new StringBuilder();
- for (char c : s.toCharArray()) {
- switch (c) {
- case '\"': sb.append("\\\""); break;
- case '\\': sb.append("\\\\"); break;
- case '\n': sb.append("\\n"); break;
- case '\r': sb.append("\\r"); break;
- case '\t': sb.append("\\t"); break;
- case '\f': sb.append("\\f"); break;
- case '\b': sb.append("\\b"); break;
- default:
- if (c < 0x20 || c > 0x7E) {
-
sb.append(String.format("\\u%04x", (int)c));
- } else {
- sb.append(c);
- }
- }
- }
- return sb.toString();
- }
/**
* Asserts that a List contains the expected values using flexible
comparison logic.
@@ -508,41 +439,14 @@ public class TestUtils extends Utils2 {
* @see #l(Object) for converting other collection types to Lists
*/
public static <T> void assertList(Object actual, Object...expected) {
- assertList(BasicBeanConverter.DEFAULT, actual, expected);
- }
-
- @SuppressWarnings("unchecked")
- public static <T> void assertList(BeanConverter converter, Object
actual, Object...expected) {
- assertNotNull(actual, "Value was null.");
- assertArgNotNull("expected", expected);
-
- var list = converter.listify(actual);
- assertEquals(expected.length, list.size(), fms("Wrong list
length. expected={0}, actual={1}", expected.length, list.size()));
-
- for (var i = 0; i < expected.length; i++) {
- var x = list.get(i);
- if (expected[i] instanceof String e) {
- assertEquals(e, converter.stringify(x),
fms("Element at index {0} did not match. expected={1}, actual={2}", i, e,
converter.stringify(x)));
- } else if (expected[i] instanceof Predicate e) {
- assertTrue(e.test(x), fms("Element at index {0}
did pass predicate. actual={1}", i, converter.stringify(x)));
- } else {
- assertEquals(expected[i], x, fms("Element at
index {0} did not match. expected={1}({2}), actual={3}(4)", i, expected[i],
t(expected[i]), x, t(x)));
- }
- }
- }
-
- private static String t(Object o) {
- return o == null ? null : o.getClass().getSimpleName();
+ Assertions2.assertList(actual, expected);
}
/**
* Asserts an object matches the expected string after it's been made
{@link Utils#r readable}.
*/
public static void assertContains(String expected, Object actual) {
- assertArgNotNull("expected", expected);
- assertNotNull(actual, "Value was null.");
- var a2 = r(actual);
- assertTrue(a2.contains(expected), fms("String did not contain
expected substring. expected={0}, actual={1}", expected, a2));
+ Assertions2.assertContains(expected, actual);
}
/**
@@ -551,33 +455,15 @@ public class TestUtils extends Utils2 {
* @param expected
* @param actual
*/
- public static void assertContainsAll(String expected, Object actual) {
- assertArgNotNull("expected", expected);
- assertNotNull(actual, "Value was null.");
- var a2 = r(actual);
- for (var e : splita(expected))
- assertTrue(a2.contains(e), fms("String did not contain
expected substring. expected={0}, actual={1}", e, a2));
+ public static void assertContainsAll(Object actual, String...expected) {
+ Assertions2.assertContainsAll(actual, expected);
}
/**
* Asserts that a collection is not null and empty.
*/
public static void assertEmpty(Object value) {
- if (value instanceof Optional v2) {
- assertTrue(v2.isEmpty(), "Optional was not empty");
- return;
- }
- if (value instanceof Map v2) {
- assertTrue(v2.isEmpty(), "Map was not empty");
- return;
- }
- assertEmpty(BasicBeanConverter.DEFAULT, value);
- }
-
- public static void assertEmpty(BasicBeanConverter converter, Object
value) {
- assertNotNull(value, "Value was null.");
- assertTrue(converter.canListify(value), fms("Value cannot be
converted to a list. Class={0}", value.getClass().getSimpleName()));
- assertTrue(converter.listify(value).isEmpty(), "Value was not
empty.");
+ Assertions2.assertEmpty(value);
}
public static void assertEqualsAll(Object...values) {
@@ -656,14 +542,7 @@ public class TestUtils extends Utils2 {
* @see BasicBeanConverter
*/
public static void assertMap(Map<?,?> actual, String fields, String
expected) {
- assertMap(BasicBeanConverter.DEFAULT, actual, fields, expected);
- }
-
- public static void assertMap(BeanConverter converter, Map<?,?> actual,
String fields, String expected) {
- assertNotNull(actual, "Value was null.");
- assertArgNotNull("fields", fields);
- assertArgNotNull("expected", expected);
- assertEquals(expected, tokenize(fields).stream().map(x ->
converter.getNested(actual, x)).collect(joining(",")));
+ Assertions2.assertBean(actual, fields, expected);
}
/**
@@ -693,24 +572,14 @@ public class TestUtils extends Utils2 {
* @see BasicBeanConverter
*/
public static <T> void assertMapped(T actual,
BiFunction<T,String,Object> f, String properties, String expected) {
- assertNotNull(actual, "Value was null.");
- var m = new LinkedHashMap<String,Object>();
- for (var p : split(properties)) {
- try {
- m.put(p, f.apply(actual, p));
- } catch (Exception e) {
- m.put(p, simpleClassNameOf(e));
- }
- }
- assertMap(m, properties, expected);
+ Assertions2.assertMapped(actual, f, properties, expected);
}
/**
* Asserts that a collection is not null and not empty.
*/
public static void assertNotEmpty(Object value) {
- assertNotNull(value, "Value was null.");
- assertFalse(toList(value).isEmpty(), "Value was empty.");
+ Assertions2.assertNotEmpty(value);
}
public static void assertNotEqualsAny(Object actual, Object...values) {
@@ -753,41 +622,28 @@ public class TestUtils extends Utils2 {
* @throws AssertionError if the object is null or not the expected
size.
*/
public static void assertSize(int expected, Object actual) {
- assertNotNull(actual, "Value was null.");
- if (actual instanceof String a2) {
- assertEquals(expected, a2.length(), fms("Value not
expected size. Expected: {0}, Actual: {1}, Value: {2}", expected, a2.length(),
a2));
- return;
- }
- assertEquals(expected, toList(actual).size(), fms("Value not
expected size. Expected: {0}, Actual: {1}", expected, toList(actual).size()));
+ Assertions2.assertSize(expected, actual);
}
/**
* Asserts an object matches the expected string after it's been made
{@link Utils#r readable}.
*/
public static void assertString(String expected, Object actual) {
- assertNotNull(actual, "Value was null.");
- assertEquals(expected, r(actual));
+ Assertions2.assertString(expected, actual);
}
/**
* Asserts value when stringified matches the specified pattern.
*/
- public static Object assertMatches(String pattern, Object value) {
- var m = getMatchPattern3(pattern).matcher(s(value));
- if (! m.matches()) {
- var msg = "Pattern didn't match:
\n\tExpected:\n"+pattern+"\n\tActual:\n"+value;
- System.err.println(msg); // For easier debugging.
- fail(msg);
- }
- return value;
+ public static void assertMatches(String pattern, Object value) {
+ Assertions2.assertMatches(pattern, value);
}
/**
* Asserts an object matches the expected string after it's been made
{@link Utils#r readable}.
*/
public static void assertString(String expected, Object actual,
Supplier<String> messageSupplier) {
- assertNotNull(actual, "Value was null.");
- assertEquals(expected, r(actual), messageSupplier);
+ Assertions2.assertString(expected, actual);
}
public static <T extends Throwable> T assertThrowable(Class<? extends
Throwable> expectedType, String expectedSubstring, T t) {
diff --git
a/juneau-utest/src/test/java/org/apache/juneau/html/HtmlDocConfigAnnotation_Test.java
b/juneau-utest/src/test/java/org/apache/juneau/html/HtmlDocConfigAnnotation_Test.java
index 1ae77f1d9..6bc18ef9e 100644
---
a/juneau-utest/src/test/java/org/apache/juneau/html/HtmlDocConfigAnnotation_Test.java
+++
b/juneau-utest/src/test/java/org/apache/juneau/html/HtmlDocConfigAnnotation_Test.java
@@ -314,7 +314,7 @@ class HtmlDocConfigAnnotation_Test extends SimpleTestBase {
var al = AnnotationWorkList.of(sr, e.getAnnotationList());
var x =
HtmlDocSerializer.create().apply(al).build().getSession();
var r = x.serialize(null).replaceAll("[\r\n]+", "|");
-
assertContainsAll("<aside>xxx</aside>,<footer>xxx</footer>,<head>xxx,<style>@import
\"xxx\"; xxx zzz</style>,<nav><ol><li>xxx</li></ol>xxx</nav>,<script>xxx|
yyy|</script>", r);
+ assertContainsAll(r,
"<aside>xxx</aside>","<footer>xxx</footer>","<head>xxx","<style>@import
\"xxx\"; xxx zzz</style>","<nav><ol><li>xxx</li></ol>xxx</nav>","<script>xxx|
yyy|</script>");
}
//-----------------------------------------------------------------------------------------------------------------
diff --git
a/juneau-utest/src/test/java/org/apache/juneau/junit/AssertionArgs.java
b/juneau-utest/src/test/java/org/apache/juneau/junit/AssertionArgs.java
new file mode 100644
index 000000000..cdf2a1705
--- /dev/null
+++ b/juneau-utest/src/test/java/org/apache/juneau/junit/AssertionArgs.java
@@ -0,0 +1,264 @@
+//
***************************************************************************************************************************
+// * Licensed to the Apache Software Foundation (ASF) under one or more
contributor license agreements. See the NOTICE file *
+// * distributed with this work for additional information regarding copyright
ownership. The ASF licenses this file *
+// * to you 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. *
+//
***************************************************************************************************************************
+package org.apache.juneau.junit;
+
+import static java.util.Optional.*;
+import static org.apache.juneau.junit.Utils.*;
+
+import java.text.*;
+import java.util.*;
+import java.util.function.*;
+
+/**
+ * Configuration and context object for advanced assertion operations.
+ *
+ * <p>This class encapsulates additional arguments and configuration options
for assertion methods
+ * in the Bean-Centric Testing (BCT) framework. It provides a fluent API for
customizing assertion
+ * behavior including custom converters and enhanced error messaging.</p>
+ *
+ * <p>The primary purposes of this class are:</p>
+ * <ul>
+ * <li><b>Custom Bean Conversion:</b> Override the default {@link
BeanConverter} for specialized object introspection</li>
+ * <li><b>Enhanced Error Messages:</b> Add context-specific error messages
with parameter substitution</li>
+ * <li><b>Fluent Configuration:</b> Chain configuration calls for readable
test setup</li>
+ * <li><b>Assertion Context:</b> Provide additional context for complex
assertion scenarios</li>
+ * </ul>
+ *
+ * <h5 class='section'>Basic Usage:</h5>
+ * <p class='bjava'>
+ * <jc>// Simple usage with default settings</jc>
+ * assertBean(args(), myBean, <js>"name,age"</js>, <js>"John,30"</js>);
+ *
+ * <jc>// Custom error message</jc>
+ * assertBean(args().setMessage(<js>"User validation failed"</js>),
+ * user, <js>"email,active"</js>, <js>"[email protected],true"</js>);
+ * </p>
+ *
+ * <h5 class='section'>Custom Bean Converter:</h5>
+ * <p class='bjava'>
+ * <jc>// Use custom converter for specialized object handling</jc>
+ * var customConverter = BasicBeanConverter.builder()
+ * .addStringifier(MyClass.class, obj -> obj.getDisplayName())
+ * .build();
+ *
+ * assertBean(args().setBeanConverter(customConverter),
+ * myCustomObject, <js>"property"</js>, <js>"expectedValue"</js>);
+ * </p>
+ *
+ * <h5 class='section'>Advanced Error Messages:</h5>
+ * <p class='bjava'>
+ * <jc>// Parameterized error messages</jc>
+ * assertBean(args().setMessage(<js>"Validation failed for user {0}"</js>,
userId),
+ * user, <js>"status"</js>, <js>"ACTIVE"</js>);
+ *
+ * <jc>// Dynamic error message with supplier</jc>
+ * assertBean(args().setMessage(() -> <js>"Test failed at "</js> +
Instant.now()),
+ * result, <js>"success"</js>, <js>"true"</js>);
+ * </p>
+ *
+ * <h5 class='section'>Fluent Configuration:</h5>
+ * <p class='bjava'>
+ * <jc>// Chain multiple configuration options</jc>
+ * var testArgs = args()
+ * .setBeanConverter(customConverter)
+ * .setMessage(<js>"Integration test failed for module {0}"</js>,
moduleName);
+ *
+ * assertBean(testArgs, moduleConfig, <js>"enabled,version"</js>,
<js>"true,2.1.0"</js>);
+ * assertBeans(testArgs, moduleList, <js>"name,status"</js>,
+ * <js>"ModuleA,ACTIVE"</js>, <js>"ModuleB,ACTIVE"</js>);
+ * </p>
+ *
+ * <h5 class='section'>Error Message Composition:</h5>
+ * <p>When assertion failures occur, error messages are intelligently
composed:</p>
+ * <ul>
+ * <li><b>Base Message:</b> Custom message set via {@link
#setMessage(String, Object)} or {@link #setMessage(Supplier)}</li>
+ * <li><b>Assertion Context:</b> Specific context provided by individual
assertion methods</li>
+ * <li><b>Composite Format:</b> <js>"{base message}, Caused by: {assertion
context}"</js></li>
+ * </ul>
+ *
+ * <p class='bjava'>
+ * <jc>// Example error message composition:</jc>
+ * <jc>// Base: "User validation failed for user 123"</jc>
+ * <jc>// Context: "Bean assertion failed."</jc>
+ * <jc>// Result: "User validation failed for user 123, Caused by: Bean
assertion failed."</jc>
+ * </p>
+ *
+ * <h5 class='section'>Thread Safety:</h5>
+ * <p>This class is <b>not thread-safe</b> and is intended for single-threaded
test execution.
+ * Each test method should create its own instance using {@link
Assertions2#args()} or create
+ * a new instance directly with {@code new AssertionArgs()}.</p>
+ *
+ * <h5 class='section'>Immutability Considerations:</h5>
+ * <p>While this class uses fluent setters that return {@code this} for
chaining, the instance
+ * is mutable. For reusable configurations across multiple tests, consider
creating a factory
+ * method that returns pre-configured instances.</p>
+ *
+ * @see Assertions2#args()
+ * @see BeanConverter
+ * @see BasicBeanConverter
+ */
+public class AssertionArgs {
+
+ private BeanConverter beanConverter;
+ private Supplier<String> messageSupplier;
+
+ /**
+ * Creates a new instance with default settings.
+ *
+ * <p>Instances start with no custom bean converter and no custom error
message.
+ * All assertion methods will use default behavior until configured
otherwise.</p>
+ */
+ public AssertionArgs() { /* no-op */ }
+
+ /**
+ * Sets a custom {@link BeanConverter} for object introspection and
property access.
+ *
+ * <p>The custom converter allows fine-tuned control over how objects
are converted to strings,
+ * how collections are listified, and how nested properties are
accessed. This is particularly
+ * useful for:</p>
+ * <ul>
+ * <li><b>Custom Object Types:</b> Objects that don't follow
standard JavaBean patterns</li>
+ * <li><b>Specialized Formatting:</b> Custom string representations
for assertion comparisons</li>
+ * <li><b>Performance Optimization:</b> Cached or optimized
property access strategies</li>
+ * <li><b>Domain-Specific Logic:</b> Business-specific property
resolution rules</li>
+ * </ul>
+ *
+ * <h5 class='section'>Example:</h5>
+ * <p class='bjava'>
+ * <jc>// Create converter with custom stringifiers</jc>
+ * var converter = BasicBeanConverter.builder()
+ * .addStringifier(LocalDate.class, date ->
date.format(DateTimeFormatter.ISO_LOCAL_DATE))
+ * .addStringifier(Money.class, money ->
money.getAmount().toPlainString())
+ * .build();
+ *
+ * <jc>// Use in assertions</jc>
+ * assertBean(args().setBeanConverter(converter),
+ * order, <js>"date,total"</js>, <js>"2023-12-01,99.99"</js>);
+ * </p>
+ *
+ * @param value The custom bean converter to use. If null, assertions
will fall back to the default converter.
+ * @return This instance for method chaining.
+ */
+ public AssertionArgs setBeanConverter(BeanConverter value) {
+ beanConverter = value;
+ return this;
+ }
+
+ /**
+ * Gets the configured bean converter, if any.
+ *
+ * @return An Optional containing the custom converter, or empty if
using default behavior.
+ */
+ protected Optional<BeanConverter> getBeanConverter() {
+ return ofNullable(beanConverter);
+ }
+
+ /**
+ * Sets a custom error message supplier for assertion failures.
+ *
+ * <p>The supplier allows for dynamic message generation, including
context that may only
+ * be available at the time of assertion failure. This is useful
for:</p>
+ * <ul>
+ * <li><b>Timestamps:</b> Including the exact time of failure</li>
+ * <li><b>Test State:</b> Including runtime state information</li>
+ * <li><b>Expensive Operations:</b> Deferring costly string
operations until needed</li>
+ * <li><b>Conditional Messages:</b> Different messages based on
runtime conditions</li>
+ * </ul>
+ *
+ * <h5 class='section'>Example:</h5>
+ * <p class='bjava'>
+ * <jc>// Dynamic message with timestamp</jc>
+ * assertBean(args().setMessage(() -> <js>"Test failed at "</js> +
Instant.now()),
+ * result, <js>"status"</js>, <js>"SUCCESS"</js>);
+ *
+ * <jc>// Message with expensive computation</jc>
+ * assertBean(args().setMessage(() -> <js>"Failed after "</js> +
computeTestDuration() + <js>" ms"</js>),
+ * response, <js>"error"</js>, <js>"null"</js>);
+ * </p>
+ *
+ * @param value The message supplier. Called only when an assertion
fails.
+ * @return This instance for method chaining.
+ */
+ public AssertionArgs setMessage(Supplier<String> value) {
+ messageSupplier = value;
+ return this;
+ }
+
+ /**
+ * Sets a parameterized error message for assertion failures.
+ *
+ * <p>This method uses {@link MessageFormat} to substitute parameters
into the message template.
+ * The formatting occurs immediately when this method is called, not
when the assertion fails.</p>
+ *
+ * <h5 class='section'>Parameter Substitution:</h5>
+ * <p>Uses standard MessageFormat patterns:</p>
+ * <ul>
+ * <li><code>{0}</code> - First parameter</li>
+ * <li><code>{1}</code> - Second parameter</li>
+ * <li><code>{0,number,#}</code> - Formatted number</li>
+ * <li><code>{0,date,short}</code> - Formatted date</li>
+ * </ul>
+ *
+ * <h5 class='section'>Examples:</h5>
+ * <p class='bjava'>
+ * <jc>// Simple parameter substitution</jc>
+ * assertBean(args().setMessage(<js>"User {0} validation
failed"</js>, userId),
+ * user, <js>"active"</js>, <js>"true"</js>);
+ *
+ * <jc>// Multiple parameters</jc>
+ * assertBean(args().setMessage(<js>"Test {0} failed on iteration
{1}"</js>, testName, iteration),
+ * result, <js>"success"</js>, <js>"true"</js>);
+ *
+ * <jc>// Number formatting</jc>
+ * assertBean(args().setMessage(<js>"Expected {0,number,#.##} but
got different value"</js>, expectedValue),
+ * actual, <js>"value"</js>, <js>"123.45"</js>);
+ * </p>
+ *
+ * @param message The message template with MessageFormat placeholders.
+ * @param args The parameters to substitute into the message template.
+ * @return This instance for method chaining.
+ */
+ public AssertionArgs setMessage(String message, Object... args) {
+ messageSupplier = () -> MessageFormat.format(message, args);
+ return this;
+ }
+
+ /**
+ * Gets the base message supplier for composition with
assertion-specific messages.
+ *
+ * @return The configured message supplier, or null if no custom
message was set.
+ */
+ protected Supplier<String> getMessage() {
+ return messageSupplier;
+ }
+
+ /**
+ * Composes the final error message by combining custom and
assertion-specific messages.
+ *
+ * <p>This method implements the message composition strategy used
throughout the assertion framework:</p>
+ * <ul>
+ * <li><b>No Custom Message:</b> Returns the assertion-specific
message as-is</li>
+ * <li><b>With Custom Message:</b> Returns <code>"{custom}, Caused
by: {assertion}"</code></li>
+ * </ul>
+ *
+ * <p>This allows tests to provide high-level context while preserving
the specific
+ * technical details about what assertion failed.</p>
+ *
+ * @param msg The assertion-specific message template.
+ * @param args Parameters for the assertion-specific message.
+ * @return A supplier that produces the composed error message.
+ */
+ protected Supplier<String> getMessage(String msg, Object...args) {
+ return messageSupplier == null ? fs(msg, args) : fs("{0},
Caused by: {1}", messageSupplier.get(), f(msg, args));
+ }
+}
diff --git
a/juneau-utest/src/test/java/org/apache/juneau/junit/AssertionArgs_Test.java
b/juneau-utest/src/test/java/org/apache/juneau/junit/AssertionArgs_Test.java
new file mode 100644
index 000000000..033c5a8d9
--- /dev/null
+++ b/juneau-utest/src/test/java/org/apache/juneau/junit/AssertionArgs_Test.java
@@ -0,0 +1,508 @@
+//
***************************************************************************************************************************
+// * Licensed to the Apache Software Foundation (ASF) under one or more
contributor license agreements. See the NOTICE file *
+// * distributed with this work for additional information regarding copyright
ownership. The ASF licenses this file *
+// * to you 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. *
+//
***************************************************************************************************************************
+package org.apache.juneau.junit;
+
+import static org.apache.juneau.junit.Assertions2.*;
+import static org.junit.jupiter.api.Assertions.*;
+
+import java.text.*;
+import java.time.*;
+import java.util.*;
+import java.util.function.*;
+
+import org.junit.jupiter.api.*;
+
+/**
+ * Unit tests for {@link AssertionArgs}.
+ *
+ * <p>Tests the configuration and behavior of the assertion arguments class
including
+ * bean converter customization, error message composition, and fluent API
functionality.</p>
+ */
+class AssertionArgs_Test {
+
+ // Test objects for assertions
+ static class TestBean {
+ private String name;
+ private int age;
+ private boolean active;
+
+ public TestBean(String name, int age, boolean active) {
+ this.name = name;
+ this.age = age;
+ this.active = active;
+ }
+
+ public String getName() { return name; }
+ public int getAge() { return age; }
+ public boolean isActive() { return active; }
+ }
+
+ static class CustomObject {
+ private String value;
+
+ public CustomObject(String value) {
+ this.value = value;
+ }
+
+ public String getValue() { return value; }
+
+ @Override
+ public String toString() {
+ return "CustomObject[" + value + "]";
+ }
+ }
+
+ @Test
+ void a01_defaultConstruction() {
+ var args = new AssertionArgs();
+
+ // Should have no custom converter
+ assertTrue(args.getBeanConverter().isEmpty());
+
+ // Should have no custom message
+ assertNull(args.getMessage());
+ }
+
+ @Test
+ void a02_fluentAPIReturnsThis() {
+ var args = new AssertionArgs();
+ var mockConverter = createMockConverter();
+
+ // Fluent methods should return the same instance
+ assertSame(args, args.setBeanConverter(mockConverter));
+ assertSame(args, args.setMessage("test message"));
+ assertSame(args, args.setMessage(() -> "dynamic message"));
+ }
+
+ @Test
+ void b01_beanConverterConfiguration() {
+ var args = new AssertionArgs();
+ var mockConverter = createMockConverter();
+
+ // Initially empty
+ assertTrue(args.getBeanConverter().isEmpty());
+
+ // Set converter
+ args.setBeanConverter(mockConverter);
+ assertTrue(args.getBeanConverter().isPresent());
+ assertSame(mockConverter, args.getBeanConverter().get());
+
+ // Set to null should clear
+ args.setBeanConverter(null);
+ assertTrue(args.getBeanConverter().isEmpty());
+ }
+
+ @Test
+ void b02_customConverterInAssertion() {
+ // Create a mock custom converter for testing
+ var customConverter = createCustomConverter();
+
+ var args = args().setBeanConverter(customConverter);
+ var obj = new TestBeanWithCustomObject("test", new
CustomObject("value"));
+
+ // Should use custom converter for stringification
+ assertBean(args, obj, "custom", "CUSTOM:value");
+ }
+
+ static class TestBeanWithCustomObject {
+ private String name;
+ private CustomObject custom;
+
+ public TestBeanWithCustomObject(String name, CustomObject
custom) {
+ this.name = name;
+ this.custom = custom;
+ }
+
+ public String getName() { return name; }
+ public CustomObject getCustom() { return custom; }
+ }
+
+ @Test
+ void c01_messageSupplierConfiguration() {
+ var args = new AssertionArgs();
+
+ // Initially null
+ assertNull(args.getMessage());
+
+ // Set supplier
+ Supplier<String> supplier = () -> "test message";
+ args.setMessage(supplier);
+ assertNotNull(args.getMessage());
+ assertEquals("test message", args.getMessage().get());
+
+ // Set different supplier
+ args.setMessage(() -> "different message");
+ assertEquals("different message", args.getMessage().get());
+ }
+
+ @Test
+ void c02_parameterizedMessageConfiguration() {
+ var args = new AssertionArgs();
+
+ // Simple parameter substitution
+ args.setMessage("Hello {0}", "World");
+ assertEquals("Hello World", args.getMessage().get());
+
+ // Multiple parameters
+ args.setMessage("User {0} has {1} points", "John", 100);
+ assertEquals("User John has 100 points",
args.getMessage().get());
+
+ // Number formatting
+ args.setMessage("Value: {0,number,#.##}", 123.456);
+ assertEquals("Value: 123.46", args.getMessage().get());
+ }
+
+ @Test
+ void c03_dynamicMessageSupplier() {
+ var counter = new int[1]; // Mutable counter for testing
+ var args = new AssertionArgs();
+
+ args.setMessage(() -> "Call #" + (++counter[0]));
+
+ // Each call should increment the counter
+ assertEquals("Call #1", args.getMessage().get());
+ assertEquals("Call #2", args.getMessage().get());
+ assertEquals("Call #3", args.getMessage().get());
+ }
+
+ @Test
+ void d01_messageCompositionWithoutCustomMessage() {
+ var args = new AssertionArgs();
+
+ // No custom message, should return assertion message as-is
+ var composedMessage = args.getMessage("Bean assertion failed");
+ assertEquals("Bean assertion failed", composedMessage.get());
+
+ // With parameters
+ var composedWithParams = args.getMessage("Element at index {0}
did not match", 5);
+ assertEquals("Element at index 5 did not match",
composedWithParams.get());
+ }
+
+ @Test
+ void d02_messageCompositionWithCustomMessage() {
+ var args = new AssertionArgs();
+ args.setMessage("User validation failed");
+
+ // Should compose: custom + assertion
+ var composedMessage = args.getMessage("Bean assertion failed");
+ assertEquals("User validation failed, Caused by: Bean assertion
failed", composedMessage.get());
+
+ // With parameters in assertion message
+ var composedWithParams = args.getMessage("Element at index {0}
did not match", 3);
+ assertEquals("User validation failed, Caused by: Element at
index 3 did not match", composedWithParams.get());
+ }
+
+ @Test
+ void d03_messageCompositionWithParameterizedCustomMessage() {
+ var args = new AssertionArgs();
+ args.setMessage("Test {0} failed on iteration {1}",
"UserValidation", 42);
+
+ var composedMessage = args.getMessage("Bean assertion failed");
+ assertEquals("Test UserValidation failed on iteration 42,
Caused by: Bean assertion failed", composedMessage.get());
+ }
+
+ @Test
+ void d04_messageCompositionWithDynamicCustomMessage() {
+ var timestamp = Instant.now().toString();
+ var args = new AssertionArgs();
+ args.setMessage(() -> "Test failed at " + timestamp);
+
+ var composedMessage = args.getMessage("Bean assertion failed");
+ assertEquals("Test failed at " + timestamp + ", Caused by: Bean
assertion failed", composedMessage.get());
+ }
+
+ @Test
+ void e01_fluentConfigurationChaining() {
+ var converter = createMockConverter();
+
+ // Chain multiple configurations
+ var args = new AssertionArgs()
+ .setBeanConverter(converter)
+ .setMessage("Integration test failed for module {0}",
"AuthModule");
+
+ // Verify both configurations applied
+ assertTrue(args.getBeanConverter().isPresent());
+ assertSame(converter, args.getBeanConverter().get());
+ assertEquals("Integration test failed for module AuthModule",
args.getMessage().get());
+ }
+
+ @Test
+ void e02_configurationOverwriting() {
+ var args = new AssertionArgs();
+ var converter1 = createMockConverter();
+ var converter2 = createMockConverter();
+
+ // Set initial values
+ args.setBeanConverter(converter1)
+ .setMessage("First message");
+
+ // Overwrite with new values
+ args.setBeanConverter(converter2)
+ .setMessage("Second message");
+
+ // Should have latest values
+ assertSame(converter2, args.getBeanConverter().get());
+ assertEquals("Second message", args.getMessage().get());
+ }
+
+ @Test
+ void f01_integrationWithAssertBean() {
+ var bean = new TestBean("John", 30, true);
+ var args = args().setMessage("User test failed");
+
+ // Should work with custom message
+ assertBean(args, bean, "name,age,active", "John,30,true");
+
+ // Test assertion failure message composition
+ var exception = assertThrows(AssertionError.class, () -> {
+ assertBean(args, bean, "name", "Jane");
+ });
+
+ assertTrue(exception.getMessage().contains("User test failed"));
+ assertTrue(exception.getMessage().contains("Caused by:"));
+ }
+
+ @Test
+ void f02_integrationWithAssertBeans() {
+ var beans = List.of(
+ new TestBean("Alice", 25, true),
+ new TestBean("Bob", 35, false)
+ );
+ var args = args().setMessage("Batch validation failed");
+
+ // Should work with custom message
+ assertBeans(args, beans, "name,age", "Alice,25", "Bob,35");
+
+ // Test assertion failure message composition
+ var exception = assertThrows(AssertionError.class, () -> {
+ assertBeans(args, beans, "name", "Charlie", "David");
+ });
+
+ assertTrue(exception.getMessage().contains("Batch validation
failed"));
+ assertTrue(exception.getMessage().contains("Caused by:"));
+ }
+
+ @Test
+ void f03_integrationWithAssertList() {
+ var list = List.of("apple", "banana", "cherry");
+ var args = args().setMessage("List validation failed");
+
+ // Should work with custom message
+ assertList(args, list, "apple", "banana", "cherry");
+
+ // Test assertion failure message composition
+ var exception = assertThrows(AssertionError.class, () -> {
+ assertList(args, list, "orange", "banana", "cherry");
+ });
+
+ assertTrue(exception.getMessage().contains("List validation
failed"));
+ assertTrue(exception.getMessage().contains("Caused by:"));
+ }
+
+ @Test
+ void g01_edgeCaseNullValues() {
+ var args = new AssertionArgs();
+
+ // Null converter should work
+ args.setBeanConverter(null);
+ assertTrue(args.getBeanConverter().isEmpty());
+
+ // Null message supplier should work
+ args.setMessage((Supplier<String>) null);
+ assertNull(args.getMessage());
+ }
+
+ @Test
+ void g02_edgeCaseEmptyMessages() {
+ var args = new AssertionArgs();
+
+ // Empty string message
+ args.setMessage("");
+ assertEquals("", args.getMessage().get());
+
+ // Empty supplier result
+ args.setMessage(() -> "");
+ assertEquals("", args.getMessage().get());
+
+ // Composition with empty custom message
+ var composedMessage = args.getMessage("Bean assertion failed");
+ assertEquals(", Caused by: Bean assertion failed",
composedMessage.get());
+ }
+
+ @Test
+ void g03_edgeCaseComplexParameterFormatting() {
+ var args = new AssertionArgs();
+ var date = new Date();
+
+ // Date formatting
+ args.setMessage("Test executed on {0,date,short}", date);
+ var expectedDatePart =
DateFormat.getDateInstance(DateFormat.SHORT).format(date);
+ assertTrue(args.getMessage().get().contains(expectedDatePart));
+
+ // Complex number formatting
+ args.setMessage("Processing {0,number,percent} complete", 0.85);
+ assertTrue(args.getMessage().get().contains("85%"));
+ }
+
+ @Test
+ void h01_threadSafetyDocumentationCompliance() {
+ // This test documents that AssertionArgs is NOT thread-safe
+ // Each thread should create its own instance
+
+ var sharedArgs = new AssertionArgs();
+ var results = Collections.synchronizedList(new
ArrayList<String>());
+
+ // Simulate multiple threads modifying the same instance
+ var threads = new Thread[5];
+ for (int i = 0; i < threads.length; i++) {
+ final int threadId = i;
+ threads[i] = new Thread(() -> {
+ sharedArgs.setMessage("Thread " + threadId + "
message");
+ // Small delay to increase chance of race
condition
+ try { Thread.sleep(1); } catch
(InterruptedException e) {}
+ results.add(sharedArgs.getMessage().get());
+ });
+ }
+
+ // Start all threads
+ for (var thread : threads) {
+ thread.start();
+ }
+
+ // Wait for completion
+ for (var thread : threads) {
+ try { thread.join(); } catch (InterruptedException e) {}
+ }
+
+ // Due to race conditions, we may not get the expected messages
+ // This demonstrates why each test should create its own
instance
+ assertEquals(5, results.size());
+ // Note: We don't assert specific values due to race conditions
+ }
+
+ @Test
+ void h02_recommendedUsagePattern() {
+ // Demonstrate the recommended pattern: create new instance per
test
+
+ // Test 1: User validation
+ var userArgs = args().setMessage("User validation test");
+ var user = new TestBean("Alice", 25, true);
+ assertBean(userArgs, user, "name,active", "Alice,true");
+
+ // Test 2: Product validation (separate instance)
+ var productArgs = args().setMessage("Product validation test");
+ var products = List.of("Laptop", "Phone", "Tablet");
+ assertList(productArgs, products, "Laptop", "Phone", "Tablet");
+
+ // Each test has its own configuration without interference
+ assertEquals("User validation test",
userArgs.getMessage().get());
+ assertEquals("Product validation test",
productArgs.getMessage().get());
+ }
+
+ // Helper method to create a mock converter for testing
+ private BeanConverter createMockConverter() {
+ return new BeanConverter() {
+ @Override
+ public String stringify(Object o) {
+ return String.valueOf(o);
+ }
+
+ @Override
+ public List<Object> listify(Object o) {
+ if (o instanceof List) return (List<Object>) o;
+ return List.of(o);
+ }
+
+ @Override
+ public boolean canListify(Object o) {
+ return true;
+ }
+
+ @Override
+ public Object swap(Object o) {
+ return o;
+ }
+
+ @Override
+ public Object getProperty(Object object, String name) {
+ // Simple mock implementation
+ if ("name".equals(name) && object instanceof
TestBean) {
+ return ((TestBean) object).getName();
+ }
+ if ("custom".equals(name) && object instanceof
TestBeanWithCustomObject) {
+ return ((TestBeanWithCustomObject)
object).getCustom();
+ }
+ return null;
+ }
+
+ @Override
+ public <T> T getSetting(String key, T defaultValue) {
+ return defaultValue;
+ }
+
+ @Override
+ public String getNested(Object o, NestedTokenizer.Token
token) {
+ var propValue = getProperty(o,
token.getValue());
+ return stringify(propValue);
+ }
+ };
+ }
+
+ // Helper method to create a custom converter for testing
+ private BeanConverter createCustomConverter() {
+ return new BeanConverter() {
+ @Override
+ public String stringify(Object o) {
+ if (o instanceof CustomObject) {
+ return "CUSTOM:" + ((CustomObject)
o).getValue();
+ }
+ return String.valueOf(o);
+ }
+
+ @Override
+ public List<Object> listify(Object o) {
+ if (o instanceof List) return (List<Object>) o;
+ return List.of(o);
+ }
+
+ @Override
+ public boolean canListify(Object o) {
+ return true;
+ }
+
+ @Override
+ public Object swap(Object o) {
+ return o;
+ }
+
+ @Override
+ public Object getProperty(Object object, String name) {
+ if ("custom".equals(name) && object instanceof
TestBeanWithCustomObject) {
+ return ((TestBeanWithCustomObject)
object).getCustom();
+ }
+ return null;
+ }
+
+ @Override
+ public <T> T getSetting(String key, T defaultValue) {
+ return defaultValue;
+ }
+
+ @Override
+ public String getNested(Object o, NestedTokenizer.Token
token) {
+ var propValue = getProperty(o,
token.getValue());
+ return stringify(propValue);
+ }
+ };
+ }
+}
diff --git
a/juneau-utest/src/test/java/org/apache/juneau/junit/Assertions2.java
b/juneau-utest/src/test/java/org/apache/juneau/junit/Assertions2.java
new file mode 100644
index 000000000..f2ba32295
--- /dev/null
+++ b/juneau-utest/src/test/java/org/apache/juneau/junit/Assertions2.java
@@ -0,0 +1,696 @@
+package org.apache.juneau.junit;
+
+import static java.util.stream.Collectors.*;
+import static org.apache.juneau.junit.Utils.*;
+import static org.junit.jupiter.api.Assertions.*;
+
+import java.util.*;
+import java.util.function.*;
+import java.util.stream.*;
+
+import org.opentest4j.*;
+
+/**
+* Comprehensive utility class for Bean-Centric Tests (BCT) and general testing
operations.
+*
+* <p>This class provides the core testing infrastructure for Apache Juneau,
with particular emphasis
+* on the Bean-Centric Testing (BCT) framework. BCT enables sophisticated
assertion patterns for
+* testing object properties, collections, maps, and complex nested structures
with minimal code.</p>
+*
+* <h5 class='section'>Bean-Centric Testing (BCT) Framework:</h5>
+* <p>The BCT framework consists of several key components:</p>
+* <ul>
+* <li><b>{@link BeanConverter}:</b> Core interface for object conversion
and property access</li>
+* <li><b>{@link BasicBeanConverter}:</b> Default implementation with
extensible type handlers</li>
+* <li><b>Assertion Methods:</b> High-level testing methods that leverage
the converter framework</li>
+* </ul>
+*
+* <h5 class='section'>Primary BCT Assertion Methods:</h5>
+* <dl>
+* <dt><b>{@link #assertBean(Object, String, String)}</b></dt>
+* <dd>Tests object properties with nested syntax support and collection
iteration</dd>
+*
+* <dt><b>{@link #assertMap(Map, String, String)}</b></dt>
+* <dd>Tests map entries with the same nested property syntax as
assertBean</dd>
+*
+* <dt><b>{@link #assertMapped(Object, java.util.function.BiFunction,
String, String)}</b></dt>
+* <dd>Tests custom property access using BiFunction for non-standard
objects</dd>
+*
+* <dt><b>{@link #assertList(List, Object...)}</b></dt>
+* <dd>Tests list/collection elements with varargs for expected values</dd>
+*
+* <dt><b>{@link #assertBeans(Collection, String, String...)}</b></dt>
+* <dd>Tests collections of objects by extracting and comparing specific
fields</dd>
+* </dl>
+*
+* <h5 class='section'>BCT Advanced Features:</h5>
+* <ul>
+* <li><b>Nested Property Syntax:</b> "address{street,city}" for testing
nested objects</li>
+* <li><b>Collection Iteration:</b> "#{property}" syntax for testing all
elements</li>
+* <li><b>Universal Size Properties:</b> "length" and "size" work on all
collection types</li>
+* <li><b>Array/List Access:</b> Numeric indices for element-specific
testing</li>
+* <li><b>Method Chaining:</b> Fluent setters can be tested directly</li>
+* <li><b>Direct Field Access:</b> Public fields accessed without
getters</li>
+* <li><b>Map Key Access:</b> Including special "<NULL>" syntax for
null keys</li>
+* </ul>
+*
+* <h5 class='section'>Converter Extensibility:</h5>
+* <p>The BCT framework is built on the extensible {@link BasicBeanConverter}
which allows:</p>
+* <ul>
+* <li><b>Custom Stringifiers:</b> Type-specific string conversion
logic</li>
+* <li><b>Custom Listifiers:</b> Collection-type conversion for
iteration</li>
+* <li><b>Custom Swapifiers:</b> Object transformation before
conversion</li>
+* <li><b>Configurable Settings:</b> Formatting, delimiters, and display
options</li>
+* </ul>
+*
+* <h5 class='section'>Usage Examples:</h5>
+*
+* <p><b>Basic Property Testing:</b></p>
+* <p class='bjava'>
+* <jc>// Test multiple properties</jc>
+* assertBean(user, <js>"name,age,active"</js>, <js>"John,30,true"</js>);
+*
+* <jc>// Test nested properties</jc>
+* assertBean(user, <js>"address{street,city}"</js>, <js>"{123 Main
St,Springfield}"</js>);
+* </p>
+*
+* <p><b>Collection and Array Testing:</b></p>
+* <p class='bjava'>
+* <jc>// Test collection size and iterate over all elements</jc>
+* assertBean(order, <js>"items{length,#{name}}"</js>,
<js>"{3,[{Laptop},{Phone},{Tablet}]}"</js>);
+*
+* <jc>// Test specific array elements</jc>
+* assertBean(data, <js>"values{0,1,2}"</js>, <js>"{100,200,300}"</js>);
+* </p>
+*
+* <p><b>Map and Collection Testing:</b></p>
+* <p class='bjava'>
+* <jc>// Test map entries</jc>
+* assertMap(config, <js>"timeout,retries"</js>, <js>"30000,3"</js>);
+*
+* <jc>// Test list elements</jc>
+* assertList(tags, <js>"red"</js>, <js>"green"</js>, <js>"blue"</js>);
+* </p>
+*
+* <p><b>Custom Property Access:</b></p>
+* <p class='bjava'>
+* <jc>// Test with custom accessor function</jc>
+* assertMapped(myObject, (obj, prop) -> obj.getProperty(prop),
+* <js>"prop1,prop2"</js>, <js>"value1,value2"</js>);
+* </p>
+*
+* <h5 class='section'>Performance and Thread Safety:</h5>
+* <p>The BCT framework is designed for high performance with:</p>
+* <ul>
+* <li><b>Caching:</b> Type-to-handler mappings cached for fast lookup</li>
+* <li><b>Thread Safety:</b> All operations are thread-safe for concurrent
testing</li>
+* <li><b>Minimal Allocation:</b> Efficient object reuse and minimal
temporary objects</li>
+* </ul>
+*
+* @see BeanConverter
+* @see BasicBeanConverter
+*/
+public class Assertions2 {
+
+ private static final BeanConverter DEFAULT_CONVERTER =
BasicBeanConverter.DEFAULT;
+
+ public static AssertionArgs args() {
+ return new AssertionArgs();
+ }
+
+ /**
+ * Asserts that the fields/properties on the specified bean are the
specified values after being converted to {@link Utils#r readable} strings.
+ *
+ * <p>This is the primary method for Bean-Centric Tests (BCT),
supporting extensive property validation
+ * patterns including nested objects, collections, arrays, method
chaining, direct field access, collection iteration
+ * with <js>#{property}</js> syntax, and universal
<js>length</js>/<js>size</js> properties for all collection types.</p>
+ *
+ * <p>The method uses the {@link BasicBeanConverter#DEFAULT} converter
internally for object introspection
+ * and value extraction. The converter provides sophisticated property
access through the {@link BeanConverter}
+ * interface, supporting multiple fallback mechanisms for accessing
object properties and values.</p>
+ *
+ * <h5 class='section'>Basic Usage:</h5>
+ * <p class='bjava'>
+ * <jc>// Test multiple properties</jc>
+ * assertBean(myBean, <js>"prop1,prop2,prop3"</js>,
<js>"val1,val2,val3"</js>);
+ *
+ * <jc>// Test single property</jc>
+ * assertBean(myBean, <js>"name"</js>, <js>"John"</js>);
+ * </p>
+ *
+ * <h5 class='section'>Nested Property Testing:</h5>
+ * <p class='bjava'>
+ * <jc>// Test nested bean properties</jc>
+ * assertBean(myBean, <js>"address{street,city,state}"</js>,
<js>"{123 Main St,Springfield,IL}"</js>);
+ *
+ * <jc>// Test arbitrarily deep nesting</jc>
+ * assertBean(myBean, <js>"person{address{geo{lat,lon}}}"</js>,
<js>"{{{{40.7,-74.0}}}}"</js>);
+ * </p>
+ *
+ * <h5 class='section'>Array, List, and Stream Testing:</h5>
+ * <p class='bjava'>
+ * <jc>// Test array/list elements by index</jc>
+ * assertBean(myBean, <js>"items{0,1,2}"</js>,
<js>"{item1,item2,item3}"</js>);
+ *
+ * <jc>// Test nested properties within array elements</jc>
+ * assertBean(myBean, <js>"orders{0{id,total}}"</js>,
<js>"{{123,99.95}}"</js>);
+ *
+ * <jc>// Test array length property</jc>
+ * assertBean(myBean, <js>"items{length}"</js>, <js>"{5}"</js>);
+ *
+ * <jc>// Works with any iterable type including Streams</jc>
+ * assertBean(myBean, <js>"userStream{#{name}}"</js>,
<js>"[{Alice},{Bob}]"</js>);
+ * </p>
+ *
+ * <h5 class='section'>Collection Iteration Syntax:</h5>
+ * <p class='bjava'>
+ * <jc>// Test properties across ALL elements in a collection
using #{...} syntax</jc>
+ * assertBean(myBean, <js>"userList{#{name}}"</js>,
<js>"[{John},{Jane},{Bob}]"</js>);
+ *
+ * <jc>// Test multiple properties from each element</jc>
+ * assertBean(myBean, <js>"orderList{#{id,status}}"</js>,
<js>"[{123,ACTIVE},{124,PENDING}]"</js>);
+ *
+ * <jc>// Works with nested properties within each element</jc>
+ * assertBean(myBean, <js>"customers{#{address{city}}}"</js>,
<js>"[{{New York}},{{Los Angeles}}]"</js>);
+ *
+ * <jc>// Works with arrays and any iterable collection type
(including Streams)</jc>
+ * assertBean(config, <js>"itemArray{#{type}}"</js>,
<js>"[{String},{Integer},{Boolean}]"</js>);
+ * assertBean(data, <js>"statusSet{#{name}}"</js>,
<js>"[{ACTIVE},{PENDING},{CANCELLED}]"</js>);
+ * assertBean(processor, <js>"dataStream{#{value}}"</js>,
<js>"[{A},{B},{C}]"</js>);
+ * </p>
+ *
+ * <h5 class='section'>Universal Collection Size Properties:</h5>
+ * <p class='bjava'>
+ * <jc>// Both 'length' and 'size' work universally across all
collection types</jc>
+ * assertBean(myBean, <js>"myArray{length}"</js>, <js>"{5}"</js>);
<jc>// Arrays</jc>
+ * assertBean(myBean, <js>"myArray{size}"</js>, <js>"{5}"</js>);
<jc>// Also works for arrays</jc>
+ *
+ * assertBean(myBean, <js>"myList{size}"</js>, <js>"{3}"</js>);
<jc>// Collections</jc>
+ * assertBean(myBean, <js>"myList{length}"</js>, <js>"{3}"</js>);
<jc>// Also works for collections</jc>
+ *
+ * assertBean(myBean, <js>"myMap{size}"</js>, <js>"{7}"</js>);
<jc>// Maps</jc>
+ * assertBean(myBean, <js>"myMap{length}"</js>, <js>"{7}"</js>);
<jc>// Also works for maps</jc>
+ * </p>
+ *
+ * <h5 class='section'>Class Name Testing:</h5>
+ * <p class='bjava'>
+ * <jc>// Test class properties (prefer simple names for
maintainability)</jc>
+ * assertBean(myBean, <js>"obj{class{simpleName}}"</js>,
<js>"{{MyClass}}"</js>);
+ *
+ * <jc>// Test full class names when needed</jc>
+ * assertBean(myBean, <js>"obj{class{name}}"</js>,
<js>"{{com.example.MyClass}}"</js>);
+ * </p>
+ *
+ * <h5 class='section'>Method Chaining Support:</h5>
+ * <p class='bjava'>
+ * <jc>// Test fluent setter chains (returns same object)</jc>
+ * assertBean(
+ *
item.setType(<js>"foo"</js>).setFormat(<js>"bar"</js>).setDefault(<js>"baz"</js>),
+ * <js>"type,format,default"</js>,
+ * <js>"foo,bar,baz"</js>
+ * );
+ * </p>
+ *
+ * <h5 class='section'>Advanced Collection Analysis:</h5>
+ * <p class='bjava'>
+ * <jc>// Combine size/length, metadata, and content iteration in
single assertions</jc>
+ * assertBean(myBean,
<js>"users{length,class{simpleName},#{name}}"</js>,
+ * <js>"{3,{ArrayList},[{John},{Jane},{Bob}]}"</js>);
+ *
+ * <jc>// Comprehensive collection validation with multiple
iteration patterns</jc>
+ * assertBean(order, <js>"items{size,#{name},#{price}}"</js>,
+ *
<js>"{3,[{Laptop},{Phone},{Tablet}],[{999.99},{599.99},{399.99}]}"</js>);
+ *
+ * <jc>// Perfect for validation testing - verify error count and
details</jc>
+ * assertBean(result, <js>"errors{length,#{field},#{code}}"</js>,
+ * <js>"{2,[{email},{password}],[{E001},{E002}]}"</js>);
+ *
+ * <jc>// Mixed collection types with consistent syntax</jc>
+ * assertBean(response, <js>"results{size},metadata{length}"</js>,
<js>"{25},{4}"</js>);
+ * </p>
+ *
+ * <h5 class='section'>Direct Field Access:</h5>
+ * <p class='bjava'>
+ * <jc>// Test public fields directly (no getters required)</jc>
+ * assertBean(myBean, <js>"f1,f2,f3"</js>,
<js>"val1,val2,val3"</js>);
+ *
+ * <jc>// Test field properties with chaining</jc>
+ * assertBean(myBean, <js>"f1{length},f2{class{simpleName}}"</js>,
<js>"{5},{{String}}"</js>);
+ * </p>
+ *
+ * <h5 class='section'>Map Testing:</h5>
+ * <p class='bjava'>
+ * <jc>// Test map values by key</jc>
+ * assertBean(myBean, <js>"configMap{timeout,retries}"</js>,
<js>"{30000,3}"</js>);
+ *
+ * <jc>// Test map size</jc>
+ * assertBean(myBean, <js>"settings{size}"</js>, <js>"{5}"</js>);
+ *
+ * <jc>// Test null keys using special <NULL> syntax</jc>
+ * assertBean(myBean, <js>"mapWithNullKey{<NULL>}"</js>,
<js>"{nullKeyValue}"</js>);
+ * </p>
+ *
+ * <h5 class='section'>Collection and Boolean Values:</h5>
+ * <p class='bjava'>
+ * <jc>// Test boolean values</jc>
+ * assertBean(myBean, <js>"enabled,visible"</js>,
<js>"true,false"</js>);
+ *
+ * <jc>// Test enum collections</jc>
+ * assertBean(myBean, <js>"statuses"</js>,
<js>"[ACTIVE,PENDING]"</js>);
+ * </p>
+ *
+ * <h5 class='section'>Value Syntax Rules:</h5>
+ * <ul>
+ * <li><b>Simple values:</b> <js>"value"</js> for direct property
values</li>
+ * <li><b>Nested values:</b> <js>"{value}"</js> for single-level
nested properties</li>
+ * <li><b>Deep nested values:</b> <js>"{{value}}"</js>,
<js>"{{{value}}}"</js> for multiple nesting levels</li>
+ * <li><b>Array/Collection values:</b> <js>"[item1,item2]"</js>
for collections</li>
+ * <li><b>Collection iteration:</b> <js>"#{property}"</js>
iterates over ALL collection elements, returns <js>"[{val1},{val2}]"</js></li>
+ * <li><b>Universal size properties:</b> <js>"length"</js> and
<js>"size"</js> work on arrays, collections, and maps</li>
+ * <li><b>Boolean values:</b> <js>"true"</js>,
<js>"false"</js></li>
+ * <li><b>Null values:</b> <js>"null"</js></li>
+ * </ul>
+ *
+ * <h5 class='section'>Property Access Priority:</h5>
+ * <ol>
+ * <li><b>Collection/Array access:</b> Numeric indices for
arrays/lists (e.g., <js>"0"</js>, <js>"1"</js>)</li>
+ * <li><b>Universal size properties:</b> <js>"length"</js> and
<js>"size"</js> for arrays, collections, and maps</li>
+ * <li><b>Map key access:</b> Direct key lookup for Map objects
(including <js>"<NULL>"</js> for null keys)</li>
+ * <li><b>is{Property}()</b> methods (for boolean properties)</li>
+ * <li><b>get{Property}()</b> methods</li>
+ * <li><b>Public fields</b> (direct field access)</li>
+ * </ol>
+ *
+ * @param actual The bean object to test. Must not be null.
+ * @param fields Comma-delimited list of property names to test.
Supports nested syntax with {}.
+ * @param expected Comma-delimited list of expected values. Must match
the order of fields.
+ * @throws NullPointerException if the bean is null
+ * @throws AssertionError if any property values don't match expected
values
+ * @see BeanConverter
+ * @see BasicBeanConverter
+ */
+ public static void assertBean(Object actual, String fields, String
expected) {
+ assertBean(args(), actual, fields, expected);
+ }
+
+ public static void assertBean(AssertionArgs args, Object actual, String
fields, String expected) {
+ assertNotNull(actual, "Actual was null.");
+ assertArgNotNull("args", args);
+ assertArgNotNull("fields", fields);
+ assertArgNotNull("expected", expected);
+ assertEquals(
+ expected,
+ tokenize(fields).stream().map(x ->
args.getBeanConverter().orElse(DEFAULT_CONVERTER).getNested(actual,
x)).collect(joining(",")),
+ args.getMessage("Bean assertion failed.")
+ );
+ }
+
+ /**
+ * Asserts that multiple beans in a collection have the expected
property values.
+ *
+ * <p>This method validates that each bean in a collection has the
specified property values,
+ * using the same property access logic as {@link #assertBean(Object,
String, String)}.
+ * It's perfect for testing collections of similar objects or
validation results.</p>
+ *
+ * <h5 class='section'>Basic Usage:</h5>
+ * <p class='bjava'>
+ * <jc>// Test list of user beans</jc>
+ * assertBeans(userList, <js>"name,age"</js>,
+ * <js>"John,25"</js>, <js>"Jane,30"</js>,
<js>"Bob,35"</js>);
+ * </p>
+ *
+ * <h5 class='section'>Complex Property Testing:</h5>
+ * <p class='bjava'>
+ * <jc>// Test nested properties across multiple beans</jc>
+ * assertBeans(orderList, <js>"id,customer{name,email}"</js>,
+ * <js>"1,{John,[email protected]}"</js>,
+ * <js>"2,{Jane,[email protected]}"</js>);
+ *
+ * <jc>// Test collection properties within beans</jc>
+ * assertBeans(cartList, <js>"items{0{name}},total"</js>,
+ * <js>"{{Laptop}},999.99"</js>,
+ * <js>"{{Phone}},599.99"</js>);
+ * </p>
+ *
+ * <h5 class='section'>Validation Testing:</h5>
+ * <p class='bjava'>
+ * <jc>// Test validation results</jc>
+ * assertBeans(validationErrors, <js>"field,message,code"</js>,
+ * <js>"email,Invalid email format,E001"</js>,
+ * <js>"age,Must be 18 or older,E002"</js>);
+ * </p>
+ *
+ * <h5 class='section'>Collection Iteration Testing:</h5>
+ * <p class='bjava'>
+ * <jc>// Test collection iteration within beans (#{...}
syntax)</jc>
+ * assertBeans(departmentList, <js>"name,employees{#{name}}"</js>,
+ * <js>"Engineering,[{Alice},{Bob},{Charlie}]"</js>,
+ * <js>"Marketing,[{David},{Eve}]"</js>);
+ * </p>
+ *
+ * <h5 class='section'>Parser Result Testing:</h5>
+ * <p class='bjava'>
+ * <jc>// Test parsed object collections</jc>
+ * var parsed = JsonParser.DEFAULT.parse(jsonArray,
MyBean[].class);
+ * assertBeans(Arrays.asList(parsed), <js>"prop1,prop2"</js>,
+ * <js>"val1,val2"</js>, <js>"val3,val4"</js>);
+ * </p>
+ *
+ * @param listOfBeans The collection of beans to check. Must not be
null.
+ * @param fields A comma-delimited list of bean property names
(supports nested syntax).
+ * @param values Array of expected value strings, one per bean. Each
string contains comma-delimited values matching the fields.
+ * @throws AssertionError if the collection size doesn't match values
array length or if any bean properties don't match
+ * @see #assertBean(Object, String, String)
+ */
+ public static void assertBeans(Object actual, String fields,
String...expected) {
+ assertBeans(args(), actual, fields, expected);
+ }
+
+ public static void assertBeans(AssertionArgs args, Object actual,
String fields, String...expected) {
+ assertNotNull(actual, "Value was null.");
+ assertArgNotNull("args", args);
+ assertArgNotNull("fields", fields);
+ assertArgNotNull("expected", expected);
+
+ var converter =
args.getBeanConverter().orElse(DEFAULT_CONVERTER);
+ var tokens = tokenize(fields);
+ var errors = new ArrayList<AssertionFailedError>();
+ var actualList = converter.listify(actual);
+
+ if (ne(expected.length, actualList.size())) {
+ errors.add(assertEqualsFailed(expected.length,
actualList.size(), args.getMessage("Wrong number of beans.")));
+ } else {
+ for (var i = 0; i < actualList.size(); i++) {
+ var i2 = i;
+ var e = converter.stringify(expected[i]);
+ var a = tokens.stream().map(x ->
converter.getNested(actualList.get(i2), x)).collect(joining(","));
+ if (ne(e, a)) {
+ errors.add(assertEqualsFailed(e, a,
args.getMessage("Bean at row <{0}> did not match.", i)));
+ }
+ }
+ }
+
+ if (errors.isEmpty()) return;
+
+ var actualStrings = new ArrayList<String>();
+ for (var o : actualList) {
+ actualStrings.add(tokens.stream().map(x ->
converter.getNested(o, x)).collect(joining(",")));
+ }
+
+ if (errors.size() == 1) throw errors.get(0);
+
+ throw assertEqualsFailed(
+
Stream.of(expected).map(Utils::escapeForJava).collect(joining("\", \"", "\"",
"\"")),
+
actualStrings.stream().map(Utils::escapeForJava).collect(joining("\", \"",
"\"", "\"")),
+ args.getMessage("{0} bean assertions failed:\n{1}",
errors.size(), errors.stream().map(x -> x.getMessage()).collect(joining("\n")))
+ );
+ }
+
+ /**
+ * Asserts that mapped property access on an object returns expected
values using a custom BiFunction.
+ *
+ * <p>This is the most powerful and flexible BCT method, designed for
testing objects that don't follow
+ * standard JavaBean patterns or require custom property access logic.
The BiFunction allows complete
+ * control over how properties are retrieved from the target object.</p>
+ *
+ * <p>When the BiFunction throws an exception, it's automatically
caught and the exception's
+ * simple class name becomes the property value for comparison (e.g.,
"NullPointerException").</p>
+ *
+ * <p>This method creates an intermediate LinkedHashMap to collect all
property values before
+ * delegating to assertMap(Map, String, String). This ensures
consistent ordering
+ * and supports the full nested property syntax. The {@link
BasicBeanConverter#DEFAULT} is used
+ * for value stringification and nested property access.</p>
+ *
+ * @param <T> The type of object being tested
+ * @param actual The object to test properties on
+ * @param function The BiFunction that extracts property values.
Receives (object, propertyName) and returns the property value.
+ * @param properties Comma-delimited list of property names to test
+ * @param expected Comma-delimited list of expected values (exceptions
become simple class names)
+ * @throws AssertionError if any mapped property values don't match
expected values
+ * @see #assertBean(Object, String, String)
+ * @see #assertMap(Map, String, String)
+ * @see BeanConverter
+ * @see BasicBeanConverter
+ */
+ public static <T> void assertMapped(T actual,
BiFunction<T,String,Object> function, String properties, String expected) {
+ assertMapped(args(), actual, function, properties, expected);
+ }
+
+ public static <T> void assertMapped(AssertionArgs args, T actual,
BiFunction<T,String,Object> function, String properties, String expected) {
+ assertNotNull(actual, "Value was null.");
+ assertArgNotNull("args", args);
+ assertArgNotNull("function", function);
+ assertArgNotNull("properties", properties);
+ assertArgNotNull("expected", expected);
+
+ var m = new LinkedHashMap<String,Object>();
+ for (var p : tokenize(properties)) {
+ var pv = p.getValue();
+ m.put(pv, safe(() -> function.apply(actual, pv)));
+ }
+
+ assertBean(args, m, properties, expected);
+ }
+
+ /**
+ * Asserts an object matches the expected string after it's been made
{@link Utils#r readable}.
+ */
+ public static void assertContains(String expected, Object actual) {
+ assertContains(args(), expected, actual);
+ }
+
+ public static void assertContains(AssertionArgs args, String expected,
Object actual) {
+ assertArgNotNull("args", args);
+ assertArgNotNull("expected", expected);
+ assertArgNotNull("actual", actual);
+ assertNotNull(actual, "Value was null.");
+
+ var a =
args.getBeanConverter().orElse(DEFAULT_CONVERTER).stringify(actual);
+ assertTrue(a.contains(expected), args.getMessage("String did
not contain expected substring. ==> expected: <{0}> but was: <{1}>", expected,
a));
+ }
+
+ /**
+ * Similar to {@link #assertContains(String, Object)} but allows the
expected to be a comma-delimited list of strings that
+ * all must match.
+ * @param expected
+ * @param actual
+ */
+ public static void assertContainsAll(Object actual, String...expected) {
+ assertContainsAll(args(), actual, expected);
+ }
+
+ public static void assertContainsAll(AssertionArgs args, Object actual,
String...expected) {
+ assertArgNotNull("args", args);
+ assertArgNotNull("expected", expected);
+ assertNotNull(actual, "Value was null.");
+
+ var a =
args.getBeanConverter().orElse(DEFAULT_CONVERTER).stringify(actual);
+ for (var e : expected)
+ assertTrue(a.contains(e), args.getMessage("String did
not contain expected substring. ==> expected: <{0}> but was: <{1}>", e, a));
+ }
+
+ /**
+ * Asserts that a collection is not null and empty.
+ */
+ public static void assertEmpty(Object value) {
+ assertEmpty(args(), value);
+ }
+
+ public static void assertEmpty(AssertionArgs args, Object value) {
+ assertArgNotNull("args", args);
+ assertNotNull(value, "Value was null.");
+
+ if (value instanceof Optional v2) {
+ assertTrue(v2.isEmpty(), "Optional was not empty");
+ return;
+ }
+ if (value instanceof Map v2) {
+ assertTrue(v2.isEmpty(), "Map was not empty");
+ return;
+ }
+
+ var converter =
args.getBeanConverter().orElse(DEFAULT_CONVERTER);
+
+ assertTrue(converter.canListify(value), args.getMessage("Value
cannot be converted to a list. Class=<{0}>",
value.getClass().getSimpleName()));
+ assertTrue(converter.listify(value).isEmpty(),
args.getMessage("Value was not empty."));
+ }
+
+ /**
+ * Asserts that a List contains the expected values using flexible
comparison logic.
+ *
+ * <p>This is the primary method for testing all collection-like types.
For non-List collections, use
+ * {@link #l(Object)} to convert them to Lists first. This unified
approach eliminates the need for
+ * separate assertion methods for arrays, sets, and other collection
types.</p>
+ *
+ * <h5 class='section'>Testing Non-List Collections:</h5>
+ * <p class='bjava'>
+ * <jc>// Test a Set using l() conversion</jc>
+ * Set<String> <jv>mySet</jv> = Set.of(<js>"a"</js>,
<js>"b"</js>, <js>"c"</js>);
+ * assertList(l(<jv>mySet</jv>), <js>"a"</js>, <js>"b"</js>,
<js>"c"</js>);
+ *
+ * <jc>// Test an array using l() conversion</jc>
+ * String[] <jv>myArray</jv> = {<js>"x"</js>, <js>"y"</js>,
<js>"z"</js>};
+ * assertList(l(<jv>myArray</jv>), <js>"x"</js>, <js>"y"</js>,
<js>"z"</js>);
+ *
+ * <jc>// Test a Stream using l() conversion</jc>
+ * Stream<String> <jv>myStream</jv> =
Stream.of(<js>"foo"</js>, <js>"bar"</js>);
+ * assertList(l(<jv>myStream</jv>), <js>"foo"</js>,
<js>"bar"</js>);
+ * </p>
+ *
+ * <h5 class='section'>Comparison Modes:</h5>
+ * <p>The method supports three different ways to compare expected vs
actual values:</p>
+ *
+ * <h6 class='section'>1. String Comparison (Readable Format):</h6>
+ * <p class='bjava'>
+ * <jc>// Elements are converted to {@link Utils#r readable}
format and compared as strings</jc>
+ * assertList(List.of(1, 2, 3), <js>"1"</js>, <js>"2"</js>,
<js>"3"</js>);
+ * assertList(List.of("a", "b"), <js>"a"</js>, <js>"b"</js>);
+ * </p>
+ *
+ * <h6 class='section'>2. Predicate Testing (Functional
Validation):</h6>
+ * <p class='bjava'>
+ * <jc>// Use Predicate<T> for functional testing</jc>
+ * Predicate<Integer> <jv>greaterThanOne</jv> = <jv>x</jv>
-> <jv>x</jv> > 1;
+ * assertList(List.of(2, 3, 4), <jv>greaterThanOne</jv>,
<jv>greaterThanOne</jv>, <jv>greaterThanOne</jv>);
+ *
+ * <jc>// Mix predicates with other comparison types</jc>
+ * Predicate<String> <jv>startsWithA</jv> = <jv>s</jv> ->
<jv>s</jv>.startsWith(<js>"a"</js>);
+ * assertList(List.of(<js>"apple"</js>, <js>"banana"</js>),
<jv>startsWithA</jv>, <js>"banana"</js>);
+ * </p>
+ *
+ * <h6 class='section'>3. Object Equality (Direct Comparison):</h6>
+ * <p class='bjava'>
+ * <jc>// Non-String, non-Predicate objects use Objects.equals()
comparison</jc>
+ * assertList(List.of(1, 2, 3), 1, 2, 3); <jc>// Integer
objects</jc>
+ * assertList(List.of(<jv>myBean1</jv>, <jv>myBean2</jv>),
<jv>myBean1</jv>, <jv>myBean2</jv>); <jc>// Custom objects</jc>
+ * </p>
+ *
+ * @param actual The List to test. Must not be null. For other
collection types, use {@link #l(Object)} to convert first.
+ * @param expected Multiple arguments of expected values.
+ * Can be Strings (readable format comparison),
Predicates (functional testing), or Objects (direct equality).
+ * @throws AssertionError if the List size or contents don't match
expected values
+ * @see #l(Object) for converting other collection types to Lists
+ */
+ public static <T> void assertList(Object actual, Object...expected) {
+ assertList(args(), actual, expected);
+ }
+
+ @SuppressWarnings("unchecked")
+ public static <T> void assertList(AssertionArgs args, Object actual,
Object...expected) {
+ assertArgNotNull("args", args);
+ assertArgNotNull("expected", expected);
+ assertNotNull(actual, "Value was null.");
+
+ var converter =
args.getBeanConverter().orElse(DEFAULT_CONVERTER);
+ var list = converter.listify(actual);
+ assertEquals(expected.length, list.size(),
args.getMessage("Wrong list length."));
+
+ for (var i = 0; i < expected.length; i++) {
+ var x = list.get(i);
+ var e = expected[i];
+ if (e instanceof String e2) {
+ assertEquals(e2, converter.stringify(x),
args.getMessage("Element at index {0} did not match.", i));
+ } else if (e instanceof Predicate e2) {
+ assertTrue(e2.test(x), args.getMessage("Element
at index {0} did pass predicate. ==> actual: <{0}>", i,
converter.stringify(x)));
+ } else {
+ assertEquals(e, x, args.getMessage("Element at
index {0} did not match. ==> expected: <{1}({2})> but was: <{3}(4)>", i, e,
t(e), x, t(x)));
+ }
+ }
+ }
+
+ /**
+ * Asserts that a collection is not null and not empty.
+ */
+ public static void assertNotEmpty(Object value) {
+ assertNotEmpty(args(), value);
+ }
+
+ public static void assertNotEmpty(AssertionArgs args, Object value) {
+ assertArgNotNull("args", args);
+ assertNotNull(value, "Value was null.");
+
+ if (value instanceof Optional v2) {
+ assertFalse(v2.isEmpty(), "Optional was empty");
+ return;
+ }
+ if (value instanceof Map v2) {
+ assertFalse(v2.isEmpty(), "Map was empty");
+ return;
+ }
+
+
assertFalse(args.getBeanConverter().orElse(DEFAULT_CONVERTER).listify(value).isEmpty(),
args.getMessage("Value was empty."));
+ }
+
+ /**
+ * Asserts that a collection-like object or string is not null and of
the specified size.
+ *
+ * <p>This method can validate the size of various types of objects:</p>
+ * <ul>
+ * <li><b>String:</b> Validates character length</li>
+ * <li><b>Collection-like objects:</b> Any object that can be
converted to a List via {@link #toList(Object)}</li>
+ * </ul>
+ *
+ * <h5 class='section'>Usage Examples:</h5>
+ * <p class='bjava'>
+ * <jc>// Test string length</jc>
+ * assertSize(5, <js>"hello"</js>);
+ *
+ * <jc>// Test collection size</jc>
+ * assertSize(3, List.of(<js>"a"</js>, <js>"b"</js>,
<js>"c"</js>));
+ *
+ * <jc>// Test array size</jc>
+ * assertSize(2, <jk>new</jk> String[]{<js>"x"</js>,
<js>"y"</js>});
+ * </p>
+ *
+ * @param expected The expected size/length.
+ * @param actual The object to test. Must not be null.
+ * @throws AssertionError if the object is null or not the expected
size.
+ */
+ public static void assertSize(int expected, Object actual) {
+ assertSize(args(), expected, actual);
+ }
+
+ public static void assertSize(AssertionArgs args, int expected, Object
actual) {
+ assertArgNotNull("args", args);
+ assertNotNull(actual, "Value was null.");
+
+ if (actual instanceof String a) {
+ assertEquals(expected, a.length(),
args.getMessage("Value not expected size. value: <{0}>", a));
+ return;
+ }
+
+ var size =
args.getBeanConverter().orElse(DEFAULT_CONVERTER).listify(actual).size();
+ assertEquals(expected, size, args.getMessage("Value not
expected size."));
+ }
+
+ /**
+ * Asserts an object matches the expected string after it's been made
{@link Utils#r readable}.
+ */
+ public static void assertString(String expected, Object actual) {
+ assertString(args(), expected, actual);
+ }
+
+ public static void assertString(AssertionArgs args, String expected,
Object actual) {
+ assertArgNotNull("args", args);
+ assertNotNull(actual, "Value was null.");
+
+ assertEquals(expected,
args.getBeanConverter().orElse(DEFAULT_CONVERTER).stringify(actual),
args.getMessage());
+ }
+
+ /**
+ * Asserts value when stringified matches the specified pattern.
+ */
+ public static void assertMatches(String pattern, Object value) {
+ assertMatches(args(), pattern, value);
+ }
+
+ public static void assertMatches(AssertionArgs args, String pattern,
Object value) {
+ assertArgNotNull("args", args);
+ assertArgNotNull("pattern", pattern);
+ assertNotNull(value, "Value was null.");
+
+ var v =
args.getBeanConverter().orElse(DEFAULT_CONVERTER).stringify(value);
+ var m = getMatchPattern3(pattern).matcher(v);
+ assertTrue(m.matches(), args.getMessage("Pattern didn't match.
==> pattern: <{0}> but was: <{1}>", pattern, v));
+ }
+}
diff --git a/juneau-utest/src/test/java/org/apache/juneau/junit/Utils.java
b/juneau-utest/src/test/java/org/apache/juneau/junit/Utils.java
index 9b29b735c..c1e057456 100644
--- a/juneau-utest/src/test/java/org/apache/juneau/junit/Utils.java
+++ b/juneau-utest/src/test/java/org/apache/juneau/junit/Utils.java
@@ -12,10 +12,15 @@
//
***************************************************************************************************************************
package org.apache.juneau.junit;
+import static java.util.Optional.*;
+
import java.lang.reflect.*;
import java.text.*;
import java.util.*;
import java.util.function.*;
+import java.util.regex.*;
+
+import org.opentest4j.*;
public class Utils {
public static <T> T safe(ThrowingSupplier<T> s) {
@@ -32,6 +37,14 @@ public class Utils {
return args.length == 0 ? msg : MessageFormat.format(msg, args);
}
+ public static Supplier<String> fs(String msg, Object...args) {
+ return ()->f(msg, args);
+ }
+
+ public static String t(Object o) {
+ return o == null ? null : o.getClass().getSimpleName();
+ }
+
public static List<Object> arrayToList(Object o) {
var l = new ArrayList<>();
for (var i = 0; i < Array.getLength(o); i++)
@@ -46,8 +59,105 @@ public class Utils {
return test.test(o1, o2);
}
- @SuppressWarnings("unlikely-arg-type")
- public static <T,U> boolean eq(T o1, U o2) {
+ public static <T> boolean eq(T o1, T o2) {
return Objects.equals(o1, o2);
}
+
+ public static <T> boolean ne(T o1, T o2) {
+ return ! eq(o1, o2);
+ }
+
+ /**
+ * Throws an {@link IllegalArgumentException} if the specified argument
is <jk>null</jk>.
+ *
+ * <h5 class='section'>Example:</h5>
+ * <p class='bjava'>
+ * <jk>import static</jk> org.apache.juneau.internal.ArgUtils.*;
+ *
+ * <jk>public</jk> String setFoo(String <jv>foo</jv>) {
+ * <jsm>assertArgNotNull</jsm>(<js>"foo"</js>,
<jv>foo</jv>);
+ * ...
+ * }
+ * </p>
+ *
+ * @param <T> The argument data type.
+ * @param name The argument name.
+ * @param o The object to check.
+ * @return The same argument.
+ * @throws IllegalArgumentException Constructed exception.
+ */
+ public static final <T> T assertArgNotNull(String name, T o) throws
IllegalArgumentException {
+ assertArg(o != null, "Argument ''{0}'' cannot be null.", name);
+ return o;
+ }
+
+ public static final void assertArg(boolean expression, String msg,
Object...args) throws IllegalArgumentException {
+ if (! expression)
+ throw new
IllegalArgumentException(MessageFormat.format(msg, args));
+ }
+
+ /**
+ * Converts a string containing <js>"*"</js> meta characters with a
regular expression pattern.
+ *
+ * @param s The string to create a pattern from.
+ * @return A regular expression pattern.
+ */
+ public static Pattern getMatchPattern3(String s) {
+ return getMatchPattern3(s, 0);
+ }
+
+ /**
+ * Converts a string containing <js>"*"</js> meta characters with a
regular expression pattern.
+ *
+ * @param s The string to create a pattern from.
+ * @param flags Regular expression flags.
+ * @return A regular expression pattern.
+ */
+ public static Pattern getMatchPattern3(String s, int flags) {
+ if (s == null)
+ return null;
+ var sb = new StringBuilder();
+ sb.append("\\Q");
+ for (var i = 0; i < s.length(); i++) {
+ var c = s.charAt(i);
+ if (c == '*')
+ sb.append("\\E").append(".*").append("\\Q");
+ else if (c == '?')
+ sb.append("\\E").append(".").append("\\Q");
+ else
+ sb.append(c);
+ }
+ sb.append("\\E");
+ return Pattern.compile(sb.toString(), flags);
+ }
+
+ public static List<NestedTokenizer.Token> tokenize(String fields) {
+ return NestedTokenizer.tokenize(fields);
+ }
+
+ public static AssertionFailedError assertEqualsFailed(Object expected,
Object actual, Supplier<String> messageSupplier) {
+ return new
AssertionFailedError(ofNullable(messageSupplier).map(x ->
x.get()).orElse("Equals assertion failed.") + f(" ==> expected: <{0}> but was:
<{1}>", expected, actual), expected, actual);
+ }
+
+ public static String escapeForJava(String s) {
+ var sb = new StringBuilder();
+ for (var c : s.toCharArray()) {
+ switch (c) {
+ case '\"': sb.append("\\\""); break;
+ case '\\': sb.append("\\\\"); break;
+ case '\n': sb.append("\\n"); break;
+ case '\r': sb.append("\\r"); break;
+ case '\t': sb.append("\\t"); break;
+ case '\f': sb.append("\\f"); break;
+ case '\b': sb.append("\\b"); break;
+ default:
+ if (c < 0x20 || c > 0x7E) {
+
sb.append(String.format("\\u%04x", (int)c));
+ } else {
+ sb.append(c);
+ }
+ }
+ }
+ return sb.toString();
+ }
}
diff --git
a/juneau-utest/src/test/java/org/apache/juneau/objecttools/ObjectRest_Test.java
b/juneau-utest/src/test/java/org/apache/juneau/objecttools/ObjectRest_Test.java
index cbd4dc43f..c04054797 100755
---
a/juneau-utest/src/test/java/org/apache/juneau/objecttools/ObjectRest_Test.java
+++
b/juneau-utest/src/test/java/org/apache/juneau/objecttools/ObjectRest_Test.java
@@ -16,6 +16,7 @@ import static org.apache.juneau.TestUtils.*;
import static org.junit.jupiter.api.Assertions.*;
import java.util.*;
+import java.util.function.*;
import org.apache.juneau.*;
import org.apache.juneau.annotation.*;
@@ -362,7 +363,15 @@ class ObjectRest_Test extends SimpleTestBase {
"f1,f2,f3,f4,f2a,f3a,f4a,f5,f6,f7,f8",
"true,false,false,false,true,true,true,true,true,true,true");
- assertMapped(model, ObjectRest::getMap,
+ BiFunction<ObjectRest,String,Object> f1 = (r,p) -> {
+ try {
+ return r.getMap(p);
+ } catch (Exception e) {
+ return e.getClass().getSimpleName();
+ }
+ };
+
+ assertMapped(model, f1,
"f1,f2,f3,f4,f2a,f3a,f4a,f5,f6,f7,f8",
"<null>,InvalidDataConversionException,InvalidDataConversionException,InvalidDataConversionException,<null>,<null>,<null>,<null>,<null>,<null>,<null>");
@@ -378,15 +387,39 @@ class ObjectRest_Test extends SimpleTestBase {
assertEquals("{a:'b'}", model.getMap("f7", m).toString());
assertEquals("{a:'b'}", model.getMap("f8", m).toString());
- assertMapped(model, (r,p) -> r.getMap(p, m),
+ BiFunction<ObjectRest,String,Object> f2 = (r,p) -> {
+ try {
+ return r.getMap(p, m);
+ } catch (Exception e) {
+ return e.getClass().getSimpleName();
+ }
+ };
+
+ assertMapped(model, f2,
"f1,f2,f2a,f3,f3a,f4,f4a,f5,f6,f7,f8",
"{a=b},InvalidDataConversionException,{a=b},InvalidDataConversionException,{a=b},InvalidDataConversionException,{a=b},{a=b},{a=b},{a=b},{a=b}");
- assertMapped(model, ObjectRest::getJsonMap,
+ BiFunction<ObjectRest,String,Object> f3 = (r,p) -> {
+ try {
+ return r.getJsonMap(p);
+ } catch (Exception e) {
+ return e.getClass().getSimpleName();
+ }
+ };
+
+ assertMapped(model, f3,
"f1,f2,f3,f4,f2a,f3a,f4a,f5,f6,f7,f8",
"<null>,InvalidDataConversionException,InvalidDataConversionException,InvalidDataConversionException,<null>,<null>,<null>,<null>,<null>,<null>,<null>");
- assertMapped(model, (r,p) -> r.getJsonMap(p, m),
+ BiFunction<ObjectRest,String,Object> f4 = (r,p) -> {
+ try {
+ return r.getJsonMap(p, m);
+ } catch (Exception e) {
+ return e.getClass().getSimpleName();
+ }
+ };
+
+ assertMapped(model, f4,
"f1,f2,f3,f4,f2a,f3a,f4a,f5,f6,f7,f8",
"{a=b},InvalidDataConversionException,InvalidDataConversionException,InvalidDataConversionException,{a=b},{a=b},{a=b},{a=b},{a=b},{a=b},{a=b}");