Add GraphQLTester

See gh-43
This commit is contained in:
Rossen Stoyanchev
2021-04-15 13:00:20 +01:00
parent 645cf21f17
commit e22d3e8ea4
8 changed files with 1568 additions and 2 deletions

View File

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

View File

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

View File

@@ -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<byte[]> 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<TestExecutionResult> 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<Runnable> 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<String, Object> 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<Map<String, Object>> 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<GraphQLError> errors;
private boolean errorsChecked;
private final Consumer<Runnable> assertDecorator;
private ResponseSpecSupport(List<GraphQLError> errors, Consumer<Runnable> assertDecorator) {
this.errors = errors;
this.assertDecorator = assertDecorator;
}
protected Consumer<Runnable> getAssertDecorator() {
return this.assertDecorator;
}
protected void consumeErrors(Consumer<List<GraphQLError>> 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<Runnable> assertDecorator) {
super(initErrors(documentContext), assertDecorator);
Assert.notNull(documentContext, "DocumentContext is required");
Assert.notNull(assertDecorator, "`assertDecorator` is required");
this.documentContext = documentContext;
}
private static List<GraphQLError> initErrors(DocumentContext documentContext) {
try {
return new ArrayList<>(documentContext.read(
ERRORS_PATH, new TypeRef<List<TestGraphQLError>>() {}));
}
catch (PathNotFoundException ex) {
return Collections.emptyList();
}
}
@Override
public ResponseSpec errorsSatisfy(Consumer<List<GraphQLError>> 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<Runnable> assertDecorator;
private final JsonPath jsonPath;
private final JsonPathExpectationsHelper pathHelper;
private final String content;
DefaultPathSpec(String path, DocumentContext documentContext, Consumer<Runnable> 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 <D> EntitySpec<D, ?> entity(Class<D> entityType) {
D entity = this.documentContext.read(this.jsonPath, new TypeRefAdapter<>(entityType));
return new DefaultEntitySpec<>(entity, this.documentContext, assertDecorator, this.inputPath);
}
@Override
public <D> EntitySpec<D, ?> entity(ParameterizedTypeReference<D> entityType) {
D entity = this.documentContext.read(this.jsonPath, new TypeRefAdapter<>(entityType));
return new DefaultEntitySpec<>(entity, this.documentContext, assertDecorator, this.inputPath);
}
@Override
public <D> ListEntitySpec<D> entityList(Class<D> elementType) {
List<D> entity = this.documentContext.read(this.jsonPath, new TypeRefAdapter<>(List.class, elementType));
return new DefaultListEntitySpec<>(entity, this.documentContext, assertDecorator, this.inputPath);
}
@Override
public <D> ListEntitySpec<D> entityList(ParameterizedTypeReference<D> elementType) {
List<D> 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<D, S extends EntitySpec<D, S>> implements EntitySpec<D, S> {
private final D entity;
private final DocumentContext documentContext;
private final Consumer<Runnable> assertDecorator;
private final String inputPath;
DefaultEntitySpec(D entity, DocumentContext context, Consumer<Runnable> 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<Runnable> getAssertDecorator() {
return this.assertDecorator;
}
@Override
public PathSpec path(String path) {
return new DefaultPathSpec(path, this.documentContext, this.assertDecorator);
}
@Override
public <T extends S> T isEqualTo(Object expected) {
this.assertDecorator.accept(() -> AssertionErrors.assertEquals(this.inputPath, expected, this.entity));
return self();
}
@Override
public <T extends S> T isNotEqualTo(Object other) {
this.assertDecorator.accept(() -> AssertionErrors.assertNotEquals(this.inputPath, other, this.entity));
return self();
}
@Override
public <T extends S> T isSameAs(Object expected) {
this.assertDecorator.accept(() -> AssertionErrors.assertTrue(this.inputPath, expected == this.entity));
return self();
}
@Override
public <T extends S> T isNotSameAs(Object other) {
this.assertDecorator.accept(() -> AssertionErrors.assertTrue(this.inputPath, other != this.entity));
return self();
}
@Override
public <T extends S> T matches(Predicate<D> predicate) {
this.assertDecorator.accept(() -> AssertionErrors.assertTrue(this.inputPath, predicate.test(this.entity)));
return self();
}
@Override
public <T extends S> T satisfies(Consumer<D> consumer) {
this.assertDecorator.accept(() -> consumer.accept(this.entity));
return self();
}
@Override
public D get() {
return this.entity;
}
@SuppressWarnings("unchecked")
private <T extends S> T self() {
return (T) this;
}
}
/**
* {@link ListEntitySpec} implementation.
*/
private static class DefaultListEntitySpec<E> extends DefaultEntitySpec<List<E>, ListEntitySpec<E>>
implements ListEntitySpec<E> {
DefaultListEntitySpec(List<E> entity, DocumentContext context, Consumer<Runnable> decorator, String path) {
super(entity, context, decorator, path);
}
@Override
@SuppressWarnings("unchecked")
public ListEntitySpec<E> contains(E... elements) {
getAssertDecorator().accept(() -> {
List<E> 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<E> doesNotContain(E... elements) {
getAssertDecorator().accept(() -> {
List<E> 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<E> containsExactly(E... elements) {
getAssertDecorator().accept(() -> {
List<E> 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<E> 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<E> 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<E> 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<ExecutionResult> publisher;
private final Configuration jsonPathConfig;
<T> DefaultSubscriptionSpec(
Publisher<ExecutionResult> publisher, List<GraphQLError> errors, Configuration jsonPathConfig,
Consumer<Runnable> assertDecorator) {
super(errors, assertDecorator);
this.publisher = publisher;
this.jsonPathConfig = jsonPathConfig;
}
@Override
public SubscriptionSpec errorsSatisfy(Consumer<List<GraphQLError>> errorConsumer) {
consumeErrors(errorConsumer);
return this;
}
@Override
public Flux<ResponseSpec> 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();
}
}
}

View File

@@ -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}.
*
*
* <p>GraphQL requests to Spring MVC without an HTTP server:
* <pre class="code">
* &#064;SpringBootTest
* &#064;AutoConfigureMockMvc
* public class MyTests {
*
* private GraphQLTester graphQLTester;
*
* &#064;BeforeEach
* public void setUp(&#064;Autowired MockMvc mockMvc) {
* WebTestClient client = MockMvcWebTestClient.bindTo(mockMvc).baseUrl("/graphql").build();
* this.graphQLTester = GraphQLTester.create(client);
* }
* </pre>
*
* <p>GraphQL requests to Spring WebFlux without an HTTP server:
* <pre class="code">
* &#064;SpringBootTest
* &#064;AutoConfigureWebTestClient
* public class MyTests {
*
* private GraphQLTester graphQLTester;
*
* &#064;BeforeEach
* public void setUp(&#064;Autowired WebTestClient client) {
* this.graphQLTester = GraphQLTester.create(client);
* }
* </pre>
*
* <p>GraphQL requests to a running server:
* <pre class="code">
* &#064;SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)
* public class MyTests {
*
* private GraphQLTester graphQLTester;
*
* &#064;BeforeEach
* public void setUp(&#064;Autowired WebTestClient client) {
* this.graphQLTester = GraphQLTester.create(client);
* }
* </pre>
*
* <p>GraphQL requests to any {@link WebGraphQLService}:
* <pre class="code">
* &#064;SpringBootTest
* public class MyTests {
*
* private GraphQLTester graphQLTester;
*
* &#064;BeforeEach
* public void setUp(&#064;Autowired WebGraphQLService service) {
* this.graphQLTester = GraphQLTester.create(service);
* }
* </pre>
*/
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<Map<String, Object>> 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
* <a href="https://github.com/jayway/JsonPath">JsonPath</a>.
*
* @param path the path to switch to
* @return spec for asserting the content under the given path
* @throws AssertionError if the GraphQL response contains
* <a href="https://spec.graphql.org/June2018/#sec-Errors">errors</a>
* 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 <a href="https://spec.graphql.org/June2018/#sec-Errors">errors</a>
* in the response, if any.
* <p>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<List<GraphQLError>> 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 <D> the target entity type
* @return spec to assert the converted entity with
*/
<D> EntitySpec<D, ?> entity(Class<D> entityType);
/**
* Convert the data at the given path to the target type.
* @param entityType the type to convert to
* @param <D> the target entity type
* @return spec to assert the converted entity with
*/
<D> EntitySpec<D, ?> entity(ParameterizedTypeReference<D> 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 <D> the target entity type
* @return spec to assert the converted List of entities with
*/
<D> ListEntitySpec<D> entityList(Class<D> elementType);
/**
* Convert the data at the given path to a List of the target type.
* @param elementType the type to convert to
* @param <D> the target entity type
* @return spec to assert the converted List of entities with
*/
<D> ListEntitySpec<D> entityList(ParameterizedTypeReference<D> elementType);
/**
* Parse the JSON at the given path and the given expected JSON and assert
* that the two are "similar".
* <p>Use of this option requires the
* <a href="https://jsonassert.skyscreamer.org/">JSONassert</a> 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 <D> the entity type
* @param <S> the spec type, including subtypes
*/
interface EntitySpec<D, S extends EntitySpec<D, S>> extends TraverseSpec {
/**
* Assert the converted entity equals the given Object.
* @param expected the expected Object
* @param <T> the spec type
* @return the same spec for more assertions
*/
<T extends S> T isEqualTo(Object expected);
/**
* Assert the converted entity does not equal the given Object.
* @param other the Object to check against
* @param <T> the spec type
* @return the same spec for more assertions
*/
<T extends S> T isNotEqualTo(Object other);
/**
* Assert the converted entity is the same instance as the given Object.
* @param expected the expected Object
* @param <T> the spec type
* @return the same spec for more assertions
*/
<T extends S> 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 <T> the spec type
* @return the same spec for more assertions
*/
<T extends S> T isNotSameAs(Object other);
/**
* Assert the converted entity matches the given predicate.
* @param predicate the expected Object
* @param <T> the spec type
* @return the same spec for more assertions
*/
<T extends S> T matches(Predicate<D> 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 extends S> T satisfies(Consumer<D> consumer);
/**
* Return the converted entity.
*/
D get();
}
/**
* Extension of {@link EntitySpec} for a List of entities.
* @param <E> the type of elements in the list
*/
interface ListEntitySpec<E> extends EntitySpec<List<E>, ListEntitySpec<E>> {
/**
* Assert the list contains the given elements.
* @param elements values that are expected
* @return the same spec for more assertions
*/
@SuppressWarnings("unchecked")
ListEntitySpec<E> 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<E> 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<E> 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<E> 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<E> 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<E> hasSizeGreaterThan(int boundary);
}
/**
* Declare options available to assert a GraphQL Subscription response.
*/
interface SubscriptionSpec {
/**
* Inspect <a href="https://spec.graphql.org/June2018/#sec-Errors">errors</a>
* in the response, if any.
* <p>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<List<GraphQLError>> 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 <T> the entity type
* @return a {@code Flux} of entities that can be further inspected,
* e.g. with {@code reactor.test.StepVerifier}
*/
default <T> Flux<T> toFlux(String path, Class<T> 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<ResponseSpec> toFlux();
}
}

View File

@@ -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<GraphQLError> errors = Collections.emptyList();
private Map<Object, Object> extensions = Collections.emptyMap();
public void setData(Object data) {
this.data = data;
}
@Override
@SuppressWarnings("unchecked")
public <T> T getData() {
return (T) this.data;
}
public void setErrors(List<TestGraphQLError> errors) {
this.errors = new ArrayList<>(errors);
}
@Override
public List<GraphQLError> getErrors() {
return this.errors;
}
@Override
public boolean isDataPresent() {
return getData() != null;
}
public void setExtensions(Map<Object, Object> extensions) {
this.extensions = new LinkedHashMap<>(extensions);
}
@Override
public Map<Object, Object> getExtensions() {
return this.extensions;
}
@Override
public Map<String, Object> toSpecification() {
ExecutionResultImpl.Builder builder = ExecutionResultImpl.newExecutionResult()
.addErrors(this.errors)
.extensions(this.extensions);
if (isDataPresent()) {
builder.data(this.data);
}
return builder.build().toSpecification();
}
}

View File

@@ -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<SourceLocation> locations;
private ErrorClassification errorType;
private List<Object> path;
private Map<String, Object> extensions;
public void setMessage(String message) {
this.message = message;
}
@Override
public String getMessage() {
return this.message;
}
public void setLocations(List<TestSourceLocation> locations) {
this.locations = TestSourceLocation.toSourceLocations(locations);
}
@Override
public List<SourceLocation> getLocations() {
return this.locations;
}
public void setErrorType(ErrorClassification errorType) {
this.errorType = errorType;
}
@Override
public ErrorClassification getErrorType() {
return this.errorType;
}
public void setPath(List<Object> path) {
this.path = path;
}
@Override
public List<Object> getPath() {
return this.path;
}
public void setExtensions(Map<String, Object> extensions) {
this.extensions = extensions;
}
@Override
public Map<String, Object> getExtensions() {
return this.extensions;
}
@Override
public Map<String, Object> 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<SourceLocation> toSourceLocations(List<TestSourceLocation> locations) {
return locations.stream()
.map(location -> new SourceLocation(location.line, location.column, location.sourceName))
.collect(Collectors.toList());
}
}
}

View File

@@ -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<T> extends TypeRef<T> {
private final Type type;
TypeRefAdapter(Class<T> clazz) {
this.type = clazz;
}
TypeRefAdapter(ParameterizedTypeReference<T> 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;
}
}

View File

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