Testing GraphQL APIs with Spring GraphQL and WebTestClient in practice

GraphQL has reshaped how many Australian engineering teams think about API contracts. Instead of a handful of REST endpoints that often return more data than the client needs, a single endpoint exposes a typed schema, and clients dictate the shape of the response. Atlassian's developer platform in Sydney ships a public GraphQL API, and several Brisbane-based fintechs have adopted the technology to consolidate mobile and web clients onto a single source of truth. Testing such APIs requires a different mindset from classic REST tests, and the combination of Spring GraphQL and WebTestClient is one of the cleanest ways to do it in the JVM ecosystem.

WebTestClient brings the expressive assertion style of MockMvc into the reactive world, and Spring GraphQL is purpose-built to expose the GraphQL Java engine on top of a Spring application. When the two are combined, you can drive queries, mutations, and even subscriptions from a test, assert on the JSON returned, and stub every layer below the resolver without spinning up a real HTTP server. The result feels familiar to anyone who has written Spring integration tests before, but the surface area is broad enough to cover the corners that GraphQL introduces: introspection, fragments, aliases, persisted queries, and streaming responses.

Why Spring GraphQL and WebTestClient fit together so well

Spring GraphQL exposes its ExecutionGraphQlService and the underlying GraphQlSource as Spring beans, which means a test can wire up the application context in much the same way as production. The framework also provides a WebGraphQlHandler that converts an HTTP request into a ServerRequest and back into a response, including content negotiation, request id propagation, and security context handling. WebTestClient plugs directly into that handler, so the test path is essentially the same path the production traffic follows, just without the network.

For teams in Melbourne and Sydney who are used to writing Spring Boot integration tests with @SpringBootTest, the learning curve is shallow. You keep the familiar @AutoConfigureWebTestClient annotation, swap the MockMvc test for a reactive one, and gain the ability to verify subscriptions through StepVerifier. The reactive test client also lets you assert on streaming responses, which is a sharp edge that pure MockMvc cannot reach. The same builder supports expectBody(), expectHeader(), and a fluent jsonPath() API, so the migration from REST testing is mostly a search-and-replace on the URI.

A common misconception is that GraphQL testing requires a separate toolchain. In practice, the engine that Spring GraphQL ships with already supports schema introspection, query parsing, and execution plans, and WebTestClient simply acts as the transport. This keeps the dependency footprint small, which matters when you run the suite on a developer laptop in Perth as well as on a build agent in an AWS Sydney region.

Setting up the test project

Start with a Spring Boot 3 project that includes the spring-boot-starter-graphql dependency and spring-boot-starter-webflux, since WebTestClient works most naturally against a reactive stack. Add spring-boot-starter-test, which already brings in WebTestClient, AssertJ, and JUnit 5. If your team uses Spock or Kotest, the WebTestClient binder is registered through the framework extension without any further wiring.

The application under test should expose a schema, a few DataFetcher beans, and a configured RouterFunction. For the test slice, replace the real fetcher beans with Mockito mocks annotated with @MockBean, and keep the schema definition in src/main/resources/graphql/schema.graphqls. That schema is the single source of truth for the contract, and tests can rely on the framework to validate every query against it. The schema-first approach also lines up nicely with code review tooling, because reviewers can read a schema.graphqls diff far more easily than they can read a tangle of annotations.

Configuration goes into a test-only properties file that points the GraphQL endpoint at /graphql, disables CSRF, and switches on the request id header. Teams in Canberra working on government services often need to keep audit headers intact, so it pays to set spring.graphql.path and the security filter chain in the same place rather than scattering them across application.yml files.

Writing the first end-to-end test

A WebTestClient test for a GraphQL query is shorter than you might expect. You build a request with post().uri("/graphql"), set the body as a Map containing the query and variables, and then chain .exchange() with an expectStatus().isOk() and a JSON path assertion. The body sent over the wire is the same JSON that a developer would paste into Altair or GraphiQL, which makes debugging easier when a test fails on a colleague's machine.

webTestClient.post().uri("/graphql")
    .contentType(MediaType.APPLICATION_JSON)
    .bodyValue(Map.of(
        "query", "query customer($id: ID!) { customer(id: $id) { name email } }",
        "variables", Map.of("id", "42")))
    .exchange()
    .expectStatus().isOk()
    .expectBody()
    .jsonPath("$.data.customer.name").value(equalTo("Casey"));

Mutations follow the same pattern, with a different query string and a variables map. The interesting part is that the assertion language does not change: you still reach into $.data.<field> and verify the value, or check $.errors when the resolver rejects the input. This consistency is one of the reasons a Melbourne team migrating from REST to GraphQL can keep most of its test scaffolding intact.

For subscription testing, WebTestClient supports streaming exchanges through consumeWith and Reactor's StepVerifier. Subscribe to a field, push an event through the publisher inside the test, and assert that the WebSocket frame is delivered. Keep in mind that subscription tests are slower than query tests, so it is common to gate them behind a JUnit tag such as @Tag("realtime") and run them only in the nightly pipeline.

Stubbing resolvers and external services

A GraphQL resolver is just a function from a parent object and arguments to a value, which makes it trivial to mock. In a Spring GraphQL test, you can use @MockBean to replace a DataFetcher with a Mockito stub, or you can lean on the framework's GraphQlSourceBuilder and supply a custom DataFetcherExceptionResolver that returns deterministic error JSON. Either approach keeps the test focused on the schema contract rather than on the database.

When the resolver depends on an external service, the cleanest pattern for many Australian teams is to use WireMock running in a Testcontainers container. Engineers from the Sydney JVM meetup have shared that moving from in-process Mockito stubs to containerised WireMock reduced flaky tests caused by clock drift between the JVM and the mocked server. WireMock also handles day-light-saving transitions in AEST and AEDT correctly when you stub Date headers, which is a small but appreciated detail for banking integrations that cross borders with APAC partners.

Don't forget to stub the BatchLoader if your schema uses DataLoader to avoid the classic N+1 problem. A unit test that only exercises a single resolver can pass while a production-like request triggers dozens of extra database calls. Spring GraphQL exposes the loader registry through the GraphQlSource, so a focused test can call the loader directly and assert that the right keys were requested in a single batch.

Auth, headers, and error responses

Real Australian deployments rarely expose a GraphQL endpoint without authentication, and the test must reproduce the same security context. WebTestClient supports SecurityMockServerConfigurers that let you authenticate as a JWT user or as a basic auth principal without standing up the full OAuth2 stack. The mockJwt().jwt(j -> j.subject("alice")) helper is enough to cover most of the positive cases, and a separate test with an anonymous principal exercises the unauthorised branch.

Error handling deserves its own test class. The GraphQL spec defines a structured errors array that is always present alongside a possibly empty data field, and Spring GraphQL surfaces both. A useful assertion pattern is to check that $.errors[0].extensions.classification equals "INTERNAL_ERROR" for an unexpected exception, or "BAD_REQUEST" when a query is rejected at parse time. Several teams in the Adelaide health sector have used this pattern to catch contract regressions where a custom exception resolver was changed in a refactor.

Headers such as X-Request-Id and X-Forwarded-For should also be verified. Even if the resolver ignores them, the observability layer often does not, and a missing header in production is a real source of incident reports. Set the headers in the test, run the query, and assert on the captured server exchange through an ExchangeFilterFunction when you need full traceability.

Running the suite locally and in CI

The Australian timezone advantage is that build agents in the AWS Sydney region run on AEST, and several teams in the Asia-Pacific region point their CI pipelines at that region for latency reasons. The build itself does not change: ./gradlew test or mvn verify runs the full WebTestClient suite, and the reactive client starts and tears down an embedded server for each test class. The only thing to watch is the JVM flag that controls parallel execution, because some subscription tests share global state and need to be serialised.

Concern Spring GraphQL + WebTestClient REST Assured + HTTP
Transport Reactive, in-process Real or mock HTTP
Schema validation Built into the engine Manual, often skipped
Subscription testing Native via StepVerifier Not supported
Setup cost Low, Spring-native Medium, separate DSL
Best fit Spring Boot services with GraphQL Mixed-stack or REST-only services

Keep the comparison in mind when you plan the test pyramid. Unit tests around the resolvers catch most of the logic, WebTestClient integration tests validate the schema and the wiring, and a thin smoke test against a deployed environment in ap-southeast-2 confirms the whole pipeline. The trick is to keep the layers honest about what they verify, so a flaky network stub never hides a real resolver bug.

The piece worth carrying forward is that GraphQL testing in Spring is not a new discipline. The same layered strategy that worked for REST services still applies: contract tests, integration tests, and a small number of contract-driven end-to-end tests. The difference is that WebTestClient and Spring GraphQL collapse the transport and the schema into a single fluent API, so the tests you write read almost like the queries your customers will send, and the suite stays fast enough to run on every commit.