Skip to content

Commit

Permalink
Add XMLUnit test utils: KiwiXmlAssert and LoggingComparisonListener (#…
Browse files Browse the repository at this point in the history
…443)

* KiwiXmlAssert is a simple wrapper around XMLUnit's XmlAssert
  which logs all differences. It also provides some convenience
  methods to compare XML without any boilerplate code.
* LoggingComparisonListener is an XMLUnit ComparisonListener
  that logs differences found by XmlAssert.
* Both of these classes are marked Beta in this first iteration
* Additionally, added InMemoryAppender and InMemoryAppenderExtension,
  which provide test utilities for testing Logback messages.
  They are only in the test sources for now, but I documented
  them fully because I anticipate we might want to add them
  as full test utilities at some point. And if not, then at least
  they are documented for internal use within this library.

Closes #442
  • Loading branch information
sleberknight authored Dec 10, 2023
1 parent bea7802 commit c3403db
Show file tree
Hide file tree
Showing 16 changed files with 851 additions and 0 deletions.
8 changes: 8 additions & 0 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@

<!-- Versions for provided dependencies -->
<jakarta.persistence-api.version>3.1.0</jakarta.persistence-api.version>
<xmlunit.version>2.9.1</xmlunit.version>

<!-- Sonar properties -->
<sonar.projectKey>kiwiproject_kiwi-test</sonar.projectKey>
Expand Down Expand Up @@ -231,6 +232,13 @@
<scope>provided</scope>
</dependency>

<dependency>
<groupId>org.xmlunit</groupId>
<artifactId>xmlunit-assertj</artifactId>
<version>${xmlunit.version}</version>
<scope>provided</scope>
</dependency>

<!-- test dependencies -->

<dependency>
Expand Down
171 changes: 171 additions & 0 deletions src/main/java/org/kiwiproject/test/xmlunit/KiwiXmlAssert.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
package org.kiwiproject.test.xmlunit;

import static org.kiwiproject.base.KiwiPreconditions.requireNotNull;

import com.google.common.annotations.Beta;
import com.google.errorprone.annotations.CanIgnoreReturnValue;
import org.junit.jupiter.api.TestInfo;
import org.xmlunit.assertj.CompareAssert;
import org.xmlunit.assertj.XmlAssert;
import org.xmlunit.diff.ComparisonController;
import org.xmlunit.diff.ComparisonControllers;
import org.xmlunit.diff.ComparisonListener;

/**
* KiwiXmlAssert provides convenience on top of <a href="https://www.xmlunit.org">XMLUnit</a> to log
* any differences found using a {@link LoggingComparisonListener}. Optionally, you can specify a test
* name that will be included when logging differences.
* <p>
* 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.
* <p>
* 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:
* <pre>
* // inside a test
* assertThatXml(someXml).isIdenticalTo(someOtherXml);
* </pre>
* <p>
* 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.
* <p>
* If needed, the {@link ComparisonController} can be overridden simply by calling
* {@link CompareAssert#withComparisonController(ComparisonController)}
* on the CompareAssert returned by this method. <em>Note, however, that if you use
* a different ComparisonController which stops at the first difference, then
* only that first difference will be logged.</em>
* <p>
* 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();
}
}
Original file line number Diff line number Diff line change
@@ -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.
* <p>
* 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);
}
}
96 changes: 96 additions & 0 deletions src/test/java/org/kiwiproject/test/logback/InMemoryAppender.java
Original file line number Diff line number Diff line change
@@ -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.
* <p>
* <em>This is for testing purposes only, and is not at all intended for production use!</em>
*/
public class InMemoryAppender extends AppenderBase<ILoggingEvent> {

private final AtomicInteger messageOrder;
private final ConcurrentMap<Integer, ILoggingEvent> eventMap;

/**
* Create a new InMemoryAppender with no messages.
*/
public InMemoryAppender() {
this.messageOrder = new AtomicInteger();
this.eventMap = new ConcurrentHashMap<>();
}

/**
* Assert this appender has the expected number of logging events, and if the assertion succeeds, return a
* list containing those events.
*/
@SuppressWarnings("unused")
public List<ILoggingEvent> assertNumberOfLoggingEventsAndGet(int expectedEventCount) {
var events = orderedEvents();
assertThat(events).hasSize(expectedEventCount);
return events;
}

/**
* {@inheritDoc}
*/
@Override
@Synchronized
protected void append(ILoggingEvent eventObject) {
eventMap.put(messageOrder.incrementAndGet(), eventObject);
}

/**
* Clears all logging events from this appender.
*/
@Synchronized
public void clearEvents() {
messageOrder.set(0);
eventMap.clear();
}

/**
* Retrieves a list of logging events ordered by ascending timestamp.
*
* @return the ordered list of logging events
*/
public List<ILoggingEvent> orderedEvents() {
return orderedEventStream().collect(toList());
}

/**
* Retrieves a list of logged messages ordered by ascending timestamp.
*
* @return the ordered list of logged messages
*/
@SuppressWarnings("unused")
public List<String> orderedEventMessages() {
return orderedEventStream()
.map(ILoggingEvent::getFormattedMessage)
.collect(toList());
}

/**
* Retrieves a stream of logging events ordered by ascending timestamp.
*
* @return the ordered stream of logged messages
*/
public Stream<ILoggingEvent> orderedEventStream() {
return eventMap.values()
.stream()
.sorted(comparing(ILoggingEvent::getTimeStamp));
}
}
Loading

0 comments on commit c3403db

Please sign in to comment.