diff --git a/spring-test/src/main/java/org/springframework/test/web/reactive/server/DefaultWebTestClient.java b/spring-test/src/main/java/org/springframework/test/web/reactive/server/DefaultWebTestClient.java index 7efae18795..30c66b846b 100644 --- a/spring-test/src/main/java/org/springframework/test/web/reactive/server/DefaultWebTestClient.java +++ b/spring-test/src/main/java/org/springframework/test/web/reactive/server/DefaultWebTestClient.java @@ -27,6 +27,7 @@ import java.util.Optional; import java.util.concurrent.atomic.AtomicLong; import java.util.function.Consumer; import java.util.function.Function; +import javax.xml.xpath.XPathExpressionException; import org.reactivestreams.Publisher; import reactor.core.publisher.Flux; @@ -40,6 +41,7 @@ import org.springframework.http.client.reactive.ClientHttpConnector; import org.springframework.http.client.reactive.ClientHttpRequest; import org.springframework.lang.Nullable; import org.springframework.test.util.JsonExpectationsHelper; +import org.springframework.test.util.XmlExpectationsHelper; import org.springframework.util.Assert; import org.springframework.util.MimeType; import org.springframework.util.MultiValueMap; @@ -476,11 +478,34 @@ class DefaultWebTestClient implements WebTestClient { return this; } + @Override + public BodyContentSpec xml(String expectedXml) { + this.result.assertWithDiagnostics(() -> { + try { + new XmlExpectationsHelper().assertXmlEqual(expectedXml, getBodyAsString()); + } + catch (Exception ex) { + throw new AssertionError("XML parsing error", ex); + } + }); + return this; + } + @Override public JsonPathAssertions jsonPath(String expression, Object... args) { return new JsonPathAssertions(this, getBodyAsString(), expression, args); } + @Override + public XpathAssertions xpath(String expression, Map namespaces, Object... args) { + try { + return new XpathAssertions(this, expression, namespaces, args); + } + catch (XPathExpressionException ex) { + throw new AssertionError("XML parsing error", ex); + } + } + private String getBodyAsString() { byte[] body = this.result.getResponseBody(); if (body == null || body.length == 0) { diff --git a/spring-test/src/main/java/org/springframework/test/web/reactive/server/WebTestClient.java b/spring-test/src/main/java/org/springframework/test/web/reactive/server/WebTestClient.java index d0a51894f2..fd8ca30c9f 100644 --- a/spring-test/src/main/java/org/springframework/test/web/reactive/server/WebTestClient.java +++ b/spring-test/src/main/java/org/springframework/test/web/reactive/server/WebTestClient.java @@ -819,6 +819,14 @@ public interface WebTestClient { */ BodyContentSpec json(String expectedJson); + /** + * Parse the expected and actual response content as XML and perform a + * comparison verifying the same structure. + * @param expectedXml the expected JSON content. + * @since 5.1 + */ + BodyContentSpec xml(String expectedXml); + /** * Access to response body assertions using a * JsonPath expression @@ -830,6 +838,32 @@ public interface WebTestClient { */ JsonPathAssertions jsonPath(String expression, Object... args); + /** + * Access to response body assertions using an XPath expression to inspect a specific + * subset of the body. + *

The XPath expression can be a parameterized string using + * formatting specifiers as defined in {@link String#format}. + * @param expression The XPath expression + * @param args arguments to parameterize the expression + * @see #xpath(String, Map, Object...) + * @since 5.1 + */ + default XpathAssertions xpath(String expression, Object... args){ + return xpath(expression, null, args); + } + + /** + * Access to response body assertions with specific namespaces using an XPath + * expression to inspect a specific subset of the body. + *

The XPath expression can be a parameterized string using + * formatting specifiers as defined in {@link String#format}. + * @param expression The XPath expression + * @param namespaces The namespaces + * @param args arguments to parameterize the expression + * @since 5.1 + */ + XpathAssertions xpath(String expression, Map namespaces, Object... args); + /** * Assert the response body content with the given {@link Consumer}. * @param consumer the consumer for the response body; the input diff --git a/spring-test/src/main/java/org/springframework/test/web/reactive/server/XpathAssertions.java b/spring-test/src/main/java/org/springframework/test/web/reactive/server/XpathAssertions.java new file mode 100644 index 0000000000..c3d966b5f4 --- /dev/null +++ b/spring-test/src/main/java/org/springframework/test/web/reactive/server/XpathAssertions.java @@ -0,0 +1,159 @@ +/* + * Copyright 2002-2018 the original author or authors. + * + * Licensed 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.springframework.test.web.reactive.server; + +import java.nio.charset.StandardCharsets; +import java.util.Map; +import java.util.Optional; +import javax.xml.xpath.XPathExpressionException; + +import org.hamcrest.Matcher; +import org.w3c.dom.Node; + +import org.springframework.http.HttpHeaders; +import org.springframework.lang.Nullable; +import org.springframework.test.util.XpathExpectationsHelper; +import org.springframework.util.MimeType; + +/** + * XPath assertions for a {@link WebTestClient}. + * + * @author Eric Deandrea + * @since 5.1 + */ +public class XpathAssertions { + + private final WebTestClient.BodyContentSpec bodySpec; + + private final XpathExpectationsHelper xpathHelper; + + XpathAssertions(WebTestClient.BodyContentSpec spec, String expression, @Nullable Map namespaces, Object... args) throws XPathExpressionException { + this.bodySpec = spec; + this.xpathHelper = new XpathExpectationsHelper(expression, namespaces, args); + } + + /** + * Applies {@link XpathExpectationsHelper#assertString(byte[], String, String)} + */ + public WebTestClient.BodyContentSpec isEqualTo(String expectedValue) { + return performXmlAssertionAndHandleError(() -> this.xpathHelper.assertString(getResponseBody(), getDefinedEncoding(), expectedValue)); + } + + /** + * Applies {@link XpathExpectationsHelper#assertNumber(byte[], String, Double)} + */ + public WebTestClient.BodyContentSpec isEqualTo(Double expectedValue) { + return performXmlAssertionAndHandleError(() -> this.xpathHelper.assertNumber(getResponseBody(), getDefinedEncoding(), expectedValue)); + } + + /** + * Applies {@link XpathExpectationsHelper#assertBoolean(byte[], String, boolean)} + */ + public WebTestClient.BodyContentSpec isEqualTo(boolean expectedValue) { + return performXmlAssertionAndHandleError(() -> this.xpathHelper.assertBoolean(getResponseBody(), getDefinedEncoding(), expectedValue)); + } + + /** + * Applies {@link XpathExpectationsHelper#exists(byte[], String)} + */ + public WebTestClient.BodyContentSpec exists() { + return performXmlAssertionAndHandleError(() -> this.xpathHelper.exists(getResponseBody(), getDefinedEncoding())); + } + + /** + * Applies {@link XpathExpectationsHelper#doesNotExist(byte[], String)} + */ + public WebTestClient.BodyContentSpec doesNotExist() { + return performXmlAssertionAndHandleError(() -> this.xpathHelper.doesNotExist(getResponseBody(), getDefinedEncoding())); + } + + /** + * Applies {@link XpathExpectationsHelper[#assertNodeCount(byte[], String, int)} + */ + public WebTestClient.BodyContentSpec nodeCount(int expectedCount) { + return performXmlAssertionAndHandleError(() -> this.xpathHelper.assertNodeCount(getResponseBody(), getDefinedEncoding(), expectedCount)); + } + + /** + * Applies {@link XpathExpectationsHelper#assertNodeCount(byte[], String, Matcher)} + */ + public WebTestClient.BodyContentSpec nodeCount(Matcher matcher) { + return performXmlAssertionAndHandleError(() -> this.xpathHelper.assertNodeCount(getResponseBody(), getDefinedEncoding(), matcher)); + } + + /** + * Applies {@link XpathExpectationsHelper#assertNode(byte[], String, Matcher)} + */ + public WebTestClient.BodyContentSpec nodeMatches(Matcher matcher) { + return performXmlAssertionAndHandleError(() -> this.xpathHelper.assertNode(getResponseBody(), getDefinedEncoding(), matcher)); + } + + /** + * Applies {@link XpathExpectationsHelper#assertString(byte[], String, Matcher)} + */ + public WebTestClient.BodyContentSpec matchesString(Matcher matcher) { + return performXmlAssertionAndHandleError(() -> this.xpathHelper.assertString(getResponseBody(), getDefinedEncoding(), matcher)); + } + + /** + * Applies {@link XpathExpectationsHelper#assertNumber(byte[], String, Matcher)} + */ + public WebTestClient.BodyContentSpec matchesNumber(Matcher matcher) { + return performXmlAssertionAndHandleError(() -> this.xpathHelper.assertNumber(getResponseBody(), getDefinedEncoding(), matcher)); + } + + private WebTestClient.BodyContentSpec performXmlAssertionAndHandleError(AssertionThrowingRunnable assertion) { + assertion.run(); + return this.bodySpec; + } + + private byte[] getResponseBody() { + return getResult().getResponseBody(); + } + + private EntityExchangeResult getResult() { + return this.bodySpec.returnResult(); + } + + private String getDefinedEncoding() { + return Optional.ofNullable(getResult()) + .map(EntityExchangeResult::getResponseHeaders) + .map(HttpHeaders::getContentType) + .map(MimeType::getCharset) + .orElse(StandardCharsets.UTF_8) + .name(); + } + + /** + * Lets us be able to use lambda expressions that could throw checked exceptions, since + * {@link XpathExpectationsHelper} throws {@link Exception} on its methods. + */ + @FunctionalInterface + private interface AssertionThrowingRunnable extends Runnable { + void runThrows() throws Exception; + + @Override + default void run() { + try { + runThrows(); + } + catch (Exception ex) { + throw new AssertionError("XML parsing error", ex); + } + } + } +} \ No newline at end of file diff --git a/spring-test/src/test/java/org/springframework/test/web/reactive/server/samples/XmlContentTests.java b/spring-test/src/test/java/org/springframework/test/web/reactive/server/samples/XmlContentTests.java new file mode 100644 index 0000000000..10abc2305f --- /dev/null +++ b/spring-test/src/test/java/org/springframework/test/web/reactive/server/samples/XmlContentTests.java @@ -0,0 +1,228 @@ +/* + * Copyright 2002-2018 the original author or authors. + * + * Licensed 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.springframework.test.web.reactive.server.samples; + +import java.net.URI; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +import javax.validation.constraints.NotNull; +import javax.xml.bind.annotation.XmlAccessType; +import javax.xml.bind.annotation.XmlAccessorType; +import javax.xml.bind.annotation.XmlElement; +import javax.xml.bind.annotation.XmlElementWrapper; +import javax.xml.bind.annotation.XmlRootElement; + +import org.hamcrest.Matchers; +import org.junit.Test; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.test.web.reactive.server.WebTestClient; +import org.springframework.util.ObjectUtils; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +/** + * Samples of tests using {@link WebTestClient} with serialized XML content. + * + * @author Eric Deandrea + * @since 5.1 + */ +public class XmlContentTests { + + private static final String PEOPLE_XML = + "" + + "" + + "Jane" + + "Jason" + + "John" + + ""; + + private final WebTestClient client = WebTestClient.bindToController(new PersonController()).build(); + + @Test + public void xmlContent() { + this.client.get().uri("/persons") + .accept(MediaType.APPLICATION_XML) + .exchange() + .expectStatus().isOk() + .expectBody().xml(PEOPLE_XML); + } + + @Test + public void xpathIsEqualTo() { + this.client.get().uri("/persons") + .accept(MediaType.APPLICATION_XML) + .exchange() + .expectStatus().isOk() + .expectBody() + .xpath("/").exists() + .xpath("/people").exists() + .xpath("/people/people").exists() + .xpath("/people/people/person").exists() + .xpath("/people/people/person").nodeCount(3) + .xpath("/people/people/person[1]/name").isEqualTo("Jane") + .xpath("/people/people/person[2]/name").isEqualTo("Jason") + .xpath("/people/people/person[3]/name").isEqualTo("John"); + } + + @Test + public void xpathMatches() { + this.client.get().uri("/persons") + .accept(MediaType.APPLICATION_XML) + .exchange() + .expectStatus().isOk() + .expectBody() + .xpath("/").exists() + .xpath("/people").exists() + .xpath("/people/people").exists() + .xpath("/people/people/person").exists() + .xpath("/people/people/person").nodeCount(3) + .xpath("//person/name").matchesString(Matchers.startsWith("J")); + } + + @Test + public void xpathContainsSubstringViaRegex() { + this.client.get().uri("/persons/John") + .accept(MediaType.APPLICATION_XML) + .exchange() + .expectStatus().isOk() + .expectBody() + .xpath("//name[contains(text(), 'oh')]").exists(); + } + + @Test + public void postXmlContent() { + this.client.post().uri("/persons") + .contentType(MediaType.APPLICATION_XML) + .syncBody("John") + .exchange() + .expectStatus().isCreated() + .expectHeader().valueEquals(HttpHeaders.LOCATION, "/persons/John") + .expectBody().isEmpty(); + } + + @XmlRootElement + private static class Person { + + @NotNull + private String name; + + public Person() { + } + + public Person(String name) { + this.name = name; + } + + public String getName() { + return this.name; + } + + public void setName(String name) { + this.name = name; + } + + public Person name(String name) { + setName(name); + return this; + } + + @Override + public boolean equals(Object other) { + if (this == other) { + return true; + } + if (!(other instanceof Person)) { + return false; + } + Person otherPerson = (Person) other; + return ObjectUtils.nullSafeEquals(this.name, otherPerson.name); + } + + @Override + public int hashCode() { + return Person.class.hashCode(); + } + + @Override + public String toString() { + return "Person [name=" + this.name + "]"; + } + } + + @SuppressWarnings("unused") + @XmlRootElement(name="people") + @XmlAccessorType(XmlAccessType.FIELD) + private static class PeopleWrapper { + + @XmlElementWrapper(name="people") + @XmlElement(name="person") + private final List people = new ArrayList<>(); + + public PeopleWrapper() { + } + + public PeopleWrapper(List people) { + this.people.addAll(people); + } + + public PeopleWrapper(Person... people) { + this.people.addAll(Arrays.asList(people)); + } + + public List getPeople() { + return this.people; + } + } + + @RestController + @RequestMapping("/persons") + static class PersonController { + + @GetMapping(produces = MediaType.APPLICATION_XML_VALUE) + Mono getPersons() { + return Mono.just(new PeopleWrapper(new Person("Jane"), new Person("Jason"), new Person("John"))); + } + + @GetMapping(path = "/{name}", produces = MediaType.APPLICATION_XML_VALUE) + Mono getPerson(@PathVariable String name) { + return Mono.just(new Person(name)); + } + + @PostMapping(consumes = MediaType.APPLICATION_XML_VALUE) + Mono> savePeople(@RequestBody Flux person) { + return person + .map(Person::getName) + .map(name -> String.format("/persons/%s", name)) + .map(URI::create) + .map(ResponseEntity::created) + .map(ResponseEntity.BodyBuilder::build) + .next(); + } + } + +} \ No newline at end of file