Skip to main content

Testing Spring GraphQL Applications - A Complete Guide

· 8 min read
GraphQL Guy

Testing GraphQL

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>
<!-- Only needed for the query-counting test near the end of this post -->
<dependency>
<groupId>io.github.hakky54</groupId>
<artifactId>logcaptor</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 {
books {
id
title
publishedYear
}
}
""")
.execute()
.path("books")
.entityList(Book.class)
.hasSizeGreaterThan(0);
}

@Test
void shouldReturnBookById() {
graphQlTester.document("""
query Book($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 BooksWithAuthorBooks {
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 Book($id: ID!) {
bookById(id: $id) {
id
}
}
""")
.variable("id", bookId)
.execute()
.path("bookById")
.valueIsNull();
}

Testing Error Handling

@Test
void shouldReturnErrorForInvalidId() {
graphQlTester.document("""
query BookWithInvalidId {
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 CreateBookWithEmptyTitle {
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 MissingBook {
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:

Note @MockitoBean and not @MockBean. The older annotation was deprecated in Spring Boot 3.4 and removed in Boot 4; @MockitoBean from org.springframework.test.context.bean.override.mockito replaces it and behaves the same way here.

@GraphQlTest(BookController.class)
class BookControllerSliceTest {

@Autowired
private GraphQlTester graphQlTester;

@MockitoBean
private BookRepository bookRepository;

@MockitoBean
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 {
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 BookTitles {
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() {
// ExecutionGraphQlService takes an ExecutionGraphQlRequest,
// not a graphql-java ExecutionInput.
ExecutionGraphQlRequest request = new DefaultExecutionGraphQlRequest(
"""
subscription BookAdded {
bookAdded {
id
title
}
}
""",
null, null, null, UUID.randomUUID().toString(), null);

Flux<ExecutionResult> subscription = graphQlService.execute(request)
.flatMapMany(response -> Flux.from(response.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
// ...

// Count the SQL Hibernate actually issued. org.hibernate.SQL is a
// logger name, not a class, so use forName and not forClass.
LogCaptor logCaptor = LogCaptor.forName("org.hibernate.SQL");
logCaptor.setLogLevelToDebug();

graphQlTester.document("""
query BooksWithAuthors {
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.getDebugLogs().stream()
.filter(log -> log.toLowerCase().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

getBookById.graphql:

query BookById($id: ID!) {
bookById(id: $id) {
...BookFields
author {
...AuthorFields
}
}
}

fragment BookFields on Book {
id
title
publishedYear
genre
}

fragment AuthorFields on Author {
id
name
}
Fragments have to live in the same file

It is tempting to factor shared fragments into a fragments.graphql next door and reference them from every query. That does not work. documentName("getBookById") resolves one classpath resource and sends its contents as the request document; it never concatenates sibling files. A query referencing a fragment defined elsewhere fails validation with an unknown-fragment error before it reaches your resolvers.

If you want to share fragment text across documents, assemble it at build time or keep a small helper that concatenates the files and hands the result to document(...) instead of documentName(...).

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:

@TestComponent
public class GraphQLTestUtils {

private final GraphQlTester graphQlTester;

public GraphQLTestUtils(GraphQlTester graphQlTester) {
this.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 BookTitles { books { title } }")
.execute()
.path("books")
.entityList(Book.class)
.hasSizeGreaterThan(0);
}

@Test
@WithMockUser(roles = "USER")
void userCannotDeleteBooks() {
graphQlTester.document("""
mutation DeleteBook {
deleteBook(id: "1") {
success
}
}
""")
.execute()
.errors()
.expect(error ->
"FORBIDDEN".equals(error.getExtensions().get("code")))
.verify();
}

@Test
@WithMockUser(roles = "ADMIN")
void adminCanDeleteBooks() {
graphQlTester.document("""
mutation DeleteBook {
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 BooksByUnknownGenre {
books(filter: { genre: "NonExistent" }) {
title
}
}
""")
.execute()
.path("books")
.entityList(Book.class)
.hasSize(0);
}

@Test
void shouldHandleNullFields() {
// Create book without optional fields
graphQlTester.document("""
mutation CreateMinimalBook {
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 TypeAnnotationUse Case
Integration@SpringBootTestFull application context
Slice@GraphQlTestController only, mocked dependencies
HTTPHttpGraphQlTesterTest HTTP headers, authentication
SubscriptionStepVerifierTest 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.


This post has 94% coverage. The remaining 6% is the line you are reading, which nobody has asserted on.