如何在 fasterXml Jackson XmlMapper 中保留 xml 属性?

How to keep xml attribute in fasterXml Jackson XmlMapper?

我正在编写测试生成 xml 结构的测试用例。我通过 xml 文件提供 xml 结构。我目前正在使用 FasterXMLs Jackson XmlMapper 来读取和测试预期的 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>

我有一个 xml 文件,其中包含预期的 xml(例如:/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>

我的代码看起来像这样(简化):

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;
    }

}

但是返回的预期 xml 看起来像这样:

            <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>

元素 sizeGroup 的前一个属性 id 被映射为子元素并且未通过我的测试。我如何告诉 XmlMapper 保留 xml 元素的属性?

此致, 大卫

我无法告诉 XmlMapper 从加载的 xml 文件中保留 xml 标签的属性。但是我找到了另一种方法,通过使用 xPath 表达式解析 xml 测试数据。

如果预期和实际 xml 包含不同的空格或 xml 标记顺序,则证明简单的 String.equals(...) 是不可靠的。幸运的是,有一个用于比较 xml 的库。 XmlUnit!

额外的依赖(从 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(适合我,您可以根据自己的喜好更改):

<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>

此致, 大卫