Skip to main content

Spring WebTestClient

Introduction

Spring WebTestClient is a non-blocking, reactive test client designed specifically for testing Spring WebFlux applications. It's part of the Spring Test module and provides a fluent API for testing server endpoints without needing to start a real HTTP server. Whether you're developing a reactive REST API or a reactive web application, WebTestClient allows you to verify your application's behavior with concise and readable test code.

In this guide, you'll learn:

  • What WebTestClient is and why it's useful
  • How to set up WebTestClient in your Spring projects
  • Common testing patterns and best practices
  • Advanced techniques for more complex testing scenarios

What is WebTestClient?

WebTestClient is a testing utility that allows you to:

  1. Send HTTP requests to your WebFlux controllers
  2. Verify responses without starting a full server
  3. Test your reactive endpoints in a non-blocking way
  4. Validate both synchronous and asynchronous response handling

It's particularly useful for testing Spring WebFlux applications, which are built on reactive programming principles using Project Reactor.

Setting Up WebTestClient

To use WebTestClient in your Spring Boot project, you'll need the following dependency:

xml
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>

If you're using Gradle:

groovy
testImplementation 'org.springframework.boot:spring-boot-starter-test'

This dependency includes WebTestClient along with other testing utilities.

Basic Testing Patterns

Testing a Controller

Let's start with a simple reactive REST controller:

java
@RestController
@RequestMapping("/api/products")
public class ProductController {

private final ProductRepository repository;

public ProductController(ProductRepository repository) {
this.repository = repository;
}

@GetMapping
public Flux<Product> getAllProducts() {
return repository.findAll();
}

@GetMapping("/{id}")
public Mono<ResponseEntity<Product>> getProductById(@PathVariable String id) {
return repository.findById(id)
.map(product -> ResponseEntity.ok(product))
.defaultIfEmpty(ResponseEntity.notFound().build());
}

@PostMapping
@ResponseStatus(HttpStatus.CREATED)
public Mono<Product> createProduct(@RequestBody Product product) {
return repository.save(product);
}
}

Now, let's write a test for this controller:

java
@WebFluxTest(ProductController.class)
class ProductControllerTest {

@Autowired
private WebTestClient webTestClient;

@MockBean
private ProductRepository repository;

@Test
void shouldReturnAllProducts() {
// Given
Product product1 = new Product("1", "Laptop", 1200.00);
Product product2 = new Product("2", "Phone", 800.00);
Flux<Product> productFlux = Flux.just(product1, product2);

when(repository.findAll()).thenReturn(productFlux);

// When & Then
webTestClient.get()
.uri("/api/products")
.exchange()
.expectStatus().isOk()
.expectBodyList(Product.class)
.hasSize(2)
.contains(product1, product2);
}

@Test
void shouldReturnProductById() {
// Given
String productId = "1";
Product product = new Product(productId, "Laptop", 1200.00);

when(repository.findById(productId)).thenReturn(Mono.just(product));

// When & Then
webTestClient.get()
.uri("/api/products/{id}", productId)
.exchange()
.expectStatus().isOk()
.expectBody(Product.class)
.isEqualTo(product);
}

@Test
void shouldReturnNotFoundForNonExistentProduct() {
// Given
String productId = "999";

when(repository.findById(productId)).thenReturn(Mono.empty());

// When & Then
webTestClient.get()
.uri("/api/products/{id}", productId)
.exchange()
.expectStatus().isNotFound();
}
}

Explaining the Test Setup

  1. @WebFluxTest(ProductController.class) - This annotation sets up a minimal Spring context with components needed for testing WebFlux controllers. It only loads the specified controller.

  2. @Autowired private WebTestClient webTestClient - Spring Boot automatically configures and injects a WebTestClient instance.

  3. @MockBean private ProductRepository repository - This creates a mock implementation of the repository that we can control in our tests.

Verifying Responses

WebTestClient offers several ways to verify responses:

Status Verification

java
webTestClient.get().uri("/api/products")
.exchange()
.expectStatus().isOk(); // Verify HTTP 200

// Other status checkers
.expectStatus().isCreated(); // HTTP 201
.expectStatus().isBadRequest(); // HTTP 400
.expectStatus().isNotFound(); // HTTP 404
.expectStatus().is5xxServerError(); // Any 5xx error
.expectStatus().isEqualTo(HttpStatus.OK); // Custom comparison

Header Verification

java
webTestClient.get().uri("/api/products")
.exchange()
.expectHeader().contentType(MediaType.APPLICATION_JSON)
.expectHeader().exists("Custom-Header")
.expectHeader().valueEquals("Custom-Header", "Expected-Value");

