Merge GraphQL test support
Closes gh-43
This commit is contained in:
@@ -13,7 +13,10 @@ dependencies {
|
||||
implementation 'org.springframework.boot:spring-boot-starter-webflux'
|
||||
implementation 'org.springframework.boot:spring-boot-starter-actuator'
|
||||
developmentOnly 'org.springframework.boot:spring-boot-devtools'
|
||||
testImplementation project(':spring-graphql-test')
|
||||
testImplementation 'org.springframework:spring-webflux'
|
||||
testImplementation 'org.springframework.boot:spring-boot-starter-test'
|
||||
testImplementation 'io.projectreactor:reactor-test'
|
||||
}
|
||||
test {
|
||||
useJUnitPlatform()
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
/*
|
||||
* Copyright 2002-2021 the original author or authors.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
package io.spring.sample.graphql;
|
||||
|
||||
import graphql.GraphQL;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import reactor.core.publisher.Flux;
|
||||
import reactor.test.StepVerifier;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.graphql.WebGraphQLService;
|
||||
import org.springframework.graphql.test.query.GraphQLTester;
|
||||
|
||||
/**
|
||||
* GraphQL subscription tests directly via {@link GraphQL}.
|
||||
*/
|
||||
@SpringBootTest
|
||||
public class SubscriptionGraphQLTests {
|
||||
|
||||
private GraphQLTester graphQLTester;
|
||||
|
||||
|
||||
@BeforeEach
|
||||
public void setUp(@Autowired WebGraphQLService service) {
|
||||
this.graphQLTester = GraphQLTester.create(service);
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
void subscriptionWithEntityPath() {
|
||||
String query = "subscription { greetings }";
|
||||
|
||||
Flux<String> result = this.graphQLTester.query(query)
|
||||
.executeSubscription()
|
||||
.toFlux("greetings", String.class);
|
||||
|
||||
StepVerifier.create(result)
|
||||
.expectNext("Hi", "Bonjour", "Hola", "Ciao", "Zdravo")
|
||||
.verifyComplete();
|
||||
}
|
||||
|
||||
@Test
|
||||
void subscriptionWithResponseSpec() {
|
||||
String query = "subscription { greetings }";
|
||||
|
||||
Flux<GraphQLTester.ResponseSpec> result = this.graphQLTester.query(query)
|
||||
.executeSubscription()
|
||||
.toFlux();
|
||||
|
||||
StepVerifier.create(result)
|
||||
.consumeNextWith(spec -> spec.path("greetings").valueExists())
|
||||
.consumeNextWith(spec -> spec.path("greetings").matchesJson("\"Bonjour\""))
|
||||
.consumeNextWith(spec -> spec.path("greetings").matchesJson("\"Hola\""))
|
||||
.expectNextCount(2)
|
||||
.verifyComplete();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -16,6 +16,7 @@ dependencies {
|
||||
implementation 'org.springframework.boot:spring-boot-starter-actuator'
|
||||
developmentOnly 'org.springframework.boot:spring-boot-devtools'
|
||||
runtimeOnly 'com.h2database:h2'
|
||||
testImplementation project(':spring-graphql-test')
|
||||
testImplementation 'org.springframework:spring-webflux'
|
||||
testImplementation 'org.springframework.boot:spring-boot-starter-test'
|
||||
}
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
/*
|
||||
* Copyright 2002-2021 the original author or authors.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
package io.spring.sample.graphql.project;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
|
||||
import org.springframework.lang.Nullable;
|
||||
import org.springframework.util.CollectionUtils;
|
||||
|
||||
class JsonRequest {
|
||||
|
||||
private final String query;
|
||||
|
||||
@Nullable
|
||||
private final String operationName;
|
||||
|
||||
private final Map<String, Object> variables;
|
||||
|
||||
|
||||
private JsonRequest(String query, @Nullable String operationName, Map<String, Object> variables) {
|
||||
this.query = query;
|
||||
this.operationName = operationName;
|
||||
this.variables = (!CollectionUtils.isEmpty(variables) ?
|
||||
new LinkedHashMap<>(variables) : Collections.emptyMap());
|
||||
}
|
||||
|
||||
|
||||
public String getQuery() {
|
||||
return query;
|
||||
}
|
||||
|
||||
public String query() {
|
||||
return this.query;
|
||||
}
|
||||
|
||||
@Nullable
|
||||
public String operationName() {
|
||||
return this.operationName;
|
||||
}
|
||||
|
||||
public Map<String, Object> variables() {
|
||||
return this.variables;
|
||||
}
|
||||
|
||||
public static JsonRequest create(String query) {
|
||||
return new JsonRequest(query, null, Collections.emptyMap());
|
||||
}
|
||||
|
||||
public static JsonRequest create(String query, String operationName, Map<String, Object> variables) {
|
||||
return new JsonRequest(query, operationName, variables);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
/*
|
||||
* Copyright 2002-2021 the original author or authors.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
package io.spring.sample.graphql.project;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.Map;
|
||||
|
||||
import org.springframework.lang.Nullable;
|
||||
|
||||
class JsonResponse<T> {
|
||||
|
||||
private Map<String, T> data = Collections.emptyMap();
|
||||
|
||||
private Map<String, Map<String, Object>> errors = Collections.emptyMap();
|
||||
|
||||
|
||||
public void setData(@Nullable Map<String, T> data) {
|
||||
this.data = data;
|
||||
}
|
||||
|
||||
public T getDataEntry() {
|
||||
return this.data.values().iterator().next();
|
||||
}
|
||||
|
||||
public void setErrors(@Nullable Map<String, Map<String, Object>> errors) {
|
||||
this.errors = errors;
|
||||
}
|
||||
|
||||
public Map<String, Map<String, Object>> getErrors() {
|
||||
return this.errors;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -15,17 +15,13 @@
|
||||
*/
|
||||
package io.spring.sample.graphql.project;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.core.ParameterizedTypeReference;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.graphql.test.query.GraphQLTester;
|
||||
import org.springframework.test.web.reactive.server.WebTestClient;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
import org.springframework.test.web.servlet.client.MockMvcWebTestClient;
|
||||
@@ -33,23 +29,22 @@ import org.springframework.test.web.servlet.client.MockMvcWebTestClient;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
/**
|
||||
* Example tests using {@link WebTestClient} to connect to {@link MockMvc} as
|
||||
* the "mock" server, i.e. without running an HTTP server.
|
||||
* GraphQL requests via {@link WebTestClient} connecting to {@link MockMvc}.
|
||||
*/
|
||||
@SpringBootTest
|
||||
@AutoConfigureMockMvc
|
||||
public class MockWebTestClientTests {
|
||||
public class MockMvcGraphQLTests {
|
||||
|
||||
private GraphQLTester graphQLTester;
|
||||
|
||||
private WebTestClient client;
|
||||
|
||||
@BeforeEach
|
||||
public void setUp(@Autowired MockMvc mockMvc) {
|
||||
this.client = MockMvcWebTestClient.bindTo(mockMvc)
|
||||
.baseUrl("/graphql")
|
||||
.defaultHeaders(headers -> headers.setContentType(MediaType.APPLICATION_JSON))
|
||||
.build();
|
||||
WebTestClient client = MockMvcWebTestClient.bindTo(mockMvc).baseUrl("/graphql").build();
|
||||
this.graphQLTester = GraphQLTester.create(client);
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
void jsonPath() {
|
||||
String query = "{" +
|
||||
@@ -60,13 +55,12 @@ public class MockWebTestClientTests {
|
||||
" }" +
|
||||
"}";
|
||||
|
||||
this.client.post().bodyValue(JsonRequest.create(query))
|
||||
.exchange()
|
||||
.expectStatus().isOk()
|
||||
.expectBody().jsonPath("$.data.project.releases[*].version")
|
||||
.value((Consumer<List<String>>) versions -> {
|
||||
assertThat(versions).hasSizeGreaterThan(1);
|
||||
});
|
||||
this.graphQLTester.query(query)
|
||||
.execute()
|
||||
.path("project.releases[*].version")
|
||||
.entityList(String.class)
|
||||
.hasSizeGreaterThan(1);
|
||||
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -77,18 +71,10 @@ public class MockWebTestClientTests {
|
||||
" }" +
|
||||
"}";
|
||||
|
||||
String expectedJson = "{" +
|
||||
" \"data\":{" +
|
||||
" \"project\":{" +
|
||||
" \"repositoryUrl\":\"http://github.com/spring-projects/spring-framework\"" +
|
||||
" }" +
|
||||
" }" +
|
||||
"}";
|
||||
|
||||
this.client.post().bodyValue(JsonRequest.create(query))
|
||||
.exchange()
|
||||
.expectStatus().isOk()
|
||||
.expectBody().json(expectedJson);
|
||||
this.graphQLTester.query(query)
|
||||
.execute()
|
||||
.path("project")
|
||||
.matchesJson("{\"repositoryUrl\":\"http://github.com/spring-projects/spring-framework\"}");
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -101,14 +87,11 @@ public class MockWebTestClientTests {
|
||||
" }" +
|
||||
"}";
|
||||
|
||||
this.client.post().bodyValue(JsonRequest.create(query))
|
||||
.exchange()
|
||||
.expectStatus().isOk()
|
||||
.expectBody(new ParameterizedTypeReference<JsonResponse<Project>>() {})
|
||||
.consumeWith(exchangeResult -> {
|
||||
Project project = exchangeResult.getResponseBody().getDataEntry();
|
||||
assertThat(project.getReleases()).hasSizeGreaterThan(1);
|
||||
});
|
||||
this.graphQLTester.query(query)
|
||||
.execute()
|
||||
.path("project")
|
||||
.entity(Project.class)
|
||||
.satisfies(project -> assertThat(project.getReleases()).hasSizeGreaterThan(1));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
63
spring-graphql-test/build.gradle
Normal file
63
spring-graphql-test/build.gradle
Normal 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"
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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">
|
||||
* @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);
|
||||
* }
|
||||
* </pre>
|
||||
*
|
||||
* <p>GraphQL requests to Spring WebFlux without an HTTP server:
|
||||
* <pre class="code">
|
||||
* @SpringBootTest
|
||||
* @AutoConfigureWebTestClient
|
||||
* public class MyTests {
|
||||
*
|
||||
* private GraphQLTester graphQLTester;
|
||||
*
|
||||
* @BeforeEach
|
||||
* public void setUp(@Autowired WebTestClient client) {
|
||||
* this.graphQLTester = GraphQLTester.create(client);
|
||||
* }
|
||||
* </pre>
|
||||
*
|
||||
* <p>GraphQL requests to a running server:
|
||||
* <pre class="code">
|
||||
* @SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)
|
||||
* public class MyTests {
|
||||
*
|
||||
* private GraphQLTester graphQLTester;
|
||||
*
|
||||
* @BeforeEach
|
||||
* public void setUp(@Autowired WebTestClient client) {
|
||||
* this.graphQLTester = GraphQLTester.create(client);
|
||||
* }
|
||||
* </pre>
|
||||
*
|
||||
* <p>GraphQL requests to any {@link WebGraphQLService}:
|
||||
* <pre class="code">
|
||||
* @SpringBootTest
|
||||
* public class MyTests {
|
||||
*
|
||||
* private GraphQLTester graphQLTester;
|
||||
*
|
||||
* @BeforeEach
|
||||
* public void setUp(@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();
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,402 @@
|
||||
/*
|
||||
* Copyright 2002-2021 the original author or authors.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
package org.springframework.graphql.test.query;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.stream.Collectors;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import graphql.ExecutionResult;
|
||||
import graphql.ExecutionResultImpl;
|
||||
import graphql.GraphQLError;
|
||||
import graphql.GraphqlErrorBuilder;
|
||||
import graphql.language.SourceLocation;
|
||||
import okhttp3.mockwebserver.MockResponse;
|
||||
import okhttp3.mockwebserver.MockWebServer;
|
||||
import okhttp3.mockwebserver.RecordedRequest;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.MethodSource;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
import org.springframework.core.ParameterizedTypeReference;
|
||||
import org.springframework.graphql.WebGraphQLService;
|
||||
import org.springframework.graphql.WebInput;
|
||||
import org.springframework.graphql.WebOutput;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.lang.Nullable;
|
||||
import org.springframework.test.web.reactive.server.WebTestClient;
|
||||
import org.springframework.util.CollectionUtils;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* Tests for {@link GraphQLTester} parameterized to:
|
||||
* <ul>
|
||||
* <li>Connect to {@link MockWebServer} and return a preset HTTP response.
|
||||
* <li>Use mock {@link WebGraphQLService} to return a preset {@link ExecutionResult}.
|
||||
* </ul>
|
||||
*
|
||||
* <p>There is no actual handling via {@link graphql.GraphQL} in either scenario.
|
||||
* The main focus is to verify {@link GraphQLTester} request preparation and
|
||||
* response handling.
|
||||
*/
|
||||
public class GraphQLTesterTests {
|
||||
|
||||
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
|
||||
|
||||
|
||||
public static Stream<GraphQLTesterSetup> argumentSource() {
|
||||
return Stream.of(new MockWebServerSetup(), new MockWebGraphQLServiceSetup());
|
||||
}
|
||||
|
||||
|
||||
@ParameterizedTest
|
||||
@MethodSource("argumentSource")
|
||||
void pathAndValueExistsAndEmptyChecks(GraphQLTesterSetup setup) throws Exception {
|
||||
|
||||
String query = "{me {name, friends}}";
|
||||
setup.response("{\"me\": {\"name\":\"Luke Skywalker\", \"friends\":[]}}");
|
||||
|
||||
GraphQLTester.ResponseSpec spec = setup.graphQLTester().query(query).execute();
|
||||
|
||||
spec.path("me.name").pathExists().valueExists().valueIsNotEmpty();
|
||||
spec.path("me.friends").valueIsEmpty();
|
||||
spec.path("hero").pathDoesNotExist().valueDoesNotExist().valueIsEmpty();
|
||||
|
||||
setup.verifyRequest(input -> assertThat(input.getQuery()).contains(query));
|
||||
setup.shutdown();
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@MethodSource("argumentSource")
|
||||
void matchesJson(GraphQLTesterSetup setup) throws Exception {
|
||||
|
||||
String query = "{me {name}}";
|
||||
setup.response("{\"me\": {\"name\":\"Luke Skywalker\", \"friends\":[]}}");
|
||||
|
||||
GraphQLTester.ResponseSpec spec = setup.graphQLTester().query(query).execute();
|
||||
|
||||
spec.path("").matchesJson("{\"me\": {\"name\":\"Luke Skywalker\",\"friends\":[]}}");
|
||||
spec.path("me").matchesJson("{\"name\":\"Luke Skywalker\"}");
|
||||
spec.path("me").matchesJson("{\"friends\":[]}"); // lenient match with subset of fields
|
||||
|
||||
assertThatThrownBy(() -> spec.path("me").matchesJsonStrictly("{\"friends\":[]}"))
|
||||
.as("Extended fields should fail in strict mode")
|
||||
.hasMessageContaining("Unexpected: name");
|
||||
|
||||
setup.verifyRequest(input -> assertThat(input.getQuery()).contains(query));
|
||||
setup.shutdown();
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@MethodSource("argumentSource")
|
||||
void entity(GraphQLTesterSetup setup) throws Exception {
|
||||
|
||||
String query = "{me {name}}";
|
||||
setup.response("{\"me\": {\"name\":\"Luke Skywalker\"}}");
|
||||
|
||||
GraphQLTester.ResponseSpec spec = setup.graphQLTester().query(query).execute();
|
||||
|
||||
MovieCharacter luke = MovieCharacter.create("Luke Skywalker");
|
||||
MovieCharacter han = MovieCharacter.create("Han Solo");
|
||||
AtomicReference<MovieCharacter> personRef = new AtomicReference<>();
|
||||
|
||||
MovieCharacter actual = spec.path("me").entity(MovieCharacter.class)
|
||||
.isEqualTo(luke)
|
||||
.isNotEqualTo(han)
|
||||
.satisfies(personRef::set)
|
||||
.matches(movieCharacter -> personRef.get().equals(movieCharacter))
|
||||
.isSameAs(personRef.get())
|
||||
.isNotSameAs(luke)
|
||||
.get();
|
||||
|
||||
assertThat(actual.getName()).isEqualTo("Luke Skywalker");
|
||||
|
||||
spec.path("")
|
||||
.entity(new ParameterizedTypeReference<Map<String, MovieCharacter>>() {})
|
||||
.isEqualTo(Collections.singletonMap("me", luke));
|
||||
|
||||
setup.verifyRequest(input -> assertThat(input.getQuery()).contains(query));
|
||||
setup.shutdown();
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@MethodSource("argumentSource")
|
||||
void entityList(GraphQLTesterSetup setup) throws Exception {
|
||||
|
||||
String query = "{me {name, friends}}";
|
||||
setup.response("{" +
|
||||
" \"me\":{" +
|
||||
" \"name\":\"Luke Skywalker\"," +
|
||||
" \"friends\":[{\"name\":\"Han Solo\"}, {\"name\":\"Leia Organa\"}]" +
|
||||
" }" +
|
||||
"}"
|
||||
);
|
||||
|
||||
GraphQLTester.ResponseSpec spec = setup.graphQLTester().query(query).execute();
|
||||
|
||||
MovieCharacter han = MovieCharacter.create("Han Solo");
|
||||
MovieCharacter leia = MovieCharacter.create("Leia Organa");
|
||||
MovieCharacter jabba = MovieCharacter.create("Jabba the Hutt");
|
||||
|
||||
List<MovieCharacter> actual = spec.path("me.friends").entityList(MovieCharacter.class)
|
||||
.contains(han)
|
||||
.containsExactly(han, leia)
|
||||
.doesNotContain(jabba)
|
||||
.hasSize(2)
|
||||
.hasSizeGreaterThan(1)
|
||||
.hasSizeLessThan(3)
|
||||
.get();
|
||||
|
||||
assertThat(actual).containsExactly(han, leia);
|
||||
|
||||
spec.path("me.friends")
|
||||
.entityList(new ParameterizedTypeReference<MovieCharacter>() {})
|
||||
.containsExactly(han, leia);
|
||||
|
||||
setup.verifyRequest(input -> assertThat(input.getQuery()).contains(query));
|
||||
setup.shutdown();
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@MethodSource("argumentSource")
|
||||
void operationNameAndVariables(GraphQLTesterSetup setup) throws Exception {
|
||||
|
||||
String query = "query HeroNameAndFriends($episode: Episode) {" +
|
||||
" hero(episode: $episode) {" +
|
||||
" name" +
|
||||
" }" +
|
||||
"}";
|
||||
|
||||
setup.response("{\"hero\": {\"name\":\"R2-D2\"}}");
|
||||
|
||||
GraphQLTester.ResponseSpec spec = setup.graphQLTester().query(query)
|
||||
.operationName("HeroNameAndFriends")
|
||||
.variable("episode", "JEDI")
|
||||
.variables(map -> map.put("foo", "bar"))
|
||||
.execute();
|
||||
|
||||
spec.path("hero").entity(MovieCharacter.class).isEqualTo(MovieCharacter.create("R2-D2"));
|
||||
|
||||
setup.verifyRequest(input -> {
|
||||
assertThat(input.getQuery()).contains(query);
|
||||
assertThat(input.getOperationName()).isEqualTo("HeroNameAndFriends");
|
||||
assertThat(input.getVariables()).hasSize(2);
|
||||
assertThat(input.getVariables()).containsEntry("episode", "JEDI");
|
||||
assertThat(input.getVariables()).containsEntry("foo", "bar");
|
||||
});
|
||||
setup.shutdown();
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@MethodSource("argumentSource")
|
||||
void errorsAssertedIfNotChecked(GraphQLTesterSetup setup) throws Exception {
|
||||
|
||||
String query = "{me {name, friends}}";
|
||||
setup.response(GraphqlErrorBuilder.newError()
|
||||
.message("Invalid query")
|
||||
.location(new SourceLocation(1, 2))
|
||||
.build());
|
||||
|
||||
GraphQLTester.ResponseSpec spec = setup.graphQLTester().query(query).execute();
|
||||
|
||||
assertThatThrownBy(() -> spec.path("me")).hasMessageContaining("Response contains GraphQL errors.");
|
||||
|
||||
setup.verifyRequest(input -> assertThat(input.getQuery()).contains(query));
|
||||
setup.shutdown();
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@MethodSource("argumentSource")
|
||||
void errorsAssertedOnExecuteAndVerify(GraphQLTesterSetup setup) throws Exception {
|
||||
|
||||
String query = "{me {name, friends}}";
|
||||
setup.response(GraphqlErrorBuilder.newError()
|
||||
.message("Invalid query")
|
||||
.location(new SourceLocation(1, 2))
|
||||
.build());
|
||||
|
||||
assertThatThrownBy(() -> setup.graphQLTester().query(query).executeAndVerify())
|
||||
.hasMessageContaining("Response contains GraphQL errors.");
|
||||
|
||||
setup.verifyRequest(input -> assertThat(input.getQuery()).contains(query));
|
||||
setup.shutdown();
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@MethodSource("argumentSource")
|
||||
void errorsAllowedIfChecked(GraphQLTesterSetup setup) throws Exception {
|
||||
|
||||
String query = "{me {name, friends}}";
|
||||
setup.response(GraphqlErrorBuilder.newError()
|
||||
.message("Invalid query")
|
||||
.location(new SourceLocation(1, 2))
|
||||
.build());
|
||||
|
||||
setup.graphQLTester().query(query).execute()
|
||||
.errorsSatisfy(errors -> {
|
||||
assertThat(errors).hasSize(1);
|
||||
assertThat(errors.get(0).getMessage()).isEqualTo("Invalid query");
|
||||
assertThat(errors.get(0).getLocations()).hasSize(1);
|
||||
assertThat(errors.get(0).getLocations().get(0).getLine()).isEqualTo(1);
|
||||
assertThat(errors.get(0).getLocations().get(0).getColumn()).isEqualTo(2);
|
||||
})
|
||||
.path("me").pathDoesNotExist();
|
||||
|
||||
setup.verifyRequest(input -> assertThat(input.getQuery()).contains(query));
|
||||
setup.shutdown();
|
||||
}
|
||||
|
||||
|
||||
private interface GraphQLTesterSetup {
|
||||
|
||||
GraphQLTester graphQLTester();
|
||||
|
||||
default void response(String data) throws Exception {
|
||||
response(data, Collections.emptyList());
|
||||
}
|
||||
|
||||
default void response(GraphQLError... errors) throws Exception {
|
||||
response(null, Arrays.asList(errors));
|
||||
}
|
||||
|
||||
void response(@Nullable String data, List<GraphQLError> errors) throws Exception;
|
||||
|
||||
void verifyRequest(Consumer<WebInput> consumer) throws Exception;
|
||||
|
||||
default void shutdown() throws Exception {
|
||||
// no-op by default
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
private static class MockWebServerSetup implements GraphQLTesterSetup {
|
||||
|
||||
private final MockWebServer server;
|
||||
|
||||
private final GraphQLTester graphQLTester;
|
||||
|
||||
MockWebServerSetup() {
|
||||
this.server = new MockWebServer();
|
||||
this.graphQLTester = GraphQLTester.create(initWebTestClient(this.server));
|
||||
}
|
||||
|
||||
private static WebTestClient initWebTestClient(MockWebServer server) {
|
||||
String baseUrl = server.url("/graphQL").toString();
|
||||
return WebTestClient.bindToServer().baseUrl(baseUrl).build();
|
||||
}
|
||||
|
||||
@Override
|
||||
public GraphQLTester graphQLTester() {
|
||||
return this.graphQLTester;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void response(@Nullable String data, List<GraphQLError> errors) throws Exception {
|
||||
StringBuilder sb = new StringBuilder("{");
|
||||
if (StringUtils.hasText(data)) {
|
||||
sb.append("\"data\":").append(data);
|
||||
}
|
||||
if (!CollectionUtils.isEmpty(errors)) {
|
||||
List<Map<String, Object>> errorSpecs = errors.stream()
|
||||
.map(GraphQLError::toSpecification)
|
||||
.collect(Collectors.toList());
|
||||
|
||||
sb.append(StringUtils.hasText(data) ? ", " : "")
|
||||
.append("\"errors\":")
|
||||
.append(OBJECT_MAPPER.writeValueAsString(errorSpecs));
|
||||
}
|
||||
sb.append("}");
|
||||
|
||||
MockResponse response = new MockResponse();
|
||||
response.setHeader("Content-Type", "application/json");
|
||||
response.setBody(sb.toString());
|
||||
|
||||
this.server.enqueue(response);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void verifyRequest(Consumer<WebInput> consumer) throws Exception {
|
||||
assertThat(this.server.getRequestCount()).isEqualTo(1);
|
||||
RecordedRequest request = this.server.takeRequest();
|
||||
assertThat(request.getHeader(HttpHeaders.CONTENT_TYPE)).isEqualTo("application/json");
|
||||
|
||||
String content = request.getBody().readUtf8();
|
||||
Map<String, Object> map = new ObjectMapper().readValue(content, new TypeReference<Map<String, Object>>() {});
|
||||
WebInput webInput = new WebInput(request.getRequestUrl().uri(), new HttpHeaders(), map, null);
|
||||
|
||||
consumer.accept(webInput);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void shutdown() throws Exception {
|
||||
this.server.shutdown();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private static class MockWebGraphQLServiceSetup implements GraphQLTesterSetup {
|
||||
|
||||
private final WebGraphQLService service = mock(WebGraphQLService.class);
|
||||
|
||||
private final ArgumentCaptor<WebInput> bodyCaptor = ArgumentCaptor.forClass(WebInput.class);
|
||||
|
||||
private final GraphQLTester graphQLTester;
|
||||
|
||||
public MockWebGraphQLServiceSetup() {
|
||||
this.graphQLTester = GraphQLTester.create(this.service);
|
||||
}
|
||||
|
||||
@Override
|
||||
public GraphQLTester graphQLTester() {
|
||||
return this.graphQLTester;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void response(@Nullable String data, List<GraphQLError> errors) throws Exception {
|
||||
ExecutionResultImpl.Builder builder = new ExecutionResultImpl.Builder();
|
||||
if (data != null) {
|
||||
builder.data(OBJECT_MAPPER.readValue(data, new TypeReference<Map<String, Object>>() {}));
|
||||
}
|
||||
if (!CollectionUtils.isEmpty(errors)) {
|
||||
builder.addErrors(errors);
|
||||
}
|
||||
ExecutionResult result = builder.build();
|
||||
WebOutput output = new WebOutput(mock(WebInput.class), result);
|
||||
when(this.service.execute(this.bodyCaptor.capture())).thenReturn(Mono.just(output));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void verifyRequest(Consumer<WebInput> consumer) {
|
||||
WebInput webInput = this.bodyCaptor.getValue();
|
||||
consumer.accept(webInput);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
/*
|
||||
* Copyright 2002-2021 the original author or authors.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
package org.springframework.graphql.test.query;
|
||||
|
||||
import org.springframework.lang.Nullable;
|
||||
|
||||
public class MovieCharacter {
|
||||
|
||||
@Nullable
|
||||
private String name;
|
||||
|
||||
public void setName(String name) {
|
||||
this.name = name;
|
||||
}
|
||||
|
||||
@Nullable
|
||||
public String getName() {
|
||||
return this.name;
|
||||
}
|
||||
|
||||
public static MovieCharacter create(String name) {
|
||||
MovieCharacter movieCharacter = new MovieCharacter();
|
||||
movieCharacter.setName(name);
|
||||
return movieCharacter;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(Object other) {
|
||||
if (this == other) {
|
||||
return true;
|
||||
}
|
||||
if (other == null || getClass() != other.getClass()) {
|
||||
return false;
|
||||
}
|
||||
MovieCharacter movieCharacter = (MovieCharacter) other;
|
||||
return (this.name != null ? this.name.equals(movieCharacter.name) : movieCharacter.name == null);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
return (this.name != null ? this.name.hashCode() : 0);
|
||||
}
|
||||
}
|
||||
15
spring-graphql-test/src/test/resources/log4j2-test.xml
Normal file
15
spring-graphql-test/src/test/resources/log4j2-test.xml
Normal file
@@ -0,0 +1,15 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Configuration status="WARN">
|
||||
<Appenders>
|
||||
<Console name="Console" target="SYSTEM_OUT">
|
||||
<PatternLayout pattern="%d{HH:mm:ss.SSS} [%t] %-5level %logger{1.} - %msg%n" />
|
||||
</Console>
|
||||
</Appenders>
|
||||
<Loggers>
|
||||
<Logger name="org.springframework.web" level="debug" />
|
||||
<Logger name="org.springframework.http" level="debug" />
|
||||
<Root level="error">
|
||||
<AppenderRef ref="Console" />
|
||||
</Root>
|
||||
</Loggers>
|
||||
</Configuration>
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user