Search code examples
javaxmljackson-dataformat-xml

How to keep xml attribute in fasterXml Jackson XmlMapper?


I am writing test cases which test generated xml structures. I am supplying the xml structures via an xml file. I am using currently FasterXMLs Jackson XmlMapper for reading and testing for expected xml.

Java:            adoptopenjdk 11
Maven:           3.6.3
JUnit (Jupiter): 5.7.1 (JUnit Jupiter)
Mapper:          com.fasterxml.jackson.dataformat.xml.XmlMapper
Dependency:      <dependency>
                     <groupId>com.fasterxml.jackson.dataformat</groupId>
                     <artifactId>jackson-dataformat-xml</artifactId>
                     <version>2.11.4</version>
                 </dependency>

I have an xml file which contains expected xml (e.g.: /test/testcases.xml:

<testcases>
    <testcase1>
        <response>
            <sizegroup-list>
                <sizeGroup id="1">
                <sizes>
                    <size>
                        <technicalSize>38</technicalSize>
                        <textSize>38</textSize>
                    <size>
                    <size>
                        <technicalSize>705</technicalSize>
                        <textSize>110cm</textSize>
                    <size>
                </sizes>
            </sizeGroup-list>
        </response>
    </testcase1>
</testcases>

My code looks like this (simplified):

import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.dataformat.xml.XmlMapper;
import org.apache.commons.lang3.StringUtils;
import org.junit.jupiter.api.Test;

import java.io.FileInputStream;
import java.io.InputStream;

import static org.junit.jupiter.api.Assertions.assertEquals;

class Testcases {
    private static final String OBJECT_NODE_START_TAG = "<ObjectNode>";
    private static final String OBJECT_NODE_CLOSE_TAG = "</ObjectNode>";
    private static final String TESTCASES_XML = "/test/testcases.xml";
    private static final XmlMapper XML_MAPPER = new XmlMapper();

    @Test
    void testcase1() throws Exception {
        final String nodePtr = "/testcase1/response";
        try (InputStream inputStream = new FileInputStream(TESTCASES_XML)) {
            JsonNode rootNode = XML_MAPPER.readTree(inputStream);
            JsonNode subNode = rootNode.at(nodePtr);

            if (subNode.isMissingNode()) {
                throw new IllegalArgumentException(
                        "Node '" + nodePtr + "' not found in file " + TESTCASES_XML);
            }

            String expectedXml = XML_MAPPER.writeValueAsString(subNode);
            expectedXml = unwrapObjectNode(expectedXml);

            // Testcalls, e.g. someService.generateXmlData()
            String generatedXml = "...";

            assertEquals(expectedXml, generatedXml);
        };
    }

    // FIXME: Ugly: Tell XmlMapper to unwrap ObjectNode automatically
    private String unwrapObjectNode(String xmlString) {
        if(StringUtils.isBlank(xmlString)) {
            return xmlString;
        }

        if(xmlString.startsWith(OBJECT_NODE_START_TAG)) {
            xmlString = xmlString.substring(OBJECT_NODE_START_TAG.length());
            if(xmlString.endsWith(OBJECT_NODE_CLOSE_TAG)) {
                xmlString = xmlString.substring(0, xmlString.length() - OBJECT_NODE_CLOSE_TAG.length());
            }
        }

        return xmlString;
    }

}

But the returned expected xml looks like this:

            <sizegroup-list>
                <sizeGroup>
                <id>1</id>
                <sizes>
                    <size>
                        <technicalSize>38</technicalSize>
                        <textSize>38</textSize>
                    <size>
                    <size>
                        <technicalSize>705</technicalSize>
                        <textSize>110cm</textSize>
                    <size>
                </sizes>
            </sizeGroup-list>

The former attribute id of the element sizeGroup gets mapped as a sub element and fails my test. How can I tell XmlMapper to keep the attributes of xml elements?

Best regards, David


Solution

  • i was not able to tell XmlMapper to keep the attributes of xml tags from the loaded xml file. But i have found another way by parsing xml test data with xPath expressions.

    A simple String.equals(...) proofed to be unreliable if expected and actual xml contain different whitespaces or xml tag order. Luckily there is a library for comparing xml. XmlUnit!

    Additional dependency (seems to be present as transitive dependency as of Spring Boot 2.6.x):

    <dependency>
        <groupId>org.xmlunit</groupId>
        <artifactId>xmlunit-core</artifactId>
        <!-- version transitive in spring-boot-starter-parent 2.6.7 -->
        <version>2.8.4</version>
        <scope>test</test>
    </dependency>
    

    ResourceUtil.java:

    import org.apache.commons.lang3.StringUtils;
    import org.w3c.dom.Document;
    import org.w3c.dom.Node;
    import org.w3c.dom.NodeList;
    import org.xml.sax.SAXException;
    
    import javax.xml.parsers.DocumentBuilder;
    import javax.xml.parsers.DocumentBuilderFactory;
    import javax.xml.parsers.ParserConfigurationException;
    import javax.xml.transform.*;
    import javax.xml.transform.dom.DOMSource;
    import javax.xml.transform.stream.StreamResult;
    import javax.xml.transform.stream.StreamSource;
    import javax.xml.xpath.XPath;
    import javax.xml.xpath.XPathConstants;
    import javax.xml.xpath.XPathExpressionException;
    import javax.xml.xpath.XPathFactory;
    import java.io.IOException;
    import java.io.InputStream;
    import java.io.StringWriter;
    import java.net.URL;
    
    public class ResourceUtil {
        private static final DocumentBuilderFactory XML_DOCUMENT_BUILDER_FACTORY = DocumentBuilderFactory.newInstance();
        private static final XPathFactory X_PATH_FACTORY = XPathFactory.newInstance();
    
        private ResourceUtil() {}
    
        /** Reads an xml file named after the testcase class (e.g. MyTestcase.class
          * -> MyTestcase.xml) and parses the data at the supplied xPath expression. */
        public static String xmlData(Class<?> testClass, String xPathExpression) {
            return getXmlDocumentAsString(testClass, testClass.getSimpleName() + ".xml", xPathExpression);
        }
    
        /** Reads the specified xml file and parses the data at the supplied xPath
          * expression. The xml file is expected in the same package/directory as
          * the testcase class. */
        private static String getXmlDocumentAsString(Class<?> ctxtClass, String fileName, String xPathExpression) {
            Document xmlDocument = getXmlDocument(ctxtClass, fileName);
            XPath xPath = X_PATH_FACTORY.newXPath();
    
            try {
                Node subNode = (Node)xPath.compile(xPathExpression).evaluate(xmlDocument, XPathConstants.NODE);
                return nodeToString(subNode.getChildNodes());
            } catch (TransformerException | XPathExpressionException var6) {
                throw new IllegalArgumentException("Unable to read value of '" + xPathExpression + "' from file " + fileName, var6);
            }
        }
    
        /** Reads the specified xml file and returns a Document instance of the
          * xml data. The xml file is expected in the same package/directory as
          * the testcase class. */
        private static Document getXmlDocument(Class<?> ctxtClass, String xmlFileName) {
            InputStream inputStream = getResourceFile(ctxtClass, xmlFileName);
    
            try {
                DocumentBuilder builder = XML_DOCUMENT_BUILDER_FACTORY.newDocumentBuilder();
                return builder.parse(inputStream);
            } catch (SAXException | IOException | ParserConfigurationException var4) {
                throw new IllegalStateException("Unable to read xml content from file '" + xmlFileName + "'.", var4);
            }
        }
    
        /** Returns an InputStream of the specified xml file. The xml file is
          * expected in the same package/directory as the testcase class. */
        private static InputStream getResourceFile(Class<?> ctxtClass, String fileName) {
          String pkgPath = StringUtils.replaceChars(ctxtClass.getPackage().getName(), ".", "/");
          String filePath = "/" + pkgPath + "/" + fileName;
          URL url = ctxtClass.getResource(filePath);
          if (url == null) {
              throw new IllegalArgumentException("Resource file not found: " + filePath);
          }
          return ResourceTestUtil.class.getResourceAsStream(filePath);
        }
    
        /** Deserializes a NodeList to a String with (formatted) xml. */
        private static String nodeToString(NodeList nodeList) throws TransformerException {
            StringWriter buf = new StringWriter();
            Transformer xform = TransformerFactory.newInstance().newTransformer(getXsltAsResource());
            xform.setOutputProperty("omit-xml-declaration", "yes");
            xform.setOutputProperty("indent", "no");
    
            for(int i = 0; i < nodeList.getLength(); ++i) {
                xform.transform(new DOMSource(nodeList.item(i)), new StreamResult(buf));
            }
    
            return buf.toString().trim();
        }
    
        /** Returns a Source of an XSLT file for formatting xml data */
        private static Source getXsltAsResource() {
            return new StreamSource(ResourceTestUtil.class.getResourceAsStream("xmlstylesheet.xslt"));
        }
    
    

    xmlstylesheet.xslt (works for me, you may alter to your preferences):

    <xsl:stylesheet version="1.0" xmlns:xsl="http://www.w3.org/1999/XSL/Transform">
        <xsl:strip-space elements="*"/>
        <xsl:output method="xml" encoding="UTF-8"/>
    
        <xsl:template match="@*|node()">
            <xsl:copy>
                <xsl:apply-templates select="@*|node()"/>
            </xsl:copy>
        </xsl:template>
    
    </xsl:stylesheet>
    

    MyTestcase.java:

    import org.xmlunit.builder.DiffBuilder;
    import org.xmlunit.diff.DefaultNodeMatcher;
    import org.xmlunit.diff.Diff;
    import org.xmlunit.diff.ElementSelectors;
    
    import static ResourceUtil.xmldata;
    
    public class MyTestcase {
        @Test
        void testcase1() {
            // Execute logic to generate xml
            String xml = ...
           
            assertXmlEquals(xmlData(getClass(), "/test/testcase1/result"), xml);
        }
    
        /** Compare xml using XmlUnit assertion. Expected and actual xml need
          * to be equal in content (ignoring whitespace and xml tag order) */
        void assertXmlEquals(String expectedXml, String testXml) {
            Diff diff = DiffBuilder.compare(expectedXml)
                    .withTest(testXml)
                    .ignoreWhitespace()
                    .checkForSimilar()
                    .withNodeMatcher(new DefaultNodeMatcher(ElementSelectors.byNameAndText, ElementSelectors.byName))
                    .build();
            assertFalse(diff.fullDescription(), diff.hasDifferences());
        }
    
    }
    

    MyTestcase.xml:

    <test>
        <testcase1>
            <result>
                <myData>
                    ...
                </myData>
            </result>
        </testcase1>
    </test>
    

    Best regards, David