Body Verification

JSON Body Testing

java
webTestClient.get().uri("/api/products/{id}", "1")
.exchange()
.expectStatus().isOk()
.expectBody()
.jsonPath("$.id").isEqualTo("1")
.jsonPath("$.name").isEqualTo("Laptop")
.jsonPath("$.price").isNumber()
.jsonPath("$.price").value(price -> {
assertThat(price).isGreaterThan(1000.0);
});

Object Mapping

java
webTestClient.get().uri("/api/products/{id}", "1")
.exchange()
.expectStatus().isOk()
.expectBody(Product.class)
.value(product -> {
assertThat(product.getId()).isEqualTo("1");
assertThat(product.getName()).isEqualTo("Laptop");
assertThat(product.getPrice()).isGreaterThan(1000.0);
});

List Verification

java
webTestClient.get().uri("/api/products")
.exchange()
.expectStatus().isOk()
.expectBodyList(Product.class)
.hasSize(2)
.contains(product1)
.doesNotContain(deletedProduct);

Testing POST Requests

java
@Test
void shouldCreateProduct() {
// Given
Product newProduct = new Product(null, "Tablet", 500.00);
Product savedProduct = new Product("3", "Tablet", 500.00);

when(repository.save(any(Product.class))).thenReturn(Mono.just(savedProduct));

// When & Then
webTestClient.post()
.uri("/api/products")
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(newProduct)
.exchange()
.expectStatus().isCreated()
.expectBody(Product.class)
.isEqualTo(savedProduct);
}

Real-World Example: Testing a Weather API

Let's build a more complex example with a Weather API that has rate limiting and error handling:

java
@RestController
@RequestMapping("/api/weather")
public class WeatherController {

private final WeatherService weatherService;

public WeatherController(WeatherService weatherService) {
this.weatherService = weatherService;
}

@GetMapping("/{city}")
public Mono<ResponseEntity<WeatherData>> getWeatherForCity(@PathVariable String city) {
return weatherService.getWeatherData(city)
.map(ResponseEntity::ok)
.onErrorResume(WeatherServiceException.class, e -> {
if (e.getErrorCode() == ErrorCode.CITY_NOT_FOUND) {
return Mono.just(ResponseEntity.notFound().build());
} else if (e.getErrorCode() == ErrorCode.RATE_LIMIT_EXCEEDED) {
return Mono.just(ResponseEntity.status(HttpStatus.TOO_MANY_REQUESTS).build());
}
return Mono.just(ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build());
});
}
}

And now a comprehensive test for this controller:

java
@WebFluxTest(WeatherController.class)
class WeatherControllerTest {

@Autowired
private WebTestClient webTestClient;

@MockBean
private WeatherService weatherService;

@Test
void shouldReturnWeatherData() {
// Given
String city = "London";
WeatherData weatherData = new WeatherData(city, 15.5, "Cloudy", 75);

when(weatherService.getWeatherData(city)).thenReturn(Mono.just(weatherData));

// When & Then
webTestClient.get()
.uri("/api/weather/{city}", city)
.exchange()
.expectStatus().isOk()
.expectBody(WeatherData.class)
.consumeWith(response -> {
WeatherData responseBody = response.getResponseBody();
assertThat(responseBody).isNotNull();
assertThat(responseBody.getCity()).isEqualTo(city);
assertThat(responseBody.getTemperature()).isEqualTo(15.5);
assertThat(responseBody.getCondition()).isEqualTo("Cloudy");
});
}

@Test
void shouldReturn404WhenCityNotFound() {
// Given
String city = "NonExistentCity";

when(weatherService.getWeatherData(city))
.thenReturn(Mono.error(new WeatherServiceException(ErrorCode.CITY_NOT_FOUND)));

// When & Then
webTestClient.get()
.uri("/api/weather/{city}", city)
.exchange()
.expectStatus().isNotFound();
}

@Test
void shouldReturn429WhenRateLimitExceeded() {
// Given
String city = "London";

when(weatherService.getWeatherData(city))
.thenReturn(Mono.error(new WeatherServiceException(ErrorCode.RATE_LIMIT_EXCEEDED)));

// When & Then
webTestClient.get()
.uri("/api/weather/{city}", city)
.exchange()
.expectStatus().isEqualTo(HttpStatus.TOO_MANY_REQUESTS);
}
}

Testing with Authentication

For secured endpoints, you can add authentication to your requests:

