diff --git a/samples/webflux-websocket/build.gradle b/samples/webflux-websocket/build.gradle index fa8ce2d70..da812d479 100644 --- a/samples/webflux-websocket/build.gradle +++ b/samples/webflux-websocket/build.gradle @@ -13,7 +13,10 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-webflux' implementation 'org.springframework.boot:spring-boot-starter-actuator' developmentOnly 'org.springframework.boot:spring-boot-devtools' + testImplementation project(':spring-graphql-test') + testImplementation 'org.springframework:spring-webflux' testImplementation 'org.springframework.boot:spring-boot-starter-test' + testImplementation 'io.projectreactor:reactor-test' } test { useJUnitPlatform() diff --git a/samples/webflux-websocket/src/test/java/io/spring/sample/graphql/SubscriptionGraphQLTests.java b/samples/webflux-websocket/src/test/java/io/spring/sample/graphql/SubscriptionGraphQLTests.java new file mode 100644 index 000000000..5f7f14cef --- /dev/null +++ b/samples/webflux-websocket/src/test/java/io/spring/sample/graphql/SubscriptionGraphQLTests.java @@ -0,0 +1,73 @@ +/* + * Copyright 2002-2021 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 + * + * https://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 io.spring.sample.graphql; + +import graphql.GraphQL; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import reactor.core.publisher.Flux; +import reactor.test.StepVerifier; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.graphql.WebGraphQLService; +import org.springframework.graphql.test.query.GraphQLTester; + +/** + * GraphQL subscription tests directly via {@link GraphQL}. + */ +@SpringBootTest +public class SubscriptionGraphQLTests { + + private GraphQLTester graphQLTester; + + + @BeforeEach + public void setUp(@Autowired WebGraphQLService service) { + this.graphQLTester = GraphQLTester.create(service); + } + + + @Test + void subscriptionWithEntityPath() { + String query = "subscription { greetings }"; + + Flux result = this.graphQLTester.query(query) + .executeSubscription() + .toFlux("greetings", String.class); + + StepVerifier.create(result) + .expectNext("Hi", "Bonjour", "Hola", "Ciao", "Zdravo") + .verifyComplete(); + } + + @Test + void subscriptionWithResponseSpec() { + String query = "subscription { greetings }"; + + Flux result = this.graphQLTester.query(query) + .executeSubscription() + .toFlux(); + + StepVerifier.create(result) + .consumeNextWith(spec -> spec.path("greetings").valueExists()) + .consumeNextWith(spec -> spec.path("greetings").matchesJson("\"Bonjour\"")) + .consumeNextWith(spec -> spec.path("greetings").matchesJson("\"Hola\"")) + .expectNextCount(2) + .verifyComplete(); + } + +} diff --git a/samples/webmvc-http/build.gradle b/samples/webmvc-http/build.gradle index e7c0b87c6..437b136af 100644 --- a/samples/webmvc-http/build.gradle +++ b/samples/webmvc-http/build.gradle @@ -16,6 +16,7 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-actuator' developmentOnly 'org.springframework.boot:spring-boot-devtools' runtimeOnly 'com.h2database:h2' + testImplementation project(':spring-graphql-test') testImplementation 'org.springframework:spring-webflux' testImplementation 'org.springframework.boot:spring-boot-starter-test' } diff --git a/samples/webmvc-http/src/test/java/io/spring/sample/graphql/project/JsonRequest.java b/samples/webmvc-http/src/test/java/io/spring/sample/graphql/project/JsonRequest.java deleted file mode 100644 index 5e8f01bdf..000000000 --- a/samples/webmvc-http/src/test/java/io/spring/sample/graphql/project/JsonRequest.java +++ /dev/null @@ -1,68 +0,0 @@ -/* - * Copyright 2002-2021 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 - * - * https://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 io.spring.sample.graphql.project; - -import java.util.Collections; -import java.util.LinkedHashMap; -import java.util.Map; - -import org.springframework.lang.Nullable; -import org.springframework.util.CollectionUtils; - -class JsonRequest { - - private final String query; - - @Nullable - private final String operationName; - - private final Map variables; - - - private JsonRequest(String query, @Nullable String operationName, Map variables) { - this.query = query; - this.operationName = operationName; - this.variables = (!CollectionUtils.isEmpty(variables) ? - new LinkedHashMap<>(variables) : Collections.emptyMap()); - } - - - public String getQuery() { - return query; - } - - public String query() { - return this.query; - } - - @Nullable - public String operationName() { - return this.operationName; - } - - public Map variables() { - return this.variables; - } - - public static JsonRequest create(String query) { - return new JsonRequest(query, null, Collections.emptyMap()); - } - - public static JsonRequest create(String query, String operationName, Map variables) { - return new JsonRequest(query, operationName, variables); - } - -} diff --git a/samples/webmvc-http/src/test/java/io/spring/sample/graphql/project/JsonResponse.java b/samples/webmvc-http/src/test/java/io/spring/sample/graphql/project/JsonResponse.java deleted file mode 100644 index 8db0ecffa..000000000 --- a/samples/webmvc-http/src/test/java/io/spring/sample/graphql/project/JsonResponse.java +++ /dev/null @@ -1,46 +0,0 @@ -/* - * Copyright 2002-2021 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 - * - * https://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 io.spring.sample.graphql.project; - -import java.util.Collections; -import java.util.Map; - -import org.springframework.lang.Nullable; - -class JsonResponse { - - private Map data = Collections.emptyMap(); - - private Map> errors = Collections.emptyMap(); - - - public void setData(@Nullable Map data) { - this.data = data; - } - - public T getDataEntry() { - return this.data.values().iterator().next(); - } - - public void setErrors(@Nullable Map> errors) { - this.errors = errors; - } - - public Map> getErrors() { - return this.errors; - } - -} diff --git a/samples/webmvc-http/src/test/java/io/spring/sample/graphql/project/MockWebTestClientTests.java b/samples/webmvc-http/src/test/java/io/spring/sample/graphql/project/MockMvcGraphQLTests.java similarity index 54% rename from samples/webmvc-http/src/test/java/io/spring/sample/graphql/project/MockWebTestClientTests.java rename to samples/webmvc-http/src/test/java/io/spring/sample/graphql/project/MockMvcGraphQLTests.java index 97bbba81a..bdc2d1feb 100644 --- a/samples/webmvc-http/src/test/java/io/spring/sample/graphql/project/MockWebTestClientTests.java +++ b/samples/webmvc-http/src/test/java/io/spring/sample/graphql/project/MockMvcGraphQLTests.java @@ -15,17 +15,13 @@ */ package io.spring.sample.graphql.project; -import java.util.List; -import java.util.function.Consumer; - import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.core.ParameterizedTypeReference; -import org.springframework.http.MediaType; +import org.springframework.graphql.test.query.GraphQLTester; import org.springframework.test.web.reactive.server.WebTestClient; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.client.MockMvcWebTestClient; @@ -33,23 +29,22 @@ import static org.assertj.core.api.Assertions.assertThat; /** - * Example tests using {@link WebTestClient} to connect to {@link MockMvc} as - * the "mock" server, i.e. without running an HTTP server. + * GraphQL requests via {@link WebTestClient} connecting to {@link MockMvc}. */ @SpringBootTest @AutoConfigureMockMvc -public class MockWebTestClientTests { +public class MockMvcGraphQLTests { + + private GraphQLTester graphQLTester; - private WebTestClient client; @BeforeEach public void setUp(@Autowired MockMvc mockMvc) { - this.client = MockMvcWebTestClient.bindTo(mockMvc) - .baseUrl("/graphql") - .defaultHeaders(headers -> headers.setContentType(MediaType.APPLICATION_JSON)) - .build(); + WebTestClient client = MockMvcWebTestClient.bindTo(mockMvc).baseUrl("/graphql").build(); + this.graphQLTester = GraphQLTester.create(client); } + @Test void jsonPath() { String query = "{" + @@ -60,13 +55,12 @@ void jsonPath() { " }" + "}"; - this.client.post().bodyValue(JsonRequest.create(query)) - .exchange() - .expectStatus().isOk() - .expectBody().jsonPath("$.data.project.releases[*].version") - .value((Consumer>) versions -> { - assertThat(versions).hasSizeGreaterThan(1); - }); + this.graphQLTester.query(query) + .execute() + .path("project.releases[*].version") + .entityList(String.class) + .hasSizeGreaterThan(1); + } @Test @@ -77,18 +71,10 @@ void jsonContent() { " }" + "}"; - String expectedJson = "{" + - " \"data\":{" + - " \"project\":{" + - " \"repositoryUrl\":\"http://github.com/spring-projects/spring-framework\"" + - " }" + - " }" + - "}"; - - this.client.post().bodyValue(JsonRequest.create(query)) - .exchange() - .expectStatus().isOk() - .expectBody().json(expectedJson); + this.graphQLTester.query(query) + .execute() + .path("project") + .matchesJson("{\"repositoryUrl\":\"http://github.com/spring-projects/spring-framework\"}"); } @Test @@ -101,14 +87,11 @@ void decodedResponse() { " }" + "}"; - this.client.post().bodyValue(JsonRequest.create(query)) - .exchange() - .expectStatus().isOk() - .expectBody(new ParameterizedTypeReference>() {}) - .consumeWith(exchangeResult -> { - Project project = exchangeResult.getResponseBody().getDataEntry(); - assertThat(project.getReleases()).hasSizeGreaterThan(1); - }); + this.graphQLTester.query(query) + .execute() + .path("project") + .entity(Project.class) + .satisfies(project -> assertThat(project.getReleases()).hasSizeGreaterThan(1)); } } diff --git a/settings.gradle b/settings.gradle index 6cc8b2da8..a9ecddc95 100644 --- a/settings.gradle +++ b/settings.gradle @@ -15,4 +15,4 @@ pluginManagement { } rootProject.name = 'spring-graphql' -include 'spring-graphql-web', 'graphql-spring-boot-starter', 'samples:webmvc-http', 'samples:webflux-websocket' +include 'spring-graphql-web', 'spring-graphql-test', 'graphql-spring-boot-starter', 'samples:webmvc-http', 'samples:webflux-websocket' diff --git a/spring-graphql-test/build.gradle b/spring-graphql-test/build.gradle new file mode 100644 index 000000000..1c1286184 --- /dev/null +++ b/spring-graphql-test/build.gradle @@ -0,0 +1,63 @@ + +plugins { + id 'io.spring.dependency-management' version '1.0.10.RELEASE' + id 'java-library' + id 'maven' +} + +description = "Spring Support for Testing GraphQL Applications" + +java { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 +} + +dependencyManagement { + imports { + mavenBom "com.fasterxml.jackson:jackson-bom:2.12.3" + mavenBom "io.projectreactor:reactor-bom:2020.0.6" + mavenBom "org.springframework:spring-framework-bom:5.3.6" + } + generatedPomCustomization { + enabled = false + } +} + +dependencies { + api project(':spring-graphql-web') + api 'com.graphql-java:graphql-java:16.2' + api 'io.projectreactor:reactor-core' + api 'org.springframework:spring-context' + api 'org.springframework:spring-test' + api 'com.jayway.jsonpath:json-path:2.5.0' + + compileOnly "javax.annotation:javax.annotation-api:1.3.2" + compileOnly 'org.springframework:spring-webflux' + compileOnly 'org.springframework:spring-webmvc' + compileOnly 'org.springframework:spring-websocket' + compileOnly 'javax.servlet:javax.servlet-api:4.0.1' + compileOnly 'org.skyscreamer:jsonassert:1.5.0' + + testImplementation 'org.junit.jupiter:junit-jupiter:5.7.1' + testImplementation 'org.assertj:assertj-core:3.19.0' + testImplementation 'org.mockito:mockito-core:3.8.0' + testImplementation 'org.skyscreamer:jsonassert:1.5.0' + testImplementation 'org.springframework:spring-webflux' + testImplementation 'org.springframework:spring-test' + testImplementation 'io.projectreactor:reactor-test' + testImplementation 'io.projectreactor.netty:reactor-netty' + testImplementation 'com.squareup.okhttp3:mockwebserver:3.14.9' + testImplementation 'com.fasterxml.jackson.core:jackson-databind' + + testRuntime 'org.apache.logging.log4j:log4j-core:2.14.1' + testRuntime 'org.apache.logging.log4j:log4j-slf4j-impl:2.14.1' +} + +test { + useJUnitPlatform() + testLogging { + events "passed", "skipped", "failed" + } +} + +apply from: "${rootDir}/gradle/publishing.gradle" diff --git a/spring-graphql-test/src/main/java/org/springframework/graphql/test/query/DefaultGraphQLTester.java b/spring-graphql-test/src/main/java/org/springframework/graphql/test/query/DefaultGraphQLTester.java new file mode 100644 index 000000000..628b8520a --- /dev/null +++ b/spring-graphql-test/src/main/java/org/springframework/graphql/test/query/DefaultGraphQLTester.java @@ -0,0 +1,730 @@ +/* + * Copyright 2002-2021 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 + * + * https://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.graphql.test.query; + +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.function.Consumer; +import java.util.function.Predicate; + +import com.jayway.jsonpath.Configuration; +import com.jayway.jsonpath.DocumentContext; +import com.jayway.jsonpath.JsonPath; +import com.jayway.jsonpath.PathNotFoundException; +import com.jayway.jsonpath.TypeRef; +import com.jayway.jsonpath.spi.json.JacksonJsonProvider; +import com.jayway.jsonpath.spi.json.JsonProvider; +import com.jayway.jsonpath.spi.mapper.JacksonMappingProvider; +import graphql.ExecutionResult; +import graphql.GraphQL; +import graphql.GraphQLError; +import org.reactivestreams.Publisher; +import reactor.core.publisher.Flux; + +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.graphql.RequestInput; +import org.springframework.graphql.WebGraphQLService; +import org.springframework.graphql.WebInput; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.lang.Nullable; +import org.springframework.test.util.AssertionErrors; +import org.springframework.test.util.JsonExpectationsHelper; +import org.springframework.test.util.JsonPathExpectationsHelper; +import org.springframework.test.web.reactive.server.EntityExchangeResult; +import org.springframework.test.web.reactive.server.FluxExchangeResult; +import org.springframework.test.web.reactive.server.WebTestClient; +import org.springframework.util.Assert; +import org.springframework.util.ClassUtils; +import org.springframework.util.CollectionUtils; +import org.springframework.util.StringUtils; + +/** + * Default implementation of {@link GraphQLTester}. + */ +class DefaultGraphQLTester implements GraphQLTester { + + private static final boolean jackson2Present; + + static { + ClassLoader classLoader = DefaultGraphQLTester.class.getClassLoader(); + jackson2Present = ClassUtils.isPresent("com.fasterxml.jackson.databind.ObjectMapper", classLoader) && + ClassUtils.isPresent("com.fasterxml.jackson.core.JsonGenerator", classLoader); + } + + + private final RequestStrategy requestStrategy; + + private final Configuration jsonPathConfig; + + + DefaultGraphQLTester(WebTestClient client) { + this.jsonPathConfig = initJsonPathConfig(); + this.requestStrategy = new WebTestClientRequestStrategy(client, this.jsonPathConfig); + } + + DefaultGraphQLTester(WebGraphQLService service) { + this.jsonPathConfig = initJsonPathConfig(); + this.requestStrategy = new DirectRequestStrategy(service, this.jsonPathConfig); + } + + private Configuration initJsonPathConfig() { + return (jackson2Present ? Jackson2Configuration.create() : Configuration.builder().build()); + } + + + @Override + public QuerySpec query(String query) { + return new DefaultQuerySpec(query); + } + + + /** + * Encapsulate how a GraphQL request is performed. + */ + interface RequestStrategy { + + /** + * Perform a query with the given {@link RequestInput} container. + */ + GraphQLTester.ResponseSpec execute(RequestInput input); + + /** + * Perform a subscription with the given {@link RequestInput} container. + */ + GraphQLTester.SubscriptionSpec executeSubscription(RequestInput input); + + } + + + /** + * {@link RequestStrategy} that works as an HTTP client with requests + * executed through {@link WebTestClient} that in turn may work connect with + * or without a live server for Spring MVC and WebFlux. + */ + private static class WebTestClientRequestStrategy implements RequestStrategy { + + private final WebTestClient client; + + private final Configuration jsonPathConfig; + + WebTestClientRequestStrategy(WebTestClient client, Configuration jsonPathConfig) { + this.client = client; + this.jsonPathConfig = jsonPathConfig; + } + + @Override + public ResponseSpec execute(RequestInput requestInput) { + EntityExchangeResult result = this.client.post() + .contentType(MediaType.APPLICATION_JSON) + .bodyValue(requestInput) + .exchange() + .expectStatus().isOk() + .expectHeader().contentType(MediaType.APPLICATION_JSON) + .expectBody() + .returnResult(); + + byte[] bytes = result.getResponseBodyContent(); + Assert.notNull(bytes, "Expected GraphQL response content"); + String content = new String(bytes, StandardCharsets.UTF_8); + DocumentContext documentContext = JsonPath.parse(content, this.jsonPathConfig); + + return new DefaultResponseSpec(documentContext, result::assertWithDiagnostics); + } + + @Override + public SubscriptionSpec executeSubscription(RequestInput queryInput) { + FluxExchangeResult result = this.client.post() + .contentType(MediaType.APPLICATION_JSON) + .accept(MediaType.TEXT_EVENT_STREAM) + .bodyValue(queryInput) + .exchange() + .expectStatus().isOk() + .expectHeader().contentType(MediaType.TEXT_EVENT_STREAM) + .returnResult(TestExecutionResult.class); + + return new DefaultSubscriptionSpec( + result.getResponseBody().cast(ExecutionResult.class), + Collections.emptyList(), this.jsonPathConfig, + result::assertWithDiagnostics); + } + } + + + /** + * {@link RequestStrategy} that performs requests directly on {@link GraphQL}. + */ + private static class DirectRequestStrategy implements RequestStrategy { + + private static final URI DEFAULT_URL = URI.create("http://localhost:8080/graphql"); + + private static final HttpHeaders DEFAULT_HEADERS = new HttpHeaders(); + + private static final Duration DEFAULT_TIMEOUT = Duration.ofSeconds(5); + + + private final WebGraphQLService graphQLService; + + private final Configuration jsonPathConfig; + + public DirectRequestStrategy(WebGraphQLService service, Configuration jsonPathConfig) { + this.graphQLService = service; + this.jsonPathConfig = jsonPathConfig; + } + + @Override + public ResponseSpec execute(RequestInput input) { + ExecutionResult executionResult = executeInternal(input); + DocumentContext context = JsonPath.parse(executionResult.toSpecification(), this.jsonPathConfig); + return new DefaultResponseSpec(context, assertDecorator(input)); + } + + @Override + public SubscriptionSpec executeSubscription(RequestInput input) { + ExecutionResult result = executeInternal(input); + AssertionErrors.assertTrue("Subscription did not return Publisher", result.getData() instanceof Publisher); + return new DefaultSubscriptionSpec( + result.getData(), result.getErrors(), this.jsonPathConfig, assertDecorator(input)); + } + + private ExecutionResult executeInternal(RequestInput input) { + WebInput webInput = new WebInput(DEFAULT_URL, DEFAULT_HEADERS, input.toMap(), null); + ExecutionResult result = this.graphQLService.execute(webInput).block(DEFAULT_TIMEOUT); + Assert.notNull(result, "Expected ExecutionResult"); + return result; + } + + private Consumer assertDecorator(RequestInput input) { + return assertion -> { + try { + assertion.run(); + } + catch (AssertionError ex) { + throw new AssertionError(ex.getMessage() + "\nQuery: " + input, ex); + } + }; + } + } + + + /** + * {@link QuerySpec} that collects the query, operationName, and variables. + */ + private class DefaultQuerySpec implements QuerySpec { + + private final String query; + + @Nullable + private String operationName; + + private final Map variables = new LinkedHashMap<>(); + + private DefaultQuerySpec(String query) { + Assert.notNull(query, "`query` is required"); + this.query = query; + } + + @Override + public QuerySpec operationName(@Nullable String name) { + this.operationName = name; + return this; + } + + @Override + public QuerySpec variable(String name, Object value) { + this.variables.put(name, value); + return this; + } + + @Override + public QuerySpec variables(Consumer> variablesConsumer) { + variablesConsumer.accept(this.variables); + return this; + } + + @Override + public ResponseSpec execute() { + RequestInput input = new RequestInput(this.query, this.operationName, this.variables); + return DefaultGraphQLTester.this.requestStrategy.execute(input); + } + + @Override + public void executeAndVerify() { + RequestInput input = new RequestInput(this.query, this.operationName, this.variables); + ResponseSpec spec = DefaultGraphQLTester.this.requestStrategy.execute(input); + spec.path("$.errors").valueIsEmpty(); + } + + @Override + public SubscriptionSpec executeSubscription() { + RequestInput input = new RequestInput(this.query, this.operationName, this.variables); + return DefaultGraphQLTester.this.requestStrategy.executeSubscription(input); + } + } + + + /** + * Base for a {@link ResponseSpec} implementations. + */ + private static class ResponseSpecSupport { + + private final List errors; + + private boolean errorsChecked; + + private final Consumer assertDecorator; + + private ResponseSpecSupport(List errors, Consumer assertDecorator) { + this.errors = errors; + this.assertDecorator = assertDecorator; + } + + protected Consumer getAssertDecorator() { + return this.assertDecorator; + } + + protected void consumeErrors(Consumer> errorConsumer) { + this.errorsChecked = true; + errorConsumer.accept(this.errors); + } + + protected void assertErrorsEmptyOrConsumed() { + if (!this.errorsChecked) { + this.assertDecorator.accept(() -> AssertionErrors.assertTrue( + "Response contains GraphQL errors. " + + "To avoid this message, please use ResponseSpec#errorsSatisfy to check them.", + CollectionUtils.isEmpty(this.errors))); + } + } + } + + + /** + * {@link ResponseSpec} that operates on the response from a GraphQL HTTP request. + */ + private static class DefaultResponseSpec extends ResponseSpecSupport implements ResponseSpec { + + private static final JsonPath ERRORS_PATH = JsonPath.compile("$.errors"); + + + private final DocumentContext documentContext; + + /** + * Class constructor. + * @param documentContext the parsed response content + * @param assertDecorator decorator to apply around assertions, e.g. to + * add extra contextual information such as HTTP request and response + * body details + */ + private DefaultResponseSpec(DocumentContext documentContext, Consumer assertDecorator) { + super(initErrors(documentContext), assertDecorator); + Assert.notNull(documentContext, "DocumentContext is required"); + Assert.notNull(assertDecorator, "`assertDecorator` is required"); + this.documentContext = documentContext; + } + + private static List initErrors(DocumentContext documentContext) { + try { + return new ArrayList<>(documentContext.read( + ERRORS_PATH, new TypeRef>() {})); + } + catch (PathNotFoundException ex) { + return Collections.emptyList(); + } + } + + + @Override + public ResponseSpec errorsSatisfy(Consumer> errorConsumer) { + consumeErrors(errorConsumer); + return this; + } + + @Override + public PathSpec path(String path) { + assertErrorsEmptyOrConsumed(); + return new DefaultPathSpec(path, this.documentContext, getAssertDecorator()); + } + } + + + /** + * {@link PathSpec} implementation. + */ + private static class DefaultPathSpec implements PathSpec { + + private final String inputPath; + + private final DocumentContext documentContext; + + private final Consumer assertDecorator; + + private final JsonPath jsonPath; + + private final JsonPathExpectationsHelper pathHelper; + + private final String content; + + + DefaultPathSpec(String path, DocumentContext documentContext, Consumer assertDecorator) { + Assert.notNull(path, "`path` is required"); + this.inputPath = path; + this.documentContext = documentContext; + this.assertDecorator = assertDecorator; + this.jsonPath = initPath(path); + this.pathHelper = new JsonPathExpectationsHelper(this.jsonPath.getPath()); + this.content = documentContext.jsonString(); + } + + private static JsonPath initPath(String path) { + if (!StringUtils.hasText(path)) { + path = "$.data"; + } + else if (!path.startsWith("$") && !path.startsWith("data.")) { + path = "$.data." + path; + } + return JsonPath.compile(path); + } + + + @Override + public PathSpec path(String path) { + return new DefaultPathSpec(path, this.documentContext, this.assertDecorator); + } + + @Override + public PathSpec pathExists() { + this.assertDecorator.accept(() -> this.pathHelper.hasJsonPath(this.content)); + return this; + } + + @Override + public PathSpec pathDoesNotExist() { + this.assertDecorator.accept(() -> this.pathHelper.doesNotHaveJsonPath(this.content)); + return this; + } + + @Override + public PathSpec valueExists() { + this.assertDecorator.accept(() -> this.pathHelper.exists(this.content)); + return this; + } + + @Override + public PathSpec valueDoesNotExist() { + this.assertDecorator.accept(() -> this.pathHelper.doesNotExist(this.content)); + return this; + } + + @Override + public PathSpec valueIsEmpty() { + this.assertDecorator.accept(() -> { + try { + this.pathHelper.assertValueIsEmpty(this.content); + } + catch (AssertionError ex) { + // ignore + } + }); + return this; + } + + @Override + public PathSpec valueIsNotEmpty() { + this.assertDecorator.accept(() -> this.pathHelper.assertValueIsNotEmpty(this.content)); + return this; + } + + @Override + public EntitySpec entity(Class entityType) { + D entity = this.documentContext.read(this.jsonPath, new TypeRefAdapter<>(entityType)); + return new DefaultEntitySpec<>(entity, this.documentContext, assertDecorator, this.inputPath); + } + + @Override + public EntitySpec entity(ParameterizedTypeReference entityType) { + D entity = this.documentContext.read(this.jsonPath, new TypeRefAdapter<>(entityType)); + return new DefaultEntitySpec<>(entity, this.documentContext, assertDecorator, this.inputPath); + } + + @Override + public ListEntitySpec entityList(Class elementType) { + List entity = this.documentContext.read(this.jsonPath, new TypeRefAdapter<>(List.class, elementType)); + return new DefaultListEntitySpec<>(entity, this.documentContext, assertDecorator, this.inputPath); + } + + @Override + public ListEntitySpec entityList(ParameterizedTypeReference elementType) { + List entity = this.documentContext.read(this.jsonPath, new TypeRefAdapter<>(List.class, elementType)); + return new DefaultListEntitySpec<>(entity, this.documentContext, assertDecorator, this.inputPath); + } + + @Override + public PathSpec matchesJson(String expectedJson) { + matchesJson(expectedJson, false); + return this; + } + + @Override + public PathSpec matchesJsonStrictly(String expectedJson) { + matchesJson(expectedJson, true); + return this; + } + + private void matchesJson(String expected, boolean strict) { + this.assertDecorator.accept(() -> { + String actual; + try { + JsonProvider jsonProvider = this.documentContext.configuration().jsonProvider(); + Object content = this.documentContext.read(this.jsonPath); + actual = jsonProvider.toJson(content); + } + catch (Exception ex) { + throw new AssertionError("JSON parsing error", ex); + } + try { + new JsonExpectationsHelper().assertJsonEqual(expected, actual, strict); + } + catch (AssertionError ex) { + throw new AssertionError(ex.getMessage() + "\n\n" + + "Expected JSON content:\n'" + expected + "'\n\n" + + "Actual JSON content:\n'" + actual + "'\n\n" + + "Input path: '" + this.inputPath + "'\n", ex); + } + catch (Exception ex) { + throw new AssertionError("JSON parsing error", ex); + } + }); + } + } + + + /** + * {@link EntitySpec} implementation. + */ + private static class DefaultEntitySpec> implements EntitySpec { + + private final D entity; + + private final DocumentContext documentContext; + + private final Consumer assertDecorator; + + private final String inputPath; + + DefaultEntitySpec(D entity, DocumentContext context, Consumer decorator, String path) { + this.entity = entity; + this.documentContext = context; + this.assertDecorator = decorator; + this.inputPath = path; + } + + protected D getEntity() { + return this.entity; + } + + protected String getInputPath() { + return this.inputPath; + } + + protected Consumer getAssertDecorator() { + return this.assertDecorator; + } + + @Override + public PathSpec path(String path) { + return new DefaultPathSpec(path, this.documentContext, this.assertDecorator); + } + + @Override + public T isEqualTo(Object expected) { + this.assertDecorator.accept(() -> AssertionErrors.assertEquals(this.inputPath, expected, this.entity)); + return self(); + } + + @Override + public T isNotEqualTo(Object other) { + this.assertDecorator.accept(() -> AssertionErrors.assertNotEquals(this.inputPath, other, this.entity)); + return self(); + } + + @Override + public T isSameAs(Object expected) { + this.assertDecorator.accept(() -> AssertionErrors.assertTrue(this.inputPath, expected == this.entity)); + return self(); + } + + @Override + public T isNotSameAs(Object other) { + this.assertDecorator.accept(() -> AssertionErrors.assertTrue(this.inputPath, other != this.entity)); + return self(); + } + + @Override + public T matches(Predicate predicate) { + this.assertDecorator.accept(() -> AssertionErrors.assertTrue(this.inputPath, predicate.test(this.entity))); + return self(); + } + + @Override + public T satisfies(Consumer consumer) { + this.assertDecorator.accept(() -> consumer.accept(this.entity)); + return self(); + } + + @Override + public D get() { + return this.entity; + } + + @SuppressWarnings("unchecked") + private T self() { + return (T) this; + } + } + + + /** + * {@link ListEntitySpec} implementation. + */ + private static class DefaultListEntitySpec extends DefaultEntitySpec, ListEntitySpec> + implements ListEntitySpec { + + DefaultListEntitySpec(List entity, DocumentContext context, Consumer decorator, String path) { + super(entity, context, decorator, path); + } + + @Override + @SuppressWarnings("unchecked") + public ListEntitySpec contains(E... elements) { + getAssertDecorator().accept(() -> { + List expected = Arrays.asList(elements); + AssertionErrors.assertTrue( + "List at path '" + getInputPath() + "' does not contain " + expected, + (getEntity() != null && getEntity().containsAll(expected))); + }); + return this; + } + + @Override + @SuppressWarnings("unchecked") + public ListEntitySpec doesNotContain(E... elements) { + getAssertDecorator().accept(() -> { + List expected = Arrays.asList(elements); + AssertionErrors.assertTrue( + "List at path '" + getInputPath() + "' should not have contained " + expected, + (getEntity() == null || !getEntity().containsAll(expected))); + }); + return this; + } + + @Override + @SuppressWarnings("unchecked") + public ListEntitySpec containsExactly(E... elements) { + getAssertDecorator().accept(() -> { + List expected = Arrays.asList(elements); + AssertionErrors.assertTrue( + "List at path '" + getInputPath() + "' should have contained exactly " + expected, + (getEntity() != null && getEntity().containsAll(expected))); + }); + return this; + } + + @Override + public ListEntitySpec hasSize(int size) { + getAssertDecorator().accept(() -> { + AssertionErrors.assertTrue( + "List at path '" + getInputPath() + "' should have size " + size, + (getEntity() != null && getEntity().size() == size)); + }); + return this; + } + + @Override + public ListEntitySpec hasSizeLessThan(int boundary) { + getAssertDecorator().accept(() -> { + AssertionErrors.assertTrue( + "List at path '" + getInputPath() + "' should have size less than " + boundary, + (getEntity() != null && getEntity().size() < boundary)); + }); + return this; + } + + @Override + public ListEntitySpec hasSizeGreaterThan(int boundary) { + getAssertDecorator().accept(() -> { + AssertionErrors.assertTrue( + "List at path '" + getInputPath() + "' should have size greater than " + boundary, + (getEntity() != null && getEntity().size() > boundary)); + }); + return this; + } + } + + + /** + * {@link SubscriptionSpec} implementation that operates on a + * {@link Publisher} of {@link ExecutionResult}. + */ + private static class DefaultSubscriptionSpec extends ResponseSpecSupport implements SubscriptionSpec { + + private final Publisher publisher; + + private final Configuration jsonPathConfig; + + DefaultSubscriptionSpec( + Publisher publisher, List errors, Configuration jsonPathConfig, + Consumer assertDecorator) { + + super(errors, assertDecorator); + this.publisher = publisher; + this.jsonPathConfig = jsonPathConfig; + } + + @Override + public SubscriptionSpec errorsSatisfy(Consumer> errorConsumer) { + consumeErrors(errorConsumer); + return this; + } + + @Override + public Flux toFlux() { + return Flux.from(this.publisher).map(result -> { + DocumentContext context = JsonPath.parse(result.toSpecification(), this.jsonPathConfig); + return new DefaultResponseSpec(context, getAssertDecorator()); + }); + } + } + + + private static class Jackson2Configuration { + + static Configuration create() { + return Configuration.builder() + .jsonProvider(new JacksonJsonProvider()) + .mappingProvider(new JacksonMappingProvider()) + .build(); + } + } + +} diff --git a/spring-graphql-test/src/main/java/org/springframework/graphql/test/query/GraphQLTester.java b/spring-graphql-test/src/main/java/org/springframework/graphql/test/query/GraphQLTester.java new file mode 100644 index 000000000..fe9ba99fe --- /dev/null +++ b/spring-graphql-test/src/main/java/org/springframework/graphql/test/query/GraphQLTester.java @@ -0,0 +1,480 @@ +/* + * Copyright 2002-2021 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 + * + * https://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.graphql.test.query; + +import java.util.List; +import java.util.Map; +import java.util.function.Consumer; +import java.util.function.Predicate; + +import graphql.GraphQLError; +import reactor.core.publisher.Flux; + +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.graphql.WebGraphQLService; +import org.springframework.lang.Nullable; +import org.springframework.test.web.reactive.server.WebTestClient; + +/** + * Main entry point for testing GraphQL with requests performed via + * {@link WebTestClient} as an HTTP client or via any {@link WebGraphQLService}. + * + * + *

GraphQL requests to Spring MVC without an HTTP server: + *

+ * @SpringBootTest
+ * @AutoConfigureMockMvc
+ * public class MyTests {
+ *
+ *  private GraphQLTester graphQLTester;
+ *
+ *  @BeforeEach
+ *  public void setUp(@Autowired MockMvc mockMvc) {
+ *      WebTestClient client = MockMvcWebTestClient.bindTo(mockMvc).baseUrl("/graphql").build();
+ *      this.graphQLTester = GraphQLTester.create(client);
+ *  }
+ * 
+ * + *

GraphQL requests to Spring WebFlux without an HTTP server: + *

+ * @SpringBootTest
+ * @AutoConfigureWebTestClient
+ * public class MyTests {
+ *
+ *  private GraphQLTester graphQLTester;
+ *
+ *  @BeforeEach
+ *  public void setUp(@Autowired WebTestClient client) {
+ *      this.graphQLTester = GraphQLTester.create(client);
+ *  }
+ * 
+ * + *

GraphQL requests to a running server: + *

+ * @SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)
+ * public class MyTests {
+ *
+ *  private GraphQLTester graphQLTester;
+ *
+ *  @BeforeEach
+ *  public void setUp(@Autowired WebTestClient client) {
+ *      this.graphQLTester = GraphQLTester.create(client);
+ *  }
+ * 
+ * + *

GraphQL requests to any {@link WebGraphQLService}: + *

+ * @SpringBootTest
+ * public class MyTests {
+ *
+ *  private GraphQLTester graphQLTester;
+ *
+ *  @BeforeEach
+ *  public void setUp(@Autowired WebGraphQLService service) {
+ *      this.graphQLTester = GraphQLTester.create(service);
+ *  }
+ * 
+ */ +public interface GraphQLTester { + + /** + * Prepare to perform a GraphQL request with the given query. + * @param query the query to send + * @return spec for response assertions + * @throws AssertionError if the response status is not 200 (OK) + */ + QuerySpec query(String query); + + + /** + * Create a {@code GraphQLTester} that performs GraphQL requests as an HTTP + * client through the given {@link WebTestClient}. Depending on how the + * {@code WebTestClient} is set up, tests may be with or without a server. + * See setup examples in class-level Javadoc. + * @param client the web client to perform requests with + * @return the created {@code GraphQLTester} instance + */ + static GraphQLTester create(WebTestClient client) { + return new DefaultGraphQLTester(client); + } + + /** + * Create a {@code GraphQLTester} that performs GraphQL requests through the + * given {@link WebGraphQLService}. + * @param service the handler to execute requests with + * @return the created {@code GraphQLTester} instance + */ + static GraphQLTester create(WebGraphQLService service) { + return new DefaultGraphQLTester(service); + } + + + /** + * Declare options to perform a GraphQL request. + */ + interface ExecuteSpec { + + /** + * Execute the GraphQL request and return a spec for further inspection + * of the response data and errors. + * + * @return options for asserting the response + * @throws AssertionError if the request is performed over HTTP and the + * response status is not 200 (OK). + */ + ResponseSpec execute(); + + /** + * Perform the GraphQL request and then verify the GraphQL response does + * not contain any errors. To assert the errors, use {@link #execute()} + * instead. + */ + void executeAndVerify(); + + /** + * Perform the GraphQL subscription request. + * + * @return options for assertions on subscription events + * @throws AssertionError if the request is performed over HTTP and the + * response status is not 200 (OK). + */ + SubscriptionSpec executeSubscription(); + + } + + + /** + * Declare options to gather input for a GraphQL query and execute it. + */ + interface QuerySpec extends ExecuteSpec { + + /** + * Set the operation name. + */ + QuerySpec operationName(@Nullable String name); + + /** + * Add a variable. + */ + QuerySpec variable(String name, Object value); + + /** + * Modify variables by accessing the underlying map. + */ + QuerySpec variables(Consumer> variablesConsumer); + } + + + /** + * Declare options to switch to different part of the GraphQL response. + */ + interface TraverseSpec { + + /** + * Switch to a path under the "data" section of the GraphQL response. + * The path can be a query root type name, e.g. "project", or a nested + * path such as "project.name", or any + * JsonPath. + * + * @param path the path to switch to + * @return spec for asserting the content under the given path + * @throws AssertionError if the GraphQL response contains + * errors + * that have not be checked via {@link ResponseSpec#errorsSatisfy(Consumer)} + */ + PathSpec path(String path); + } + + + /** + * Declare the first options available to insecpt a GraphQL response. + */ + interface ResponseSpec extends TraverseSpec { + + /** + * Inspect errors + * in the response, if any. + *

If this method is not used first, any attempts to check the data + * will result in an {@link AssertionError}. Therefore for GraphQL + * responses that are expected to have both data and errors, be sure + * to use this method first. + * @param errorConsumer the consumer to inspect errors with + * @return the same spec for further assertions on the data + */ + ResponseSpec errorsSatisfy(Consumer> errorConsumer); + + } + + + /** + * Assertions available for the data at a given path. + */ + interface PathSpec extends TraverseSpec { + + /** + * Assert the given path exists, even if the value is {@code null}. + * @return spec to assert the converted entity with + */ + PathSpec pathExists(); + + /** + * Assert the given path does not {@link #pathExists() exist}. + * @return spec to assert the converted entity with + */ + PathSpec pathDoesNotExist(); + + /** + * Assert a value exists at the given path where the value is any + * {@code non-null} value, possibly an empty array or map. + * @return spec to assert the converted entity with + */ + PathSpec valueExists(); + + /** + * Assert a value does not {@link #valueExists() exist} at the given path. + * @return spec to assert the converted entity with + */ + PathSpec valueDoesNotExist(); + + /** + * Assert the value at the given path does not exist or is empty as defined + * in {@link org.springframework.util.ObjectUtils#isEmpty(Object)}. + * @return spec to assert the converted entity with + * @see org.springframework.util.ObjectUtils#isEmpty(Object) + */ + PathSpec valueIsEmpty(); + + /** + * Assert the value at the given path is not {@link #valueIsEmpty()} + * @return spec to assert the converted entity with + */ + PathSpec valueIsNotEmpty(); + + /** + * Convert the data at the given path to the target type. + * @param entityType the type to convert to + * @param the target entity type + * @return spec to assert the converted entity with + */ + EntitySpec entity(Class entityType); + + /** + * Convert the data at the given path to the target type. + * @param entityType the type to convert to + * @param the target entity type + * @return spec to assert the converted entity with + */ + EntitySpec entity(ParameterizedTypeReference entityType); + + /** + * Convert the data at the given path to a List of the target type. + * @param elementType the type of element to convert to + * @param the target entity type + * @return spec to assert the converted List of entities with + */ + ListEntitySpec entityList(Class elementType); + + /** + * Convert the data at the given path to a List of the target type. + * @param elementType the type to convert to + * @param the target entity type + * @return spec to assert the converted List of entities with + */ + ListEntitySpec entityList(ParameterizedTypeReference elementType); + + /** + * Parse the JSON at the given path and the given expected JSON and assert + * that the two are "similar". + *

Use of this option requires the + * JSONassert library + * on to be on the classpath. + * @param expectedJson the expected JSON + * @return spec to specify a different path + * @see org.springframework.test.util.JsonExpectationsHelper#assertJsonEqual(String, String) + */ + TraverseSpec matchesJson(String expectedJson); + + /** + * Parse the JSON at the given path and the given expected JSON and assert + * that the two are "similar" so they contain the same attribute-value + * pairs regardless of formatting, along with lenient checking, e.g. + * extensible and non-strict array ordering. + * @param expectedJson the expected JSON + * @return spec to specify a different path + * @see org.springframework.test.util.JsonExpectationsHelper#assertJsonEqual(String, String, boolean) + */ + TraverseSpec matchesJsonStrictly(String expectedJson); + + } + + + /** + * Declare options available to assert data converted to an entity. + * @param the entity type + * @param the spec type, including subtypes + */ + interface EntitySpec> extends TraverseSpec { + + /** + * Assert the converted entity equals the given Object. + * @param expected the expected Object + * @param the spec type + * @return the same spec for more assertions + */ + T isEqualTo(Object expected); + + /** + * Assert the converted entity does not equal the given Object. + * @param other the Object to check against + * @param the spec type + * @return the same spec for more assertions + */ + T isNotEqualTo(Object other); + + /** + * Assert the converted entity is the same instance as the given Object. + * @param expected the expected Object + * @param the spec type + * @return the same spec for more assertions + */ + T isSameAs(Object expected); + + /** + * Assert the converted entity is not the same instance as the given Object. + * @param other the Object to check against + * @param the spec type + * @return the same spec for more assertions + */ + T isNotSameAs(Object other); + + /** + * Assert the converted entity matches the given predicate. + * @param predicate the expected Object + * @param the spec type + * @return the same spec for more assertions + */ + T matches(Predicate predicate); + + /** + * Perform any assertions on the converted entity, e.g. via AssertJ. + * @param consumer the consumer to inspect the entity with + * @return the same spec for more assertions + */ + T satisfies(Consumer consumer); + + /** + * Return the converted entity. + */ + D get(); + + } + + + /** + * Extension of {@link EntitySpec} for a List of entities. + * @param the type of elements in the list + */ + interface ListEntitySpec extends EntitySpec, ListEntitySpec> { + + /** + * Assert the list contains the given elements. + * @param elements values that are expected + * @return the same spec for more assertions + */ + @SuppressWarnings("unchecked") + ListEntitySpec contains(E... elements); + + /** + * Assert the list does not contain the given elements. + * @param elements values that are not expected + * @return the same spec for more assertions + */ + @SuppressWarnings("unchecked") + ListEntitySpec doesNotContain(E... elements); + + /** + * Assert the list contains the given elements. + * @param elements values that are expected + * @return the same spec for more assertions + */ + @SuppressWarnings("unchecked") + ListEntitySpec containsExactly(E... elements); + + /** + * Assert the list contains the specified number of elements. + * @param size the number of elements expected + * @return the same spec for more assertions + */ + ListEntitySpec hasSize(int size); + + /** + * Assert the list contains fewer elements than the specified number. + * @param boundary the number to compare the number of elements to + * @return the same spec for more assertions + */ + ListEntitySpec hasSizeLessThan(int boundary); + + /** + * Assert the list contains more elements than the specified number. + * @param boundary the number to compare the number of elements to + * @return the same spec for more assertions + */ + ListEntitySpec hasSizeGreaterThan(int boundary); + + } + + + /** + * Declare options available to assert a GraphQL Subscription response. + */ + interface SubscriptionSpec { + + /** + * Inspect errors + * in the response, if any. + *

If this method is not used first, any attempts to check event data + * will result in an {@link AssertionError}. Therefore for a GraphQL + * subscription that are expected to have both errors and events, be sure + * to use this method first. + * @param errorConsumer the consumer to inspect errors with + * @return the same spec for further assertions on the data + */ + SubscriptionSpec errorsSatisfy(Consumer> errorConsumer); + + /** + * Return a {@link Flux} of entities converted from some part of the data + * in each subscription event. + * @param path a path into the data of each subscription event + * @param entityType the type to convert data to + * @param the entity type + * @return a {@code Flux} of entities that can be further inspected, + * e.g. with {@code reactor.test.StepVerifier} + */ + default Flux toFlux(String path, Class entityType) { + return toFlux().map(spec -> spec.path(path).entity(entityType).get()); + } + + /** + * Return a {@link Flux} of {@link ResponseSpec} instances, each + * representing an individual subscription event. + * @return a {@code Flux} of {@code ResponseSpec} instances that can be + * further inspected, e.g. with {@code reactor.test.StepVerifier} + */ + Flux toFlux(); + + } + +} diff --git a/spring-graphql-test/src/main/java/org/springframework/graphql/test/query/TestExecutionResult.java b/spring-graphql-test/src/main/java/org/springframework/graphql/test/query/TestExecutionResult.java new file mode 100644 index 000000000..fd0a06750 --- /dev/null +++ b/spring-graphql-test/src/main/java/org/springframework/graphql/test/query/TestExecutionResult.java @@ -0,0 +1,85 @@ +/* + * Copyright 2002-2021 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 + * + * https://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.graphql.test.query; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import graphql.ExecutionResult; +import graphql.ExecutionResultImpl; +import graphql.GraphQLError; + +/** + * {@link GraphQLError} with setters for deserialization. + */ +public class TestExecutionResult implements ExecutionResult { + + private Object data; + + private List errors = Collections.emptyList(); + + private Map extensions = Collections.emptyMap(); + + + public void setData(Object data) { + this.data = data; + } + + @Override + @SuppressWarnings("unchecked") + public T getData() { + return (T) this.data; + } + + public void setErrors(List errors) { + this.errors = new ArrayList<>(errors); + } + + @Override + public List getErrors() { + return this.errors; + } + + @Override + public boolean isDataPresent() { + return getData() != null; + } + + public void setExtensions(Map extensions) { + this.extensions = new LinkedHashMap<>(extensions); + } + + @Override + public Map getExtensions() { + return this.extensions; + } + + @Override + public Map toSpecification() { + ExecutionResultImpl.Builder builder = ExecutionResultImpl.newExecutionResult() + .addErrors(this.errors) + .extensions(this.extensions); + + if (isDataPresent()) { + builder.data(this.data); + } + + return builder.build().toSpecification(); + } +} diff --git a/spring-graphql-test/src/main/java/org/springframework/graphql/test/query/TestGraphQLError.java b/spring-graphql-test/src/main/java/org/springframework/graphql/test/query/TestGraphQLError.java new file mode 100644 index 000000000..3134dde05 --- /dev/null +++ b/spring-graphql-test/src/main/java/org/springframework/graphql/test/query/TestGraphQLError.java @@ -0,0 +1,152 @@ +/* + * Copyright 2002-2021 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 + * + * https://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.graphql.test.query; + +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +import graphql.ErrorClassification; +import graphql.GraphQLError; +import graphql.GraphqlErrorBuilder; +import graphql.execution.ResultPath; +import graphql.language.SourceLocation; + +/** + * {@link GraphQLError} with setters to use for deserialization. + */ +class TestGraphQLError implements GraphQLError { + + private String message; + + private List locations; + + private ErrorClassification errorType; + + private List path; + + private Map extensions; + + + public void setMessage(String message) { + this.message = message; + } + + @Override + public String getMessage() { + return this.message; + } + + public void setLocations(List locations) { + this.locations = TestSourceLocation.toSourceLocations(locations); + } + + @Override + public List getLocations() { + return this.locations; + } + + public void setErrorType(ErrorClassification errorType) { + this.errorType = errorType; + } + + @Override + public ErrorClassification getErrorType() { + return this.errorType; + } + + public void setPath(List path) { + this.path = path; + } + + @Override + public List getPath() { + return this.path; + } + + public void setExtensions(Map extensions) { + this.extensions = extensions; + } + + @Override + public Map getExtensions() { + return this.extensions; + } + + @Override + public Map toSpecification() { + GraphqlErrorBuilder builder = GraphqlErrorBuilder.newError(); + if (this.message != null) { + builder.message(this.message); + } + if (this.locations != null) { + this.locations.forEach(builder::location); + } + if (this.path != null) { + builder.path(ResultPath.fromList(this.path)); + } + if (this.extensions != null) { + builder.extensions(this.extensions); + } + return builder.build().toSpecification(); + } + + @Override + public String toString() { + return toSpecification().toString(); + } + + + private static class TestSourceLocation { + + private int line; + + private int column; + + private String sourceName; + + + public void setLine(int line) { + this.line = line; + } + + public int getLine() { + return this.line; + } + + public void setColumn(int column) { + this.column = column; + } + + public int getColumn() { + return this.column; + } + + public void setSourceName(String sourceName) { + this.sourceName = sourceName; + } + + public String getSourceName() { + return this.sourceName; + } + + public static List toSourceLocations(List locations) { + return locations.stream() + .map(location -> new SourceLocation(location.line, location.column, location.sourceName)) + .collect(Collectors.toList()); + } + } +} diff --git a/spring-graphql-test/src/main/java/org/springframework/graphql/test/query/TypeRefAdapter.java b/spring-graphql-test/src/main/java/org/springframework/graphql/test/query/TypeRefAdapter.java new file mode 100644 index 000000000..34610bc6b --- /dev/null +++ b/spring-graphql-test/src/main/java/org/springframework/graphql/test/query/TypeRefAdapter.java @@ -0,0 +1,56 @@ +/* + * Copyright 2002-2021 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 + * + * https://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.graphql.test.query; + +import java.lang.reflect.Type; + +import com.jayway.jsonpath.TypeRef; + +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.core.ResolvableType; + +/** + * {@link TypeRef} with a {@link #getType() type} that is given rather than + * obtained from the declared generic type information. + */ +class TypeRefAdapter extends TypeRef { + + private final Type type; + + + TypeRefAdapter(Class clazz) { + this.type = clazz; + } + + TypeRefAdapter(ParameterizedTypeReference typeReference) { + this.type = typeReference.getType(); + } + + TypeRefAdapter(Class clazz, Class generic) { + this.type = ResolvableType.forClassWithGenerics(clazz, generic).getType(); + } + + TypeRefAdapter(Class clazz, ParameterizedTypeReference generic) { + this.type = ResolvableType.forClassWithGenerics(clazz, ResolvableType.forType(generic)).getType(); + } + + + @Override + public Type getType() { + return this.type; + } + +} diff --git a/spring-graphql-test/src/test/java/org/springframework/graphql/test/query/GraphQLTesterTests.java b/spring-graphql-test/src/test/java/org/springframework/graphql/test/query/GraphQLTesterTests.java new file mode 100644 index 000000000..664082c74 --- /dev/null +++ b/spring-graphql-test/src/test/java/org/springframework/graphql/test/query/GraphQLTesterTests.java @@ -0,0 +1,402 @@ +/* + * Copyright 2002-2021 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 + * + * https://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.graphql.test.query; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Consumer; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import graphql.ExecutionResult; +import graphql.ExecutionResultImpl; +import graphql.GraphQLError; +import graphql.GraphqlErrorBuilder; +import graphql.language.SourceLocation; +import okhttp3.mockwebserver.MockResponse; +import okhttp3.mockwebserver.MockWebServer; +import okhttp3.mockwebserver.RecordedRequest; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; +import org.mockito.ArgumentCaptor; +import reactor.core.publisher.Mono; + +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.graphql.WebGraphQLService; +import org.springframework.graphql.WebInput; +import org.springframework.graphql.WebOutput; +import org.springframework.http.HttpHeaders; +import org.springframework.lang.Nullable; +import org.springframework.test.web.reactive.server.WebTestClient; +import org.springframework.util.CollectionUtils; +import org.springframework.util.StringUtils; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * Tests for {@link GraphQLTester} parameterized to: + *
    + *
  • Connect to {@link MockWebServer} and return a preset HTTP response. + *
  • Use mock {@link WebGraphQLService} to return a preset {@link ExecutionResult}. + *
+ * + *

There is no actual handling via {@link graphql.GraphQL} in either scenario. + * The main focus is to verify {@link GraphQLTester} request preparation and + * response handling. + */ +public class GraphQLTesterTests { + + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + + + public static Stream argumentSource() { + return Stream.of(new MockWebServerSetup(), new MockWebGraphQLServiceSetup()); + } + + + @ParameterizedTest + @MethodSource("argumentSource") + void pathAndValueExistsAndEmptyChecks(GraphQLTesterSetup setup) throws Exception { + + String query = "{me {name, friends}}"; + setup.response("{\"me\": {\"name\":\"Luke Skywalker\", \"friends\":[]}}"); + + GraphQLTester.ResponseSpec spec = setup.graphQLTester().query(query).execute(); + + spec.path("me.name").pathExists().valueExists().valueIsNotEmpty(); + spec.path("me.friends").valueIsEmpty(); + spec.path("hero").pathDoesNotExist().valueDoesNotExist().valueIsEmpty(); + + setup.verifyRequest(input -> assertThat(input.getQuery()).contains(query)); + setup.shutdown(); + } + + @ParameterizedTest + @MethodSource("argumentSource") + void matchesJson(GraphQLTesterSetup setup) throws Exception { + + String query = "{me {name}}"; + setup.response("{\"me\": {\"name\":\"Luke Skywalker\", \"friends\":[]}}"); + + GraphQLTester.ResponseSpec spec = setup.graphQLTester().query(query).execute(); + + spec.path("").matchesJson("{\"me\": {\"name\":\"Luke Skywalker\",\"friends\":[]}}"); + spec.path("me").matchesJson("{\"name\":\"Luke Skywalker\"}"); + spec.path("me").matchesJson("{\"friends\":[]}"); // lenient match with subset of fields + + assertThatThrownBy(() -> spec.path("me").matchesJsonStrictly("{\"friends\":[]}")) + .as("Extended fields should fail in strict mode") + .hasMessageContaining("Unexpected: name"); + + setup.verifyRequest(input -> assertThat(input.getQuery()).contains(query)); + setup.shutdown(); + } + + @ParameterizedTest + @MethodSource("argumentSource") + void entity(GraphQLTesterSetup setup) throws Exception { + + String query = "{me {name}}"; + setup.response("{\"me\": {\"name\":\"Luke Skywalker\"}}"); + + GraphQLTester.ResponseSpec spec = setup.graphQLTester().query(query).execute(); + + MovieCharacter luke = MovieCharacter.create("Luke Skywalker"); + MovieCharacter han = MovieCharacter.create("Han Solo"); + AtomicReference personRef = new AtomicReference<>(); + + MovieCharacter actual = spec.path("me").entity(MovieCharacter.class) + .isEqualTo(luke) + .isNotEqualTo(han) + .satisfies(personRef::set) + .matches(movieCharacter -> personRef.get().equals(movieCharacter)) + .isSameAs(personRef.get()) + .isNotSameAs(luke) + .get(); + + assertThat(actual.getName()).isEqualTo("Luke Skywalker"); + + spec.path("") + .entity(new ParameterizedTypeReference>() {}) + .isEqualTo(Collections.singletonMap("me", luke)); + + setup.verifyRequest(input -> assertThat(input.getQuery()).contains(query)); + setup.shutdown(); + } + + @ParameterizedTest + @MethodSource("argumentSource") + void entityList(GraphQLTesterSetup setup) throws Exception { + + String query = "{me {name, friends}}"; + setup.response("{" + + " \"me\":{" + + " \"name\":\"Luke Skywalker\"," + + " \"friends\":[{\"name\":\"Han Solo\"}, {\"name\":\"Leia Organa\"}]" + + " }" + + "}" + ); + + GraphQLTester.ResponseSpec spec = setup.graphQLTester().query(query).execute(); + + MovieCharacter han = MovieCharacter.create("Han Solo"); + MovieCharacter leia = MovieCharacter.create("Leia Organa"); + MovieCharacter jabba = MovieCharacter.create("Jabba the Hutt"); + + List actual = spec.path("me.friends").entityList(MovieCharacter.class) + .contains(han) + .containsExactly(han, leia) + .doesNotContain(jabba) + .hasSize(2) + .hasSizeGreaterThan(1) + .hasSizeLessThan(3) + .get(); + + assertThat(actual).containsExactly(han, leia); + + spec.path("me.friends") + .entityList(new ParameterizedTypeReference() {}) + .containsExactly(han, leia); + + setup.verifyRequest(input -> assertThat(input.getQuery()).contains(query)); + setup.shutdown(); + } + + @ParameterizedTest + @MethodSource("argumentSource") + void operationNameAndVariables(GraphQLTesterSetup setup) throws Exception { + + String query = "query HeroNameAndFriends($episode: Episode) {" + + " hero(episode: $episode) {" + + " name" + + " }" + + "}"; + + setup.response("{\"hero\": {\"name\":\"R2-D2\"}}"); + + GraphQLTester.ResponseSpec spec = setup.graphQLTester().query(query) + .operationName("HeroNameAndFriends") + .variable("episode", "JEDI") + .variables(map -> map.put("foo", "bar")) + .execute(); + + spec.path("hero").entity(MovieCharacter.class).isEqualTo(MovieCharacter.create("R2-D2")); + + setup.verifyRequest(input -> { + assertThat(input.getQuery()).contains(query); + assertThat(input.getOperationName()).isEqualTo("HeroNameAndFriends"); + assertThat(input.getVariables()).hasSize(2); + assertThat(input.getVariables()).containsEntry("episode", "JEDI"); + assertThat(input.getVariables()).containsEntry("foo", "bar"); + }); + setup.shutdown(); + } + + @ParameterizedTest + @MethodSource("argumentSource") + void errorsAssertedIfNotChecked(GraphQLTesterSetup setup) throws Exception { + + String query = "{me {name, friends}}"; + setup.response(GraphqlErrorBuilder.newError() + .message("Invalid query") + .location(new SourceLocation(1, 2)) + .build()); + + GraphQLTester.ResponseSpec spec = setup.graphQLTester().query(query).execute(); + + assertThatThrownBy(() -> spec.path("me")).hasMessageContaining("Response contains GraphQL errors."); + + setup.verifyRequest(input -> assertThat(input.getQuery()).contains(query)); + setup.shutdown(); + } + + @ParameterizedTest + @MethodSource("argumentSource") + void errorsAssertedOnExecuteAndVerify(GraphQLTesterSetup setup) throws Exception { + + String query = "{me {name, friends}}"; + setup.response(GraphqlErrorBuilder.newError() + .message("Invalid query") + .location(new SourceLocation(1, 2)) + .build()); + + assertThatThrownBy(() -> setup.graphQLTester().query(query).executeAndVerify()) + .hasMessageContaining("Response contains GraphQL errors."); + + setup.verifyRequest(input -> assertThat(input.getQuery()).contains(query)); + setup.shutdown(); + } + + @ParameterizedTest + @MethodSource("argumentSource") + void errorsAllowedIfChecked(GraphQLTesterSetup setup) throws Exception { + + String query = "{me {name, friends}}"; + setup.response(GraphqlErrorBuilder.newError() + .message("Invalid query") + .location(new SourceLocation(1, 2)) + .build()); + + setup.graphQLTester().query(query).execute() + .errorsSatisfy(errors -> { + assertThat(errors).hasSize(1); + assertThat(errors.get(0).getMessage()).isEqualTo("Invalid query"); + assertThat(errors.get(0).getLocations()).hasSize(1); + assertThat(errors.get(0).getLocations().get(0).getLine()).isEqualTo(1); + assertThat(errors.get(0).getLocations().get(0).getColumn()).isEqualTo(2); + }) + .path("me").pathDoesNotExist(); + + setup.verifyRequest(input -> assertThat(input.getQuery()).contains(query)); + setup.shutdown(); + } + + + private interface GraphQLTesterSetup { + + GraphQLTester graphQLTester(); + + default void response(String data) throws Exception { + response(data, Collections.emptyList()); + } + + default void response(GraphQLError... errors) throws Exception { + response(null, Arrays.asList(errors)); + } + + void response(@Nullable String data, List errors) throws Exception; + + void verifyRequest(Consumer consumer) throws Exception; + + default void shutdown() throws Exception { + // no-op by default + } + + } + + + private static class MockWebServerSetup implements GraphQLTesterSetup { + + private final MockWebServer server; + + private final GraphQLTester graphQLTester; + + MockWebServerSetup() { + this.server = new MockWebServer(); + this.graphQLTester = GraphQLTester.create(initWebTestClient(this.server)); + } + + private static WebTestClient initWebTestClient(MockWebServer server) { + String baseUrl = server.url("/graphQL").toString(); + return WebTestClient.bindToServer().baseUrl(baseUrl).build(); + } + + @Override + public GraphQLTester graphQLTester() { + return this.graphQLTester; + } + + @Override + public void response(@Nullable String data, List errors) throws Exception { + StringBuilder sb = new StringBuilder("{"); + if (StringUtils.hasText(data)) { + sb.append("\"data\":").append(data); + } + if (!CollectionUtils.isEmpty(errors)) { + List> errorSpecs = errors.stream() + .map(GraphQLError::toSpecification) + .collect(Collectors.toList()); + + sb.append(StringUtils.hasText(data) ? ", " : "") + .append("\"errors\":") + .append(OBJECT_MAPPER.writeValueAsString(errorSpecs)); + } + sb.append("}"); + + MockResponse response = new MockResponse(); + response.setHeader("Content-Type", "application/json"); + response.setBody(sb.toString()); + + this.server.enqueue(response); + } + + @Override + public void verifyRequest(Consumer consumer) throws Exception { + assertThat(this.server.getRequestCount()).isEqualTo(1); + RecordedRequest request = this.server.takeRequest(); + assertThat(request.getHeader(HttpHeaders.CONTENT_TYPE)).isEqualTo("application/json"); + + String content = request.getBody().readUtf8(); + Map map = new ObjectMapper().readValue(content, new TypeReference>() {}); + WebInput webInput = new WebInput(request.getRequestUrl().uri(), new HttpHeaders(), map, null); + + consumer.accept(webInput); + } + + @Override + public void shutdown() throws Exception { + this.server.shutdown(); + } + } + + + private static class MockWebGraphQLServiceSetup implements GraphQLTesterSetup { + + private final WebGraphQLService service = mock(WebGraphQLService.class); + + private final ArgumentCaptor bodyCaptor = ArgumentCaptor.forClass(WebInput.class); + + private final GraphQLTester graphQLTester; + + public MockWebGraphQLServiceSetup() { + this.graphQLTester = GraphQLTester.create(this.service); + } + + @Override + public GraphQLTester graphQLTester() { + return this.graphQLTester; + } + + @Override + public void response(@Nullable String data, List errors) throws Exception { + ExecutionResultImpl.Builder builder = new ExecutionResultImpl.Builder(); + if (data != null) { + builder.data(OBJECT_MAPPER.readValue(data, new TypeReference>() {})); + } + if (!CollectionUtils.isEmpty(errors)) { + builder.addErrors(errors); + } + ExecutionResult result = builder.build(); + WebOutput output = new WebOutput(mock(WebInput.class), result); + when(this.service.execute(this.bodyCaptor.capture())).thenReturn(Mono.just(output)); + } + + @Override + public void verifyRequest(Consumer consumer) { + WebInput webInput = this.bodyCaptor.getValue(); + consumer.accept(webInput); + } + } + +} diff --git a/spring-graphql-test/src/test/java/org/springframework/graphql/test/query/MovieCharacter.java b/spring-graphql-test/src/test/java/org/springframework/graphql/test/query/MovieCharacter.java new file mode 100644 index 000000000..188eb3262 --- /dev/null +++ b/spring-graphql-test/src/test/java/org/springframework/graphql/test/query/MovieCharacter.java @@ -0,0 +1,56 @@ +/* + * Copyright 2002-2021 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 + * + * https://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.graphql.test.query; + +import org.springframework.lang.Nullable; + +public class MovieCharacter { + + @Nullable + private String name; + + public void setName(String name) { + this.name = name; + } + + @Nullable + public String getName() { + return this.name; + } + + public static MovieCharacter create(String name) { + MovieCharacter movieCharacter = new MovieCharacter(); + movieCharacter.setName(name); + return movieCharacter; + } + + @Override + public boolean equals(Object other) { + if (this == other) { + return true; + } + if (other == null || getClass() != other.getClass()) { + return false; + } + MovieCharacter movieCharacter = (MovieCharacter) other; + return (this.name != null ? this.name.equals(movieCharacter.name) : movieCharacter.name == null); + } + + @Override + public int hashCode() { + return (this.name != null ? this.name.hashCode() : 0); + } +} diff --git a/spring-graphql-test/src/test/resources/log4j2-test.xml b/spring-graphql-test/src/test/resources/log4j2-test.xml new file mode 100644 index 000000000..950a361ae --- /dev/null +++ b/spring-graphql-test/src/test/resources/log4j2-test.xml @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/spring-graphql-web/src/main/java/org/springframework/graphql/RequestInput.java b/spring-graphql-web/src/main/java/org/springframework/graphql/RequestInput.java index 938f5e01f..e23c81e16 100644 --- a/spring-graphql-web/src/main/java/org/springframework/graphql/RequestInput.java +++ b/spring-graphql-web/src/main/java/org/springframework/graphql/RequestInput.java @@ -102,7 +102,7 @@ public Map toMap() { if (getOperationName() != null) { map.put("operationName", getOperationName()); } - if (CollectionUtils.isEmpty(getVariables())) { + if (!CollectionUtils.isEmpty(getVariables())) { map.put("variables", new LinkedHashMap<>(getVariables())); } return map;