Class 9: Testing
Duration: 45 minutes | Difficulty: Intermediate | Prerequisites: Class 8 completed, basic JUnit knowledge
What You'll Learn
By the end of this class, you will:
- Set up Spring's
HttpGraphQlTesterfor integration tests - Test queries including nested fields, pagination, and search
- Test mutations with JWT authentication headers
- Verify error responses for not-found and authorization failures
- Understand why integration tests are the sweet spot for GraphQL APIs
Why Integration Tests Are the Right Level
In REST APIs, you often write unit tests for each controller method, mocking the service layer. That works because REST controllers are thin. They receive a request, call a service, return a response. The framework does little beyond routing.
GraphQL is different. The framework does a lot between receiving a request and returning a response: it parses the query, validates it against the schema, resolves each field through your mappings, handles batch loading, applies security, serializes the response, and formats errors. Unit-testing a single @QueryMapping method in isolation misses most of this machinery. A test that calls controller.movie("1") directly tells you the method works, but it doesn't tell you whether the schema wires correctly, whether @BatchMapping fires, or whether @PreAuthorize blocks unauthorized access.
Integration tests with HttpGraphQlTester hit the real GraphQL endpoint. They send actual GraphQL documents, go through the full Spring GraphQL pipeline, and return the same JSON your clients see. This is where you get the most value for a GraphQL API.
| Test Approach | What It Tests | Misses |
|---|---|---|
| Unit test (mock service, call controller directly) | Method logic | Schema wiring, security, batch loading, error handling |
Slice test (@GraphQlTest, mock the service) | Schema wiring + resolver logic, fast (no DB or security) | Real persistence, security, batch loading |
Integration test (HttpGraphQlTester) | Full GraphQL pipeline end-to-end | Nothing: this is the real deal |
| E2E test (external HTTP client) | Same as integration, plus deployment | Slower, harder to set up |
For GraphQL APIs, integration tests give you the best return on effort, so most of this class focuses on them - but the fast @GraphQlTest slice earns its place for quick resolver checks, and we return to it near the end of the class.
Step 1: Test Dependencies
Your pom.xml should already have these from Spring Initializr (verify they're present):
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-graphql-test</artifactId>
<scope>test</scope>
</dependency>
spring-boot-starter-test brings JUnit 5, AssertJ, and Spring's test context. spring-boot-starter-graphql-test pulls in spring-graphql-test, which provides HttpGraphQlTester, the testing tool designed specifically for Spring GraphQL. Both starters omit the version because the Spring Boot BOM manages it, so you never hand-pick a version that could drift out of sync with the rest of the stack.
Step 2: Test Setup
Create a test class that boots the full application context and auto-configures the GraphQL tester.
Create src/test/java/com/graphqlguy/moviedb/movie/MovieQueryTest.java:
package com.graphqlguy.moviedb.movie;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.graphql.test.autoconfigure.tester.AutoConfigureHttpGraphQlTester;
import org.springframework.graphql.test.tester.HttpGraphQlTester;
import static org.assertj.core.api.Assertions.assertThat;
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@AutoConfigureHttpGraphQlTester
class MovieQueryTest {
@Autowired
HttpGraphQlTester graphQlTester;
// Tests go here
}
Two annotations do all the heavy lifting:
@SpringBootTest(webEnvironment = RANDOM_PORT) boots the entire application: JPA, security, GraphQL, data initialization, everything. RANDOM_PORT starts a real HTTP server on an available port, so tests don't collide if you run multiple test classes in parallel.
@AutoConfigureHttpGraphQlTester creates an HttpGraphQlTester bean that's pre-configured to send requests to the running GraphQL endpoint. You don't need to know which port the server started on. The tester handles that automatically.
(The import here is the Spring Boot 4.0+ location: org.springframework.boot.graphql.test.autoconfigure.tester.AutoConfigureHttpGraphQlTester. On Boot 3.x the tester auto-configuration annotations lived under org.springframework.boot.test.autoconfigure.graphql.tester instead, so on an older project adjust the import accordingly.)
MOCK is Boot's lighter default, and we deliberately choose RANDOM_PORT here so the tests drive the real JwtAuthFilter and HTTP transport end-to-end rather than a mock servlet stand-in. If you use MOCK instead, Spring creates a mock servlet environment without a real HTTP server. With MOCK you can still use HttpGraphQlTester by adding @AutoConfigureMockMvc so the tester binds to MockMvc. We use RANDOM_PORT here because it exercises the real HTTP layer end-to-end, and tests can't collide with each other if you run multiple suites at once.
Step 3: Testing Queries
Fetch a Movie by ID
The simplest test, fetch a known movie and verify its fields:
@Test
void shouldFetchMovieById() {
graphQlTester.document("""
query {
movie(id: "1") {
id title releaseYear genre rating
directors { id name }
cast { id characterName person { id name } }
}
}
""")
.execute()
.path("movie.title").entity(String.class).satisfies(title ->
assertThat(title).isNotBlank()
)
.path("movie.directors").entityList(Object.class).satisfies(dirs ->
assertThat(dirs).hasSizeGreaterThanOrEqualTo(1)
)
.path("movie.cast").entityList(Object.class).satisfies(cast ->
assertThat(cast).hasSizeGreaterThanOrEqualTo(2)
);
}
Let's trace through the assertion API:
.document(...)sets the GraphQL query to execute. We use a text block for readability..execute()sends the HTTP request and returns a response spec..path("movie.title")navigates to a field in the JSON response using a JSONPath expression..entity(String.class)deserializes the value at that path into the given type..satisfies(...)runs an AssertJ assertion against the deserialized value.
You can chain multiple .path() calls on the same response. Each one navigates to a different field, letting you verify the full response structure in one test.
Verify Error for Invalid Movie
When a client requests a movie that doesn't exist, our error handler should return a structured error. Let's verify that:
@Test
void shouldReturnNotFoundForInvalidMovie() {
graphQlTester.document("""
query {
movie(id: "99999") {
id title
}
}
""")
.execute()
.errors()
.satisfy(errors -> {
assertThat(errors).hasSize(1);
assertThat(errors.getFirst().getMessage()).contains("Movie not found");
assertThat(errors.getFirst().getExtensions())
.containsEntry("entityType", "Movie");
});
}
The .errors().satisfy() pattern is how you verify error responses. The lambda receives the full list of GraphQL errors, and you can inspect their messages, extensions, and classifications. This test verifies both the error message text and the entityType extension we added in Class 5 (Error Handling).
A GraphQL response that carries errors alongside partial data is still a valid response, so it does not fail execute() on its own. The failure happens later, at the moment you traverse the response: calling path() (or entity()) verifies that there are no unhandled errors first, and only then reads the data. So a query that returned an unexpected error passes execute() but fails as soon as you reach for a data path. To handle the errors before traversing, use .errors().verify() when you expect none, .errors().filter(...).verify() to declare which errors are expected and ignore them, or .errors().satisfy(...) to inspect the full error list yourself (which also marks them as handled so you can then traverse to a data path). This is why a test cannot silently pass when the server returns unexpected errors alongside partial data.
Fetch Paginated Movies
The next two snippets exercise the paginated movies(page, size, filter) query that we build in Class 11 (Pagination and Filtering). If you are working through linearly, you can read these tests for the pattern but skip running them until that class is in place. The query tests above (single movie, nested fields, not-found) all work with what you have at the end of Class 8.
Once pagination is added, you can test it like this:
@Test
void shouldFetchPaginatedMovies() {
graphQlTester.document("""
query {
movies(page: 0, size: 5) {
content { id title reviewCount }
totalElements totalPages hasNext
}
}
""")
.execute()
.path("movies.content").entityList(Object.class).hasSize(5)
.path("movies.totalElements").entity(Integer.class).satisfies(total ->
assertThat(total).isGreaterThan(5)
)
.path("movies.hasNext").entity(Boolean.class).isEqualTo(true);
}
This test verifies three things at once: the page has exactly 5 items (our requested size), the total is more than 5 (so pagination is meaningful), and hasNext is true (there's another page). These assertions catch common pagination bugs like returning all items regardless of the size parameter or miscounting totals.
Search Movies by Title
@Test
void shouldSearchMoviesByTitle() {
graphQlTester.document("""
query {
searchMovies(title: "the") {
id title
}
}
""")
.execute()
.path("searchMovies").entityList(Object.class).satisfies(movies ->
assertThat(movies).hasSizeGreaterThan(0)
);
}
Filter Movies by Genre
@Test
void shouldFilterMoviesByGenre() {
graphQlTester.document("""
query {
movies(filter: { genre: HORROR }, size: 50) {
content { id title genre }
totalElements
}
}
""")
.execute()
.path("movies.content[*].genre").entityList(String.class)
.satisfies(genres ->
assertThat(genres).allMatch(g -> g.equals("HORROR"))
);
}
The JSONPath movies.content[*].genre extracts the genre field from every element in the content array. This is a powerful way to verify that filtering actually works: every returned genre must be HORROR.
Step 4: Testing Mutations with Authentication
Mutations require authentication. In our tests, we can't log in through GraphiQL and paste a token. We need to generate tokens programmatically. This is where JwtUtil and the mutate() pattern come in.
Create src/test/java/com/graphqlguy/moviedb/movie/MovieMutationTest.java:
package com.graphqlguy.moviedb.movie;
import com.graphqlguy.moviedb.security.JwtUtil;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.graphql.test.autoconfigure.tester.AutoConfigureHttpGraphQlTester;
import org.springframework.graphql.test.tester.HttpGraphQlTester;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import static org.assertj.core.api.Assertions.assertThat;
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@AutoConfigureHttpGraphQlTester
class MovieMutationTest {
@Autowired
HttpGraphQlTester graphQlTester;
@Autowired
JwtUtil jwtUtil;
@Autowired
UserDetailsService userDetailsService;
HttpGraphQlTester adminTester;
@BeforeEach
void setUp() {
UserDetails admin = userDetailsService.loadUserByUsername("admin");
String role = admin.getAuthorities().iterator().next()
.getAuthority().replace("ROLE_", "");
String token = jwtUtil.generateToken(admin.getUsername(), role);
adminTester = graphQlTester.mutate()
.header("Authorization", "Bearer " + token)
.build();
}
}
The key pattern here is graphQlTester.mutate(). This creates a new tester instance that inherits everything from the original but adds the Authorization header. The original graphQlTester remains unauthenticated, useful for testing unauthorized access. The adminTester sends every request with a valid admin JWT.
We use JwtUtil.generateToken(username, role) (the same signature we defined in Class 6) and feed it data we pulled from UserDetailsService. Loading via UserDetailsService keeps the test honest: we resolve the admin user from the same source the application uses, then ask JwtUtil to mint a token exactly the way the AuthController does. The resulting JWT goes through the same JwtAuthFilter as a real client request.
Test Creating a Person as Admin
@Test
void shouldCreatePersonAsAdmin() {
adminTester.document("""
mutation {
createPerson(input: {
name: "Test Person"
birthYear: 1970
nationality: "Test"
}) {
id
name
}
}
""")
.execute()
.path("createPerson.name").entity(String.class)
.isEqualTo("Test Person");
}
This test proves the full chain works: the JWT is valid, the user has the ADMIN role, @PreAuthorize allows the mutation, the person is saved to the database, and the response contains the correct data.
Test Unauthorized Mutation
@Test
void shouldRejectMutationWithoutAuth() {
graphQlTester.document("""
mutation {
createPerson(input: {
name: "Unauthorized"
birthYear: 1990
}) {
id
}
}
""")
.execute()
.errors()
.satisfy(errors -> assertThat(errors).isNotEmpty());
}
Notice we use the original graphQlTester here, the one without any authentication header. The test verifies that the mutation is rejected with an error. We don't assert the exact error message because Spring Security's error format can vary, but we verify that at least one error is returned.
This is an important test to have. Without it, a misconfigured SecurityConfig (like accidentally removing @EnableMethodSecurity) could silently make all mutations public. The test catches that regression.
Step 5: Testing Reviews with User Auth
Reviews require a logged-in user, not necessarily an admin. Let's test the review flow with a regular user:
Create src/test/java/com/graphqlguy/moviedb/review/ReviewTest.java:
package com.graphqlguy.moviedb.review;
import com.graphqlguy.moviedb.security.JwtUtil;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.graphql.test.autoconfigure.tester.AutoConfigureHttpGraphQlTester;
import org.springframework.graphql.test.tester.HttpGraphQlTester;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import static org.assertj.core.api.Assertions.assertThat;
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@AutoConfigureHttpGraphQlTester
class ReviewTest {
@Autowired
HttpGraphQlTester graphQlTester;
@Autowired
JwtUtil jwtUtil;
@Autowired
UserDetailsService userDetailsService;
HttpGraphQlTester userTester;
@BeforeEach
void setUp() {
UserDetails user = userDetailsService.loadUserByUsername("user");
String role = user.getAuthorities().iterator().next()
.getAuthority().replace("ROLE_", "");
String token = jwtUtil.generateToken(user.getUsername(), role);
userTester = graphQlTester.mutate()
.header("Authorization", "Bearer " + token)
.build();
}
@Test
void shouldAddAndDeleteMovieReview() {
// Add review
String reviewId = userTester.document("""
mutation {
createMovieReview(input: {
movieId: "1", score: 8,
comment: "Great film!"
}) {
id score comment createdAt
user { username }
}
}
""")
.execute()
.path("createMovieReview.score").entity(Integer.class).isEqualTo(8)
.path("createMovieReview.comment").entity(String.class)
.isEqualTo("Great film!")
.path("createMovieReview.createdAt").entity(String.class)
.satisfies(dt -> assertThat(dt).isNotBlank())
.path("createMovieReview.id").entity(String.class).get();
// Delete review
userTester.document("""
mutation DeleteReview($id: ID!) {
deleteReview(id: $id) {
success message
}
}
""")
.variable("id", reviewId)
.execute()
.path("deleteReview.success").entity(Boolean.class)
.isEqualTo(true);
}
@Test
void shouldRejectReviewWithoutAuth() {
graphQlTester.document("""
mutation {
createMovieReview(input: {
movieId: "1", score: 5
}) {
id
}
}
""")
.execute()
.errors()
.satisfy(errors -> assertThat(errors).isNotEmpty());
}
}
The review test demonstrates a multi-step flow: create a review, capture its ID, then delete it. The .get() method at the end of the chain extracts the value (the review ID) so we can use it in the next request. This is a common pattern for testing create-then-verify or create-then-delete flows.
Step 6: Testing Validation Errors
The @Range and @Size validation directives are introduced in Class 13 (External APIs, Directives, and Production). The test below is included here so all of the testing patterns live in one place; you will be able to run it after completing Class 13.
Once the validation directives are added, you can verify them like this:
@Test
void shouldRejectInvalidScoreViaDirective() {
userTester.document("""
mutation {
createMovieReview(input: {
movieId: "1", score: 15,
comment: "Invalid score"
}) {
id
}
}
""")
.execute()
.errors()
.satisfy(errors -> {
assertThat(errors).isNotEmpty();
assertThat(errors.getFirst().getMessage())
.containsIgnoringCase("score");
});
}
This test sends a score of 15, which violates the @Range(min: 1, max: 10) directive on the schema. The error should mention "score" so the client knows which field failed validation.
Faster Feedback with @GraphQlTest
The integration tests above boot the whole application - JPA, security, data initialization - which is thorough but slow, and a lot of ceremony when all you want to check is that a resolver wires to the schema and returns the right shape. Spring Boot's @GraphQlTest slice sits between a plain unit test and a full integration test: it loads only the GraphQL layer (the schema, your @Controller beans, @ControllerAdvice handlers, and converters) and nothing else, so you mock the service and get a test that runs in a fraction of the time.
Create src/test/java/com/graphqlguy/moviedb/movie/MovieControllerSliceTest.java:
package com.graphqlguy.moviedb.movie;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.graphql.test.autoconfigure.GraphQlTest;
import org.springframework.graphql.test.tester.GraphQlTester;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import java.util.Optional;
import static org.mockito.BDDMockito.given;
@GraphQlTest(MovieController.class)
class MovieControllerSliceTest {
@Autowired
GraphQlTester graphQlTester;
@MockitoBean
MovieService movieService;
@Test
void fetchesMovieById() {
given(movieService.findById(1L))
.willReturn(Optional.of(Movie.builder().id(1L).title("The Matrix").build()));
graphQlTester.document("{ movie(id: 1) { title } }")
.execute()
.path("movie.title").entity(String.class).isEqualTo("The Matrix");
}
}
Three things make this fast and focused:
@GraphQlTest(MovieController.class) loads the GraphQL slice and only the named controller (omit the argument to load every @Controller). There is no @SpringBootTest, so JPA, the security filter chain, and data initialization never start. The injected tester is a plain GraphQlTester, not HttpGraphQlTester, because it drives the GraphQL engine directly rather than going over a real HTTP port. (The import shown, org.springframework.boot.graphql.test.autoconfigure.GraphQlTest, is the Spring Boot 4.0+ location; on Boot 3.x this slice annotation lived under org.springframework.boot.test.autoconfigure.graphql.GraphQlTest instead.)
@MockitoBean MovieService replaces the real service with a Mockito mock, so the test asserts exactly what the resolver does with a given service result, not what the database contains. On the Boot 4.0 stack @MockitoBean (from org.springframework.test.context.bean.override.mockito) is the only option: the older @MockBean from org.springframework.boot.test.mock.mockito was deprecated around Boot 3.4 and removed outright in Boot 4.0. On a Boot 3.x project you would still see @MockBean in older code, but on this stack it will not compile.
The schema still loads and wires, which is what separates a slice test from a plain unit test that calls controller.movie(1L) directly. The movie(id: 1) document is parsed and validated against the real schema, the argument is bound to the resolver, and the EntityNotFoundException handler from Class 5 (a @ControllerAdvice) is part of the slice too - so stubbing an empty Optional would produce a real NOT_FOUND error you can assert on. What it does not exercise is persistence, security, and batch loading.
This is the base of the Test Pyramid: use @GraphQlTest slices for many fast, focused checks of resolver-and-schema behavior, and the HttpGraphQlTester integration tests for the fewer, high-value end-to-end checks that prove persistence, security, and batching actually work together. They are complements, not competitors.
The Assertion API at a Glance
Here's a reference for the most useful HttpGraphQlTester assertion methods:
| Method | Purpose | Example |
|---|---|---|
.path("x.y") | Navigate to a field | .path("movie.title") |
.entity(Type.class) | Deserialize value | .entity(String.class) |
.entityList(Type.class) | Deserialize array | .entityList(Object.class) |
.isEqualTo(value) | Exact match | .isEqualTo("The Matrix") |
.satisfies(lambda) | Custom assertion | .satisfies(t -> assertThat(t).isNotBlank()) |
.hasSize(n) | List size | .hasSize(5) |
.hasSizeGreaterThan(n) | Minimum list size | .hasSizeGreaterThan(0) |
.get() | Extract value for later use | String id = ...get(); |
.errors().satisfy() | Assert on errors | .errors().satisfy(e -> ...) |
.variable("name", val) | Set query variable | .variable("id", "1") |
Running the Tests
Run all tests from the command line:
./mvnw test
Or run a specific test class:
./mvnw test -Dtest=MovieQueryTest
Because these are integration tests that share a database, test order can matter. The DataInitializer seeds the database on startup, so query tests have data to work with. Mutation tests that create or delete data should clean up after themselves (as our review test does) to avoid affecting other tests. If you run into flaky tests, check whether one test is modifying data that another test depends on.
Exercises
Exercise 1: Test Cursor-Based Pagination
Write a test that fetches the first page of moviesConnection(first: 3), extracts the endCursor, then fetches the next page using after. Verify that hasPreviousPage is true on the second page and that the movies are different from the first page.
Exercise 2: Test a Non-Admin User Creating a Movie
Create a userTester (with the "user" role instead of "admin") and try to call createMovie. Verify that it returns an authorization error. This tests that @PreAuthorize("hasRole('ADMIN')") correctly blocks non-admin users, not just unauthenticated ones.
Exercise 3: Test the me Query
Write two tests for the me query: one with a valid JWT that verifies the username and role are returned, and one without authentication that verifies null is returned. This tests the behavior we built in Class 6.
Common Issues
Issue: HttpGraphQlTester is null
Error: @Autowired HttpGraphQlTester graphQlTester is null or injection fails.
Solution: Make sure you have both @SpringBootTest(webEnvironment = RANDOM_PORT) and @AutoConfigureHttpGraphQlTester. Both annotations are required: the first starts the server, the second creates the tester.
Issue: Tests pass locally but fail in CI
Error: Tests fail with connection errors or data mismatch in CI.
Solution: Check that your CI environment doesn't have port conflicts. RANDOM_PORT should avoid this, but verify. Also ensure the DataInitializer runs consistently. If it depends on external data or random values, tests may see different data in different environments.
Issue: .path() throws "no value at path"
Error: AssertionError: No value at JSON path "movie.title"
Solution: The query likely returned an error instead of data. Add .errors().verify() before your .path() assertions to see the actual error. A common cause is a missing field in your query that the schema requires.
Issue: Token generation fails in tests
Error: JwtUtil throws an exception when generating tokens.
Solution: Make sure application.yaml (or application-test.yaml) has the jwt.secret and jwt.expiration properties. The test context loads the same configuration as the main application.
Summary
In this class, you learned:
- Integration tests with
HttpGraphQlTestertest the full GraphQL pipeline (from query parsing through schema resolution to JSON response), giving you the most realistic test coverage @SpringBootTest(webEnvironment = RANDOM_PORT)boots the real application, and@AutoConfigureHttpGraphQlTesterprovides a pre-configured test client.path().entity().satisfies()lets you navigate the response JSON and make assertions on individual fields.errors().satisfy()lets you verify error responses, including error messages and extensionsgraphQlTester.mutate().header(...).build()creates an authenticated tester by adding a JWT token, keeping the original tester unauthenticated for testing rejectionJwtUtil.generateToken()creates real JWT tokens in tests, so the test goes through the exact same authentication pipeline as production requests
What's Next?
In Class 10: Subscriptions, we'll add real-time capabilities:
- GraphQL subscriptions over WebSocket
- Reactor
Fluxas the streaming abstraction - Publishing events when reviews are posted
- Filtering subscription events by movie ID
Subscriptions need a different testing shape than the request/response queries and mutations above: instead of execute(), you call executeSubscription().toFlux(...) on a GraphQlTester (or WebSocketGraphQlTester for the real WebSocket transport) and assert on the resulting stream with Reactor's StepVerifier. Class 10 covers that pattern in full.