java
@Test
void shouldAccessSecuredEndpoint() {
webTestClient
.mutateWith(mockUser().roles("ADMIN"))
.get().uri("/api/admin/products")
.exchange()
.expectStatus().isOk();
}

@Test
void shouldDenyAccessToUnauthorizedUser() {
webTestClient
.mutateWith(mockUser().roles("USER")) // User without ADMIN role
.get().uri("/api/admin/products")
.exchange()
.expectStatus().isForbidden();
}

Integration Testing with a Running Server

If you want to test against a running server (integration test):

java
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class ProductApiIntegrationTest {

@Autowired
private WebTestClient webTestClient;

@Test
void shouldReturnProductsFromRunningServer() {
webTestClient.get()
.uri("/api/products")
.exchange()
.expectStatus().isOk()
.expectBodyList(Product.class)
.hasSize(greaterThan(0));
}
}

In this case, Spring Boot starts the application with a real server on a random port, and WebTestClient makes actual HTTP requests to it.

Advanced Techniques

Testing with Request Parameters

java
webTestClient.get()
.uri(uriBuilder -> uriBuilder
.path("/api/products/search")
.queryParam("name", "laptop")
.queryParam("minPrice", "1000")
.build())
.exchange()
.expectStatus().isOk();

Testing with Response Consumers

java
webTestClient.get().uri("/api/products")
.exchange()
.expectStatus().isOk()
.expectBodyList(Product.class)
.consumeWith(response -> {
List<Product> products = response.getResponseBody();
assertThat(products).isNotEmpty();
// Custom assertions on the list
assertThat(products).allMatch(p -> p.getPrice() > 0);
});

Testing Streaming Responses

java
@Test
void shouldStreamProductUpdates() {
Flux<ProductEvent> events = Flux.just(
new ProductEvent("created", new Product("1", "Laptop", 1200.00)),
new ProductEvent("updated", new Product("1", "Laptop Pro", 1500.00))
);

when(productService.getProductEvents()).thenReturn(events);

webTestClient.get()
.uri("/api/products/events")
.accept(MediaType.TEXT_EVENT_STREAM)
.exchange()
.expectStatus().isOk()
.returnResult(ProductEvent.class)
.getResponseBody()
.as(StepVerifier::create)
.expectNextMatches(event -> "created".equals(event.getType()))
.expectNextMatches(event -> "updated".equals(event.getType()))
.verifyComplete();
}

Best Practices

  1. Keep tests focused: Each test should verify a single aspect of your controller's behavior.

  2. Use meaningful test names: Names like shouldReturnNotFoundForNonExistentProduct() clearly explain what the test is checking.

  3. Use the Arrange-Act-Assert pattern:

    • Arrange: Set up the test data and expectations
    • Act: Call the controller method via WebTestClient
    • Assert: Verify the response
  4. Mock dependencies: Use @MockBean to isolate your controller tests from external services.

  5. Test error scenarios: Don't just test the happy path; also test how your application handles errors.

  6. Use parameterized tests for testing similar behaviors with different inputs.

Common Pitfalls

  1. Not consuming the body: If you don't use methods like expectBody() or returnResult(), the request won't be sent. Always include body expectations.

  2. Forgetting to complete reactive sequences: In custom assertions, make sure to properly complete any reactive sequences you create.

  3. Incorrect content types: Make sure to set the appropriate content type when sending request bodies.

  4. Not handling async behavior: Remember that WebTestClient is working with reactive types, which may have different behavior than traditional synchronous tests.

Summary

Spring WebTestClient is a powerful tool for testing reactive web applications. It allows you to:

  • Test your WebFlux controllers without starting a server
  • Verify status codes, headers, and response bodies
  • Work with both JSON responses and object mapping
  • Test error cases and special conditions
  • Perform integration tests against a running server

With the patterns and practices outlined in this guide, you'll be able to write comprehensive, maintainable tests for your Spring WebFlux applications.

Additional Resources

Exercises

  1. Create a simple WebFlux REST controller for a "Book" entity with CRUD operations, then write WebTestClient tests for each endpoint.

  2. Extend the Weather API example to include forecast data, then write tests that verify the forecast format and content.

  3. Write tests for a WebFlux controller that uses authentication and has different endpoints for different user roles.

  4. Create a streaming endpoint that emits a continuous stream of data, then write a test that verifies the first few items in the stream.

  5. Practice handling error conditions by writing tests for validation errors, not-found scenarios, and server errors.



If you spot any mistakes on this website, please let me know at [email protected]. I’d greatly appreciate your feedback! :)