+ * Once the terminal {@link #and(Object)} method is called, you use XMLUnit as usual. + * Or, you can use one of the convenience methods such as {@link #isIdenticalTo(Object)} + * which are useful for some common comparison scenarios. + */ +@Beta +public class KiwiXmlAssert { + + private final Object o; + private String testName; + + private KiwiXmlAssert(Object o) { + this.o = requireNotNull(o, "object to compare against must not bul null"); + } + + /** + * This is an entrance point to begin an XML comparison. + *
+ * Returns a KiwiXmlAssert object for making assertions on the given object. + * + * @param o object with a type supported by {@link org.xmlunit.builder.Input#from(Object)} + * @return a new KiwiXmlAssert object + */ + public static KiwiXmlAssert assertThat(Object o) { + return new KiwiXmlAssert(o); + } + + /** + * This is an alternate entrance point to begin an XML comparison. It is useful in + * tests which already statically import AssertJ's {@code assertThat}, because + * it avoids ambiguity and compilation errors while allowing it to be statically + * imported. For example, assuming this method has been statically imported: + *
+ * // inside a test + * assertThatXml(someXml).isIdenticalTo(someOtherXml); + *+ *
+ * Returns a KiwiXmlAssert object for making assertions on the given object. + * + * @param o object with a type supported by {@link org.xmlunit.builder.Input#from(Object)} + * @return a new KiwiXmlAssert object + */ + public static KiwiXmlAssert assertThatXml(Object o) { + return assertThat(o); + } + + /** + * Sets the test name for logging XML differences during comparison. + * + * @param testInfo the TestInfo object containing information about the current test + * @return this KiwiXmlAssert object updated with the updated test name + */ + public KiwiXmlAssert withTestNameFrom(TestInfo testInfo) { + return withTestName(requireNotNull(testInfo).getDisplayName()); + } + + /** + * Sets the test name for logging XML differences during comparison. + * + * @param testName the name of the test + * @return this KiwiXmlAssert object updated with the modified test name + */ + public KiwiXmlAssert withTestName(String testName) { + this.testName = testName; + return this; + } + + /** + * This is a terminal operation in {@link KiwiXmlAssert}. It returns a {@link CompareAssert} + * that has been configured with a {@link LoggingComparisonListener} and the + * {@link ComparisonControllers#Default Default} comparison controller. + *
+ * If needed, the {@link ComparisonController} can be overridden simply by calling + * {@link CompareAssert#withComparisonController(ComparisonController)} + * on the CompareAssert returned by this method. Note, however, that if you use + * a different ComparisonController which stops at the first difference, then + * only that first difference will be logged. + *
+ * Additional {@link org.xmlunit.diff.ComparisonListener comparison listeners} can be + * added by calling {@link CompareAssert#withComparisonListeners(ComparisonListener...)} + * and supplying additional listeners. + * + * @param control the object to compare against this instance's object + * @return a CompareAssert object for making further assertions on XML differences + * @see CompareAssert + * @see ComparisonControllers#Default + */ + public CompareAssert and(Object control) { + return XmlAssert.assertThat(o) + .and(control) + .withComparisonController(ComparisonControllers.Default) + .withComparisonListeners(new LoggingComparisonListener(testName)); + } + + /** + * This is a convenience terminal operation in {@link KiwiXmlAssert} built using + * {@link #and(Object)} that checks that the control object is identical to this + * instance's object. + * + * @param control the object to compare against this instance's object + * @return a CompareAssert object for making further assertions on XML differences + * @see CompareAssert#areIdentical() + */ + @CanIgnoreReturnValue + public CompareAssert isIdenticalTo(Object control) { + return and(control).areIdentical(); + } + + /** + * This is a convenience terminal operation in {@link KiwiXmlAssert} built using + * {@link #and(Object)} that checks that the control object is identical to this + * instance's object, ignoring extra whitespace. + * + * @param control the object to compare against this instance's object + * @return a CompareAssert object for making further assertions on XML differences + * @see CompareAssert#ignoreWhitespace() + * @see CompareAssert#areIdentical() + */ + @CanIgnoreReturnValue + public CompareAssert isIdenticalToIgnoringWhitespace(Object control) { + return and(control).ignoreWhitespace().areIdentical(); + } + + /** + * This is a convenience terminal operation in {@link KiwiXmlAssert} built using + * {@link #and(Object)} that checks that the control object is identical to this + * instance's object, ignoring XML comments. + * + * @param control the object to compare against this instance's object + * @return a CompareAssert object for making further assertions on XML differences + * @see CompareAssert#ignoreComments() + * @see CompareAssert#areIdentical() + */ + @CanIgnoreReturnValue + public CompareAssert isIdenticalToIgnoringComments(Object control) { + return and(control).ignoreComments().areIdentical(); + } + + /** + * This is a convenience terminal operation in {@link KiwiXmlAssert} built using + * {@link #and(Object)} that checks that the control object is identical to this + * instance's object, ignoring both whitespace and comments. + * + * @param control the object to compare against this instance's object + * @return a CompareAssert object for making further assertions on XML differences + * @see CompareAssert#ignoreWhitespace() + * @see CompareAssert#ignoreComments() + * @see CompareAssert#areIdentical() + */ + @CanIgnoreReturnValue + public CompareAssert isIdenticalToIgnoringWhitespaceAndComments(Object control) { + return and(control).ignoreWhitespace().ignoreComments().areIdentical(); + } +} diff --git a/src/main/java/org/kiwiproject/test/xmlunit/LoggingComparisonListener.java b/src/main/java/org/kiwiproject/test/xmlunit/LoggingComparisonListener.java new file mode 100644 index 00000000..7477db6d --- /dev/null +++ b/src/main/java/org/kiwiproject/test/xmlunit/LoggingComparisonListener.java @@ -0,0 +1,103 @@ +package org.kiwiproject.test.xmlunit; + +import static java.util.Objects.nonNull; +import static org.kiwiproject.base.KiwiPreconditions.requireNotNull; + +import com.google.common.annotations.Beta; +import lombok.extern.slf4j.Slf4j; +import org.checkerframework.checker.nullness.qual.Nullable; +import org.junit.jupiter.api.TestInfo; +import org.slf4j.Logger; +import org.xmlunit.diff.Comparison; +import org.xmlunit.diff.ComparisonListener; +import org.xmlunit.diff.ComparisonResult; + +import java.util.concurrent.atomic.AtomicBoolean; + +/** + * A listener for XML comparison events that logs any differences using a logger. + * + * @see ComparisonListener + */ +@Slf4j +@Beta +public class LoggingComparisonListener implements ComparisonListener { + + private final String testName; + private final AtomicBoolean haveSeenDifferenceAlready; + + /** + * Private {@link Logger} used for logging XML differences. By default, it is + * the {@code static final} Logger for this class, but can be changed using the + * {@link #withLogger(Logger)} method. + */ + @SuppressWarnings("NonConstantLogger") + private Logger logger = LOG; + + /** + * Create a new instance without an explicit test name. + */ + public LoggingComparisonListener() { + this((String) null); + } + + /** + * Create a new instance with a test name extracted from the given TestInfo. + * + * @param testInfo the JUnit {@link TestInfo} + */ + public LoggingComparisonListener(TestInfo testInfo) { + this(requireNotNull(testInfo).getDisplayName()); + } + + /** + * Create a new instance with the given test name. + * + * @param testName the test name + */ + public LoggingComparisonListener(@Nullable String testName) { + this.testName = testName; + this.haveSeenDifferenceAlready = new AtomicBoolean(); + } + + /** + * Sets the logger to be used for logging XML differences. + *
+ * If this is not called, a default logger is used. + * + * @param logger the logger to set + * @return the current instance of {@link LoggingComparisonListener} + */ + public LoggingComparisonListener withLogger(Logger logger) { + this.logger = requireNotNull(logger); + return this; + } + + /** + * This method is called when a comparison is performed. If the outcome of the comparison is DIFFERENT, + * it logs the difference, including a test name if it was provided to this instance. + * + * @param comparison the comparison object + * @param outcome the outcome of the comparison + */ + @Override + public void comparisonPerformed(Comparison comparison, ComparisonResult outcome) { + if (outcome == ComparisonResult.DIFFERENT) { + logDifference(comparison); + } + } + + @SuppressWarnings("java:S2629") + private void logDifference(Comparison comparison) { + if (haveSeenDifferenceAlready.compareAndSet(false, true)) { + logPreamble(); + } + + logger.warn(comparison.toString()); + } + + private void logPreamble() { + var testNameOrEmpty = nonNull(testName) ? (testName + ": ") : ""; + logger.warn("{}XML differences found:", testNameOrEmpty); + } +} diff --git a/src/test/java/org/kiwiproject/test/logback/InMemoryAppender.java b/src/test/java/org/kiwiproject/test/logback/InMemoryAppender.java new file mode 100644 index 00000000..19531580 --- /dev/null +++ b/src/test/java/org/kiwiproject/test/logback/InMemoryAppender.java @@ -0,0 +1,96 @@ +package org.kiwiproject.test.logback; + +import static java.util.Comparator.comparing; +import static java.util.stream.Collectors.toList; +import static org.assertj.core.api.Assertions.assertThat; + +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.core.AppenderBase; +import lombok.Synchronized; + +import java.util.List; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.stream.Stream; + +/** + * A logback appender that stores logging events in an in-memory map. + * The events can be accessed, ordered, and cleared. + *
+ * This is for testing purposes only, and is not at all intended for production use!
+ */
+public class InMemoryAppender extends AppenderBase
+ * Note also the appender must be an {@link InMemoryAppender}.
+ *
+ * @param loggerClass the class of the test logger
+ */
+ public InMemoryAppenderExtension(Class> loggerClass) {
+ this(loggerClass, loggerClass.getSimpleName() + "Appender");
+ }
+
+ /**
+ * Create a new instance associated with the given Logback logger class
+ * which has an appender of type {@link InMemoryAppender} with the name
+ * {@code appenderName}.
+ *
+ * @param loggerClass the class of the test logger
+ * @param appenderName the name of the {@link InMemoryAppender}
+ */
+ public InMemoryAppenderExtension(Class> loggerClass, String appenderName) {
+ this.loggerClass = loggerClass;
+ this.appenderName = appenderName;
+ }
+
+ /**
+ * Exposes the {@link InMemoryAppender} associated with {@code loggerClass}.
+ * It can be obtained via the {@link #appender()} method. Usually, tests
+ * will store an instance in their own {@link org.junit.jupiter.api.BeforeEach BeforeEach}
+ * method. For example:
+ *
+ * class SpaceModulatorTest {
+ *
+ * {@literal @}RegisterExtension
+ * private final InMemoryAppenderExtension inMemoryAppenderExtension =
+ * new InMemoryAppenderExtension(SpaceModulatorServiceTest.class);
+ *
+ * private InMemoryAppender appender;
+ *
+ * {@literal @}BeforeEach
+ * void setUp() {
+ * this.appender = inMemoryAppenderExtension.appender();
+ *
+ * // additional set up...
+ * }
+ *
+ * // tests...
+ * }
+ *
+ *
+ * @param context the current extension context; never {@code null}
+ */
+ @Override
+ public void beforeEach(ExtensionContext context) {
+ var logbackLogger = (Logger) LoggerFactory.getLogger(loggerClass);
+ var rawAppender = logbackLogger.getAppender(appenderName);
+
+ assertThat(rawAppender)
+ .describedAs("Expected an appender named '%s' for logger '%s' of type %s",
+ appenderName, loggerClass.getName(), InMemoryAppender.class.getName())
+ .isInstanceOf(InMemoryAppender.class);
+ appender = (InMemoryAppender) rawAppender;
+ }
+
+ /**
+ * Clears all logging events from the {@link InMemoryAppender} to ensure each
+ * test starts with an empty appender.
+ *
+ * @param context the current extension context; never {@code null}
+ * @see InMemoryAppender#clearEvents()
+ */
+ @Override
+ public void afterEach(ExtensionContext context) {
+ appender.clearEvents();
+ }
+}
diff --git a/src/test/java/org/kiwiproject/test/xmlunit/KiwiXmlAssertTest.java b/src/test/java/org/kiwiproject/test/xmlunit/KiwiXmlAssertTest.java
new file mode 100644
index 00000000..5501593d
--- /dev/null
+++ b/src/test/java/org/kiwiproject/test/xmlunit/KiwiXmlAssertTest.java
@@ -0,0 +1,197 @@
+package org.kiwiproject.test.xmlunit;
+
+import static org.assertj.core.api.Assertions.assertThatCode;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.kiwiproject.test.util.Fixtures.fixture;
+
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.TestInfo;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+
+@DisplayName("KiwiXmlAssert")
+@SuppressWarnings("java:S5778")
+class KiwiXmlAssertTest {
+
+ @Nested
+ class EntrancePoints {
+
+ @Test
+ void canUseAssertThatXml() {
+ var xml = fixture("KiwiXmlAssertTest/alice-smith.xml");
+ var otherXml = fixture("KiwiXmlAssertTest/alice-smith-duplicate.xml");
+
+ assertThatCode(() -> KiwiXmlAssert.assertThatXml(xml).isIdenticalTo(otherXml))
+ .doesNotThrowAnyException();
+ }
+
+ @Test
+ void canUseAssertThat() {
+ var xml = fixture("KiwiXmlAssertTest/alice-smith.xml");
+ var otherXml = fixture("KiwiXmlAssertTest/alice-smith-duplicate.xml");
+
+ assertThatCode(() -> KiwiXmlAssert.assertThat(xml).isIdenticalTo(otherXml))
+ .doesNotThrowAnyException();
+ }
+ }
+
+ @Nested
+ class CanUseTestName {
+
+ @Test
+ void shouldAcceptTestNameAsString() {
+ var xml = fixture("KiwiXmlAssertTest/alice-smith.xml");
+ var otherXml = fixture("KiwiXmlAssertTest/alice-jones.xml");
+
+ assertThatThrownBy(() ->
+ KiwiXmlAssert.assertThat(xml)
+ .withTestName("custom-test-name")
+ .and(otherXml)
+ .areIdentical())
+ .isExactlyInstanceOf(AssertionError.class);
+ }
+
+ @Test
+ void shouldAcceptTestNameFromTestInfo(TestInfo testInfo) {
+ var xml = fixture("KiwiXmlAssertTest/alice-smith.xml");
+ var otherXml = fixture("KiwiXmlAssertTest/alice-jones.xml");
+
+ assertThatThrownBy(() ->
+ KiwiXmlAssert.assertThat(xml)
+ .withTestNameFrom(testInfo)
+ .and(otherXml)
+ .areIdentical())
+ .isExactlyInstanceOf(AssertionError.class);
+ }
+ }
+
+ @Nested
+ class TerminalOperations {
+
+ @Nested
+ class And {
+
+ @Test
+ void shouldReturnCompareAssertForFurtherChaining() {
+ var xml = fixture("KiwiXmlAssertTest/alice-smith.xml");
+ var otherXml = fixture("KiwiXmlAssertTest/alice-smith-duplicate.xml");
+
+ assertThatCode(() -> KiwiXmlAssert.assertThat(xml)
+ .withTestName("custom-test-name")
+ .and(otherXml)
+ .ignoreWhitespace()
+ .ignoreChildNodesOrder()
+ .ignoreComments()
+ .areIdentical())
+ .doesNotThrowAnyException();
+ }
+ }
+
+ @Nested
+ class IsIdenticalTo {
+
+ @Test
+ void shouldCompareIdenticalXml() {
+ var xml = fixture("KiwiXmlAssertTest/alice-smith.xml");
+ var otherXml = fixture("KiwiXmlAssertTest/alice-smith-duplicate.xml");
+
+ assertThatCode(() -> KiwiXmlAssert.assertThat(xml).isIdenticalTo(otherXml))
+ .doesNotThrowAnyException();
+ }
+
+ @Test
+ void shouldThrowAssertionErrorWhenXmlIsDifferent() {
+ var xml = fixture("KiwiXmlAssertTest/alice-smith.xml");
+ var otherXml = fixture("KiwiXmlAssertTest/alice-jones.xml");
+
+ assertThatThrownBy(() ->
+ KiwiXmlAssert.assertThat(xml).isIdenticalTo(otherXml))
+ .isExactlyInstanceOf(AssertionError.class);
+ }
+ }
+
+ @Nested
+ class IsIdenticalToIgnoringWhitespace {
+
+ @ParameterizedTest
+ @ValueSource(strings = {
+ "KiwiXmlAssertTest/alice-smith-condensed.xml",
+ "KiwiXmlAssertTest/alice-smith-extra-whitespace.xml"
+ })
+ void shouldIgnoreWhitespace(String otherFixture) {
+ var xml = fixture("KiwiXmlAssertTest/alice-smith.xml");
+ var otherXml = fixture(otherFixture);
+
+ assertThatCode(() ->
+ KiwiXmlAssert.assertThat(xml).isIdenticalToIgnoringWhitespace(otherXml))
+ .doesNotThrowAnyException();
+ }
+
+ @Test
+ void shouldThrowAssertionErrorWhenXmlIsDifferent() {
+ var xml = fixture("KiwiXmlAssertTest/alice-smith.xml");
+ var otherXml = fixture("KiwiXmlAssertTest/alice-jones.xml");
+
+ assertThatThrownBy(() ->
+ KiwiXmlAssert.assertThat(xml).isIdenticalToIgnoringWhitespace(otherXml))
+ .isExactlyInstanceOf(AssertionError.class);
+ }
+ }
+
+ @Nested
+ class IsIdenticalToIgnoringComments {
+
+ @Test
+ void shouldIgnoreComments() {
+ var xml = fixture("KiwiXmlAssertTest/alice-smith.xml");
+ var otherXml = fixture("KiwiXmlAssertTest/alice-smith-with-comments.xml");
+
+ assertThatCode(() ->
+ KiwiXmlAssert.assertThat(xml).isIdenticalToIgnoringComments(otherXml))
+ .doesNotThrowAnyException();
+ }
+
+ /**
+ * @implNote This is a "canary test" to flag if XMLUnit ever changes the unexpected
+ * behavior that it doesn't strip comments that are between tags, only comments
+ * at the top and bottom, and within elements. View the test file to see.
+ */
+ @Test
+ void canaryDoesNotIgnoreCommentsBetweenTags() {
+ var xml = fixture("KiwiXmlAssertTest/alice-smith.xml");
+ var otherXml = fixture("KiwiXmlAssertTest/alice-smith-with-comments-between-tags.xml");
+
+ assertThatThrownBy(() ->
+ KiwiXmlAssert.assertThat(xml).isIdenticalToIgnoringComments(otherXml))
+ .isExactlyInstanceOf(AssertionError.class);
+ }
+ }
+
+ @Nested
+ class IsIdenticalToIgnoringWhitespaceAndComments {
+
+ @Test
+ void shouldIgnoreWhitespaceAndComments() {
+ var xml = fixture("KiwiXmlAssertTest/alice-smith.xml");
+ var otherXml = fixture("KiwiXmlAssertTest/alice-smith-with-comments-and-extra-whitespace.xml");
+
+ assertThatCode(() ->
+ KiwiXmlAssert.assertThat(xml).isIdenticalToIgnoringWhitespaceAndComments(otherXml))
+ .doesNotThrowAnyException();
+ }
+
+ @Test
+ void shouldThrowAssertionErrorWhenXmlIsDifferent() {
+ var xml = fixture("KiwiXmlAssertTest/alice-smith.xml");
+ var otherXml = fixture("KiwiXmlAssertTest/alice-jones.xml");
+
+ assertThatThrownBy(() ->
+ KiwiXmlAssert.assertThat(xml).isIdenticalToIgnoringWhitespaceAndComments(otherXml))
+ .isExactlyInstanceOf(AssertionError.class);
+ }
+ }
+ }
+
+}
diff --git a/src/test/java/org/kiwiproject/test/xmlunit/LoggingComparisonListenerTest.java b/src/test/java/org/kiwiproject/test/xmlunit/LoggingComparisonListenerTest.java
new file mode 100644
index 00000000..6f47f771
--- /dev/null
+++ b/src/test/java/org/kiwiproject/test/xmlunit/LoggingComparisonListenerTest.java
@@ -0,0 +1,86 @@
+package org.kiwiproject.test.xmlunit;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+import lombok.extern.slf4j.Slf4j;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.TestInfo;
+import org.junit.jupiter.api.extension.RegisterExtension;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.EnumSource;
+import org.kiwiproject.test.logback.InMemoryAppender;
+import org.kiwiproject.test.logback.InMemoryAppenderExtension;
+import org.xmlunit.diff.Comparison;
+import org.xmlunit.diff.ComparisonResult;
+
+@DisplayName("LoggingComparisonListener")
+@Slf4j
+class LoggingComparisonListenerTest {
+
+ @RegisterExtension
+ private final InMemoryAppenderExtension inMemoryAppenderExtension =
+ new InMemoryAppenderExtension(LoggingComparisonListenerTest.class);
+
+ private InMemoryAppender appender;
+ private Comparison comparison;
+
+ @BeforeEach
+ void setUp() {
+ appender = inMemoryAppenderExtension.appender();
+ comparison = mock(Comparison.class);
+ }
+
+ @ParameterizedTest
+ @EnumSource(value = ComparisonResult.class,
+ mode = EnumSource.Mode.EXCLUDE,
+ names = "DIFFERENT")
+ void shouldNotLogWhenComparisonResultIsNotDifferent(ComparisonResult result) {
+ var listener = new LoggingComparisonListener().withLogger(LOG);
+ listener.comparisonPerformed(comparison, result);
+
+ assertThat(appender.orderedEvents()).isEmpty();
+ }
+
+ @Test
+ void shouldLogWithNoTestName() {
+ var listener = new LoggingComparisonListener().withLogger(LOG);
+
+ when(comparison.toString()).thenReturn("The difference");
+
+ listener.comparisonPerformed(comparison, ComparisonResult.DIFFERENT);
+
+ assertThat(appender.orderedEventMessages())
+ .containsExactly("XML differences found:", "The difference");
+ }
+
+ @Test
+ void shouldLogWithTestName_FromTestInfo() {
+ var testInfo = mock(TestInfo.class);
+ when(testInfo.getDisplayName()).thenReturn("My Test");
+
+ var listener = new LoggingComparisonListener(testInfo).withLogger(LOG);
+
+ when(comparison.toString()).thenReturn("The difference");
+
+ listener.comparisonPerformed(comparison, ComparisonResult.DIFFERENT);
+
+ assertThat(appender.orderedEventMessages())
+ .containsExactly("My Test: XML differences found:", "The difference");
+ }
+
+ @Test
+ void shouldLogWithTestName() {
+ var listener = new LoggingComparisonListener("Some Test").withLogger(LOG);
+
+ when(comparison.toString()).thenReturn("The difference");
+
+ listener.comparisonPerformed(comparison, ComparisonResult.DIFFERENT);
+
+ assertThat(appender.orderedEventMessages())
+ .containsExactly("Some Test: XML differences found:", "The difference");
+ }
+}
diff --git a/src/test/resources/KiwiXmlAssertTest/alice-jones.xml b/src/test/resources/KiwiXmlAssertTest/alice-jones.xml
new file mode 100644
index 00000000..6268ea54
--- /dev/null
+++ b/src/test/resources/KiwiXmlAssertTest/alice-jones.xml
@@ -0,0 +1,6 @@
+