From e22d3e8ea4fb1451eb81e8f87a6f1c321e63eea7 Mon Sep 17 00:00:00 2001 From: Rossen Stoyanchev Date: Thu, 15 Apr 2021 13:00:20 +0100 Subject: [PATCH] Add GraphQLTester See gh-43 --- settings.gradle | 2 +- spring-graphql-test/build.gradle | 63 ++ .../test/query/DefaultGraphQLTester.java | 730 ++++++++++++++++++ .../graphql/test/query/GraphQLTester.java | 480 ++++++++++++ .../test/query/TestExecutionResult.java | 85 ++ .../graphql/test/query/TestGraphQLError.java | 152 ++++ .../graphql/test/query/TypeRefAdapter.java | 56 ++ .../springframework/graphql/RequestInput.java | 2 +- 8 files changed, 1568 insertions(+), 2 deletions(-) create mode 100644 spring-graphql-test/build.gradle create mode 100644 spring-graphql-test/src/main/java/org/springframework/graphql/test/query/DefaultGraphQLTester.java create mode 100644 spring-graphql-test/src/main/java/org/springframework/graphql/test/query/GraphQLTester.java create mode 100644 spring-graphql-test/src/main/java/org/springframework/graphql/test/query/TestExecutionResult.java create mode 100644 spring-graphql-test/src/main/java/org/springframework/graphql/test/query/TestGraphQLError.java create mode 100644 spring-graphql-test/src/main/java/org/springframework/graphql/test/query/TypeRefAdapter.java diff --git a/settings.gradle b/settings.gradle index 6cc8b2da..a9ecddc9 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 00000000..1c128618 --- /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 00000000..628b8520 --- /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 00000000..fe9ba99f --- /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 00000000..fd0a0675 --- /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 00000000..3134dde0 --- /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 00000000..34610bc6 --- /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-web/src/main/java/org/springframework/graphql/RequestInput.java b/spring-graphql-web/src/main/java/org/springframework/graphql/RequestInput.java index 938f5e01..e23c81e1 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 class RequestInput { if (getOperationName() != null) { map.put("operationName", getOperationName()); } - if (CollectionUtils.isEmpty(getVariables())) { + if (!CollectionUtils.isEmpty(getVariables())) { map.put("variables", new LinkedHashMap<>(getVariables())); } return map;