Testing Spring GraphQL Applications - A Complete Guide

Confidence in your GraphQL API comes from comprehensive testing. Learn unit testing, integration testing, and testing best practices for Spring GraphQL.
Why GraphQL Testing is Different
GraphQL testing has unique characteristics:
- Dynamic queries: Clients can request any combination of fields
- Nested data: Responses can be deeply nested
- Partial failures: Some fields can succeed while others fail
- Schema validation: Queries must conform to the schema
Spring GraphQL provides excellent testing utilities. Let's explore them all.
Setting Up Test Dependencies
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.graphql</groupId>
<artifactId>spring-graphql-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
Unit Testing Controllers
Testing with GraphQlTester
The GraphQlTester is Spring's primary testing utility:
@SpringBootTest
@AutoConfigureGraphQlTester
class BookControllerTest {
@Autowired
private GraphQlTester graphQlTester;
@Test
void shouldReturnAllBooks() {
graphQlTester.document("""
query {
books {
id
title
publishedYear
}
}
""")
.execute()
.path("books")
.entityList(Book.class)
.hasSizeGreaterThan(0);
}
@Test
void shouldReturnBookById() {
graphQlTester.document("""
query GetBook($id: ID!) {
bookById(id: $id) {
id
title
author {
name
}
}
}
""")
.variable("id", "1")
.execute()
.path("bookById.title")
.entity(String.class)
.isEqualTo("The Great Gatsby")
.path("bookById.author.name")
.entity(String.class)
.isEqualTo("F. Scott Fitzgerald");
}
}
Path-Based Assertions
Navigate JSON paths fluently:
@Test
void shouldNavigateNestedPaths() {
graphQlTester.document("""
query {
books {
title
author {
name
books {
title
}
}
}
}
""")
.execute()
// Check first book's title
.path("books[0].title")
.entity(String.class)
.satisfies(title -> assertThat(title).isNotBlank())
// Check nested author
.path("books[0].author.name")
.entity(String.class)
.isNotEqualTo("")
// Check author's other books
.path("books[0].author.books")
.entityList(Book.class)
.hasSizeGreaterThan(0);
}
Testing with Variables
@Test
void shouldFilterBooks() {
graphQlTester.document("""
query SearchBooks($genre: String, $year: Int) {
books(filter: { genre: $genre, publishedAfter: $year }) {
title
genre
publishedYear
}
}
""")
.variable("genre", "Fiction")
.variable("year", 1950)
.execute()
.path("books")
.entityList(Book.class)
.satisfies(books -> {
assertThat(books).allMatch(b -> b.genre().equals("Fiction"));
assertThat(books).allMatch(b -> b.publishedYear() > 1950);
});
}
Testing Mutations
@Test
void shouldCreateBook() {
graphQlTester.document("""
mutation CreateBook($input: CreateBookInput!) {
createBook(input: $input) {
id
title
author {
name
}
}
}
""")
.variable("input", Map.of(
"title", "New Book",
"authorId", "1",
"publishedYear", 2024
))
.execute()
.path("createBook.id")
.entity(String.class)
.satisfies(id -> assertThat(id).isNotBlank())
.path("createBook.title")
.entity(String.class)
.isEqualTo("New Book");
}
@Test
void shouldUpdateBook() {
// First create a book
String bookId = createTestBook();
// Then update it
graphQlTester.document("""
mutation UpdateBook($id: ID!, $input: UpdateBookInput!) {
updateBook(id: $id, input: $input) {
id
title
}
}
""")
.variable("id", bookId)
.variable("input", Map.of("title", "Updated Title"))
.execute()
.path("updateBook.title")
.entity(String.class)
.isEqualTo("Updated Title");
}
@Test
void shouldDeleteBook() {
String bookId = createTestBook();
graphQlTester.document("""
mutation DeleteBook($id: ID!) {
deleteBook(id: $id) {
success
message
}
}
""")
.variable("id", bookId)
.execute()
.path("deleteBook.success")
.entity(Boolean.class)
.isEqualTo(true);
// Verify deletion
graphQlTester.document("""
query GetBook($id: ID!) {
bookById(id: $id) {
id
}
}
""")
.variable("id", bookId)
.execute()
.path("bookById")
.valueIsNull();
}
Testing Error Handling
@Test
void shouldReturnErrorForInvalidId() {
graphQlTester.document("""
query {
bookById(id: "invalid-id") {
title
}
}
""")
.execute()
.errors()
.satisfy(errors -> {
assertThat(errors).hasSize(1);
assertThat(errors.get(0).getMessage())
.contains("Book not found");
assertThat(errors.get(0).getExtensions().get("code"))
.isEqualTo("BOOK_NOT_FOUND");
});
}
@Test
void shouldReturnValidationErrors() {
graphQlTester.document("""
mutation {
createBook(input: { title: "", authorId: "1" }) {
id
}
}
""")
.execute()
.errors()
.satisfy(errors -> {
assertThat(errors).isNotEmpty();
assertThat(errors.get(0).getMessage())
.contains("Title is required");
});
}
Expecting Errors
@Test
void shouldExpectSpecificError() {
graphQlTester.document("""
query {
bookById(id: "non-existent") {
title
}
}
""")
.execute()
.errors()
.expect(error -> error.getExtensions().get("code").equals("NOT_FOUND"))
.verify()
.path("bookById")
.valueIsNull();
}
Slice Testing with @GraphQlTest
For faster tests that only load GraphQL components:
@GraphQlTest(BookController.class)
class BookControllerSliceTest {
@Autowired
private GraphQlTester graphQlTester;
@MockBean
private BookRepository bookRepository;
@MockBean
private AuthorRepository authorRepository;
@Test
void shouldReturnBooks() {
// Setup mocks
when(bookRepository.findAll()).thenReturn(List.of(
new Book("1", "Test Book", "author-1", 2024, "Fiction")
));
graphQlTester.document("""
query {
books {
id
title
}
}
""")
.execute()
.path("books")
.entityList(Book.class)
.hasSize(1)
.contains(new Book("1", "Test Book", "author-1", 2024, "Fiction"));
}
}
Testing with HTTP
Test the full HTTP stack using HttpGraphQlTester:
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class BookControllerHttpTest {
@Autowired
private WebTestClient webTestClient;
private HttpGraphQlTester graphQlTester;
@BeforeEach
void setUp() {
graphQlTester = HttpGraphQlTester.create(webTestClient);
}
@Test
void shouldHandleHttpRequest() {
graphQlTester
.mutate()
.header("Authorization", "Bearer test-token")
.build()
.document("""
query {
books {
title
}
}
""")
.execute()
.path("books")
.entityList(Book.class)
.hasSizeGreaterThan(0);
}
}
Testing Subscriptions
@SpringBootTest
class BookSubscriptionTest {
@Autowired
private ExecutionGraphQlService graphQlService;
@Autowired
private BookSubscriptionController subscriptionController;
@Test
void shouldReceiveBookAddedEvents() {
// Create the subscription
Flux<ExecutionResult> subscription = graphQlService.execute(
ExecutionInput.newExecutionInput()
.query("""
subscription {
bookAdded {
id
title
}
}
""")
.build()
).flatMapMany(result -> Flux.from(result.getData()));
// Test with StepVerifier
StepVerifier.create(subscription.take(2))
.then(() -> {
// Publish events
subscriptionController.publishBookAdded(
new Book("1", "Book 1", "a1", 2024, "Fiction"));
subscriptionController.publishBookAdded(
new Book("2", "Book 2", "a1", 2024, "Fiction"));
})
.assertNext(result -> {
Map<String, Object> data = result.getData();
assertThat(data.get("bookAdded"))
.extracting("title")
.isEqualTo("Book 1");
})
.assertNext(result -> {
Map<String, Object> data = result.getData();
assertThat(data.get("bookAdded"))
.extracting("title")
.isEqualTo("Book 2");
})
.verifyComplete();
}
}
Testing DataLoaders
Verify batch loading works correctly:
@SpringBootTest
@AutoConfigureGraphQlTester
class BatchLoadingTest {
@Autowired
private GraphQlTester graphQlTester;
@Autowired
private TestEntityManager entityManager;
@Test
void shouldBatchLoadAuthors() {
// Setup: Create 10 books with 3 authors
// ...
// Enable SQL logging to count queries
LogCaptor logCaptor = LogCaptor.forClass(org.hibernate.SQL.class);
graphQlTester.document("""
query {
books {
title
author {
name
}
}
}
""")
.execute()
.path("books")
.entityList(Book.class)
.hasSize(10);
// Should be 2 queries: one for books, one for authors
long selectCount = logCaptor.getInfoLogs().stream()
.filter(log -> log.contains("SELECT"))
.count();
assertThat(selectCount).isLessThanOrEqualTo(2);
}
}
Document Files
Store queries in separate files for reuse:
src/test/resources/graphql-test/
├── getBooks.graphql
├── getBookById.graphql
├── createBook.graphql
└── fragments.graphql
getBookById.graphql:
query GetBookById($id: ID!) {
bookById(id: $id) {
...BookFields
author {
...AuthorFields
}
}
}
fragments.graphql:
fragment BookFields on Book {
id
title
publishedYear
genre
}
fragment AuthorFields on Author {
id
name
}
Use in tests:
@Test
void shouldLoadQueryFromFile() {
graphQlTester.documentName("getBookById") // Looks for getBookById.graphql
.variable("id", "1")
.execute()
.path("bookById.title")
.entity(String.class)
.isEqualTo("The Great Gatsby");
}
Test Utilities
Create reusable test utilities:
@Component
@TestComponent
public class GraphQLTestUtils {
private final GraphQlTester graphQlTester;
public Book createBook(String title, String authorId) {
return graphQlTester.document("""
mutation CreateBook($input: CreateBookInput!) {
createBook(input: $input) {
id
title
}
}
""")
.variable("input", Map.of(
"title", title,
"authorId", authorId
))
.execute()
.path("createBook")
.entity(Book.class)
.get();
}
public void deleteBook(String id) {
graphQlTester.document("""
mutation DeleteBook($id: ID!) {
deleteBook(id: $id) {
success
}
}
""")
.variable("id", id)
.executeAndVerify();
}
}
Testing Security
@SpringBootTest
@AutoConfigureGraphQlTester
class SecurityTest {
@Autowired
private GraphQlTester graphQlTester;
@Test
@WithMockUser(roles = "USER")
void userCanReadBooks() {
graphQlTester.document("query { books { title } }")
.execute()
.path("books")
.entityList(Book.class)
.hasSizeGreaterThan(0);
}
@Test
@WithMockUser(roles = "USER")
void userCannotDeleteBooks() {
graphQlTester.document("""
mutation {
deleteBook(id: "1") {
success
}
}
""")
.execute()
.errors()
.expect(error ->
error.getExtensions().get("code").equals("FORBIDDEN"));
}
@Test
@WithMockUser(roles = "ADMIN")
void adminCanDeleteBooks() {
graphQlTester.document("""
mutation {
deleteBook(id: "1") {
success
}
}
""")
.execute()
.path("deleteBook.success")
.entity(Boolean.class)
.isEqualTo(true);
}
}
Testing Best Practices
1. Use Realistic Test Data
@TestConfiguration
public class TestDataConfig {
@Bean
CommandLineRunner initTestData(BookRepository bookRepo,
AuthorRepository authorRepo) {
return args -> {
Author fitzgerald = authorRepo.save(
new Author(null, "F. Scott Fitzgerald"));
Author orwell = authorRepo.save(
new Author(null, "George Orwell"));
bookRepo.saveAll(List.of(
new Book(null, "The Great Gatsby", fitzgerald.id(), 1925, "Fiction"),
new Book(null, "1984", orwell.id(), 1949, "Dystopian"),
new Book(null, "Animal Farm", orwell.id(), 1945, "Satire")
));
};
}
}
2. Test Edge Cases
@Test
void shouldHandleEmptyResults() {
graphQlTester.document("""
query {
books(filter: { genre: "NonExistent" }) {
title
}
}
""")
.execute()
.path("books")
.entityList(Book.class)
.hasSize(0);
}
@Test
void shouldHandleNullFields() {
// Create book without optional fields
graphQlTester.document("""
mutation {
createBook(input: { title: "Minimal Book", authorId: "1" }) {
publishedYear
genre
}
}
""")
.execute()
.path("createBook.publishedYear")
.valueIsNull()
.path("createBook.genre")
.valueIsNull();
}
3. Clean Up After Tests
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class BookControllerTest {
private final List<String> createdBookIds = new ArrayList<>();
@AfterEach
void cleanUp() {
createdBookIds.forEach(id -> deleteBook(id));
createdBookIds.clear();
}
private String createAndTrackBook() {
String id = createTestBook();
createdBookIds.add(id);
return id;
}
}
Summary
| Test Type | Annotation | Use Case |
|---|---|---|
| Integration | @SpringBootTest | Full application context |
| Slice | @GraphQlTest | Controller only, mocked dependencies |
| HTTP | HttpGraphQlTester | Test HTTP headers, authentication |
| Subscription | StepVerifier | Test real-time events |
Comprehensive testing gives you confidence to refactor and add features. Spring GraphQL's testing utilities make it straightforward to test every aspect of your API.
Next: Security in Spring GraphQL - authentication and authorization patterns.