Spring Boot Test series 4 - explore using WebTestClient for API testing
preface
This article is the fourth in the Spring Boot Test series to explore the use of WebTestClient for API testing.
Front article:
API design
resources
resources:
- User - user
Data example:
{ "id": 10, "name": "test", "tags": [ "testing", "webtestclient" ] }
URI
URI:
- /api/users
- /api/users/{id}
explain:
- Noun: the URI is designed based on resources. Resources are nouns. The plural form of nouns is used in the URI.
- Verb: HTTP Method is used as the verb by default. If a verb appears in the URI, it needs to have business meaning. Do not use verbs such as CRUD.
- Prefix: it is recommended to use a uniform URI prefix, such as / api /.
API
URI | HTTP Method | HTTP Status | Use Case |
---|---|---|---|
/api/users | GET | 200 | Query all users |
/api/users/ | POST | 201, 400 | Create a new user |
/api/users/{id} | GET | 200, 404 | Query user with specified ID |
/api/users/{id} | DELETE | 200 | Delete user with specified ID |
explain:
- Only media types of application/json are supported.
- See the reference document for HTTP Status Code.
Unit test driven development
Create unit test class
Create a unit test class for UserControllerTest:
@WebFluxTest(UserController.class) public class UserControllerTest { @Autowired private WebTestClient webTestClient; }
Create Controller class
Press Alt + Enter to create the UserController class.
@RestController public class UserController { }
Case 1 - query all users
Write test
Add a test method to the UserControllerTest class:
@Test @DisplayName("should return all users") void shouldReturnAllUsers() { this.webTestClient .get() .uri("/api/users") .header(ACCEPT, APPLICATION_JSON_VALUE) .exchange() .expectStatus() .isOk(); }
Tips: you can quickly add a test method template through Intellij's live template.
Run test - failed
The test failed because the URI has not been defined in the UserController class.
Status expected:<200 OK> but was:<404 NOT_FOUND>
Define API
@RestController @RequestMapping("/api/users") public class UserController { @GetMapping public List<User> getAllUsers() { return null; } }
explain:
- @RequestMapping("/api/users") defines a uniform prefix for URI s.
- @GetMapping - GET method.
Create User class:
@Data @NoArgsConstructor @AllArgsConstructor public class User { private Long id; private String name; private Set<String> tags; }
explain:
- Lombok annotations are used to simplify the code.
Run test - successful
The test was successful.
Tips: use Intellij's Split window function to display test classes and implementation classes on the left and right sides respectively, which is convenient for testing and reading.
Case 2 - create a new user
Write test
@Test @DisplayName("should create new user") void shouldCreateNewUser() { User newUser = new User(1L, "William", Set.of("java", "openshift")); this.webTestClient .post() .uri("/api/users") .header(ACCEPT, APPLICATION_JSON_VALUE) .header(CONTENT_TYPE, APPLICATION_JSON_VALUE) .body(Mono.just(newUser), User.class) .exchange() .expectStatus() .isEqualTo(HttpStatus.CREATED); }
explain:
- After creating a new user successfully, the HTTP Status returned is httpstatus CREATED.
Run test - failed
The test failed because the POST method has not been implemented.
Status expected:<201> but was:<405>
Define POST method
@PostMapping public ResponseEntity<Void> createNewUser(@RequestBody User user) { return ResponseEntity.created(null).build(); }
explain:
-
@PostMapping POST method
-
@Requestbody User - accepts the JSON string of a User object.
-
Return a ResponseEntity object with HTTP Status httpstatus CREATED.
Run test - successful
The test was successful.
Case3 - query the user with the specified ID
Write test
@Test @DisplayName("should get user by id") void shouldGetUserById() { Long userId = 1L; this.webTestClient .get() .uri("/api/users/{id}", userId) .header(ACCEPT, APPLICATION_JSON_VALUE) .exchange() .expectStatus() .isOk(); }
Run test - failed
The test failed because the API has not been implemented.
Status expected:<200 OK> but was:<404 NOT_FOUND>
Define API
@GetMapping("/{id}") public User getUserById(@PathVariable("id") Long id) { return null; }
Run test - successful
The test was successful.
Case3 - delete the user with the specified ID
Write test
@Test @DisplayName("should delete user by id") void shouldDeleteUserById() { Long userId = 1L; this.webTestClient .delete() .uri("/api/users/{id}", userId) .exchange() .expectStatus() .isOk(); }
Run test - failed
The test failed because the DELETE method has not been implemented.
Status expected:<200 OK> but was:<405 METHOD_NOT_ALLOWED>
Define DELETE method
@DeleteMapping("/{id}") public void deleteByUserId(@PathVariable("id") Long id) { }
Run test - successful
The test was successful.
Unit test summary
Summary:
- @ WebFluxTest(UserController.class) is used to unit test the UserController class. There is no need to load the complete Application Context and start the server. The running time of the unit test is short.
- WebTestClient is used to test the Happy Path of API.
- Business logic is not involved in this unit test. The test of business logic will be introduced in the later integration test chapter.
At this time, the UserController class does not contain business logic:
@RestController @RequestMapping("/api/users") public class UserController { @GetMapping public List<User> getAllUsers() { return null; } @PostMapping public ResponseEntity<Void> createNewUser(@RequestBody User user) { return ResponseEntity.created(null).build(); } @GetMapping("/{id}") public User getUserById(@PathVariable("id") Long id) { return null; } @DeleteMapping("/{id}") public void deleteByUserId(@PathVariable("id") Long id) { } }
The business logic will be improved later in the integration test section.
Integrated test driven development
Create integration test class
Create an integration test class for UserControllerIT:
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) @AutoConfigureWebTestClient public class UserControllerIT { @Autowired private WebTestClient webTestClient; }
explain:
- Unit testing and integration testing can be distinguished by XXXTest and XXXIT.
- Start the server through @ SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) to listen on random ports.
- Automatically configure WebTestClient through @ AutoConfigureWebTestClient.
Case 1 - create a new user
Because you need to be able to create new users before you can do other tests.
Write test
@Test @DisplayName("should create new user") void shouldCreateNewUser() { User newUser = new User(1L, "William", Set.of("java", "openshift")); // create a new user this.webTestClient .post() .uri("/api/users") .header(ACCEPT, APPLICATION_JSON_VALUE) .header(CONTENT_TYPE, APPLICATION_JSON_VALUE) .body(Mono.just(newUser), User.class) .exchange() .expectStatus() .isEqualTo(HttpStatus.CREATED); // check new created user this.webTestClient .get() .uri("/api/users/{id}", newUser.getId()) .header(ACCEPT, APPLICATION_JSON_VALUE) .exchange() .expectStatus() .isOk() .expectBody(User.class) .isEqualTo(newUser); }
explain:
- First create a new user, and then find the newly created user according to the user ID.
Run test - failed
The test failed because the data access logic has not been implemented.
Response body expected:<User(id=1, name=William, tags=[openshift, java])> but was:<null>
Implement data access logic
The UserService class is called in the UserController class to realize the access logic of the data.
Inject the UserService bean as a constructor in the UserController class.
private final UserService userService; public UserController(UserService userService) { this.userService = userService; }
explain:
- It is recommended to inject bean s by constructing methods. The reasons will be described later.
Create UserService class:
@Service public class UserService { }
Run test - failed
The test fails with the same error, but there is no bean injection error, indicating that UserService bean injection is successful.
Implement the logic of creating users
In usercontroller The createNewUser () method calls UserService to create new users.
@PostMapping public ResponseEntity<Void> createNewUser(@RequestBody User user) { userService.addNewUser(user); return ResponseEntity.created(null).build(); }
Create addNewUser method in UserService class:
private List<User> userList; @PostConstruct public void init() { userList = new ArrayList<>(); } public Optional<User> addNewUser(User user) { this.userList.add(user); return Optional.of(user); }
explain:
- Save the new User in the userList and return the User's option.
Implement the logic of querying users according to ID
In usercontroller The getUserById () method calls UserService to query the user based on ID.
@GetMapping("/{id}") public User getUserById(@PathVariable("id") Long id) { return userService .getUserById(id) .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, String.format("User with id [%s] not found", id))); }
explain:
- When the user cannot be found, the HTTP Status returned is httpstatus NOT_ FOUND.
Create getUserById method in UserService class:
public Optional<User> getUserById(Long id) { return this.userList .stream() .filter(user -> user.getId().equals(id)) .findFirst(); }
explain:
- Turn the userList into a Stream, then look it up according to the ID and return the first found user.
Run test - successful
The test was successful.
Write test - return not when the user cannot be found according to the ID_ FOUND
@Test @DisplayName("should return not found for get unknown user") void shouldReturnNotFoundForGetUnknownUser() { Long unknownId = 9999L; this.webTestClient .get() .uri("/api/users/{id}", unknownId) .header(ACCEPT, APPLICATION_JSON_VALUE) .exchange() .expectStatus() .isEqualTo(HttpStatus.NOT_FOUND); }
Because the previous implementation returns not when the user cannot be found according to the ID_ Based on the logic of found, the tests added here only play a verification role.
Ensure that the test passes.
Write test - after creating a new user successfully, return the URI of the user in the Location of response headers
Modify the shouldcreatewuser method of UserControllerIT:
@Test @DisplayName("should create new user") void shouldCreateNewUser() { User newUser = new User(1L, "William", Set.of("java", "openshift")); // create a new user FluxExchangeResult<Void> result = this.webTestClient .post() .uri("/api/users") .header(ACCEPT, APPLICATION_JSON_VALUE) .header(CONTENT_TYPE, APPLICATION_JSON_VALUE) .body(Mono.just(newUser), User.class) .exchange() .expectStatus() .isEqualTo(HttpStatus.CREATED) .returnResult(Void.class); String newUserURI = result.getResponseHeaders().get(LOCATION).get(0); // check new created user this.webTestClient .get() .uri(newUserURI) .header(ACCEPT, APPLICATION_JSON_VALUE) .exchange() .expectStatus() .isOk() .expectBody(User.class) .isEqualTo(newUser); }
explain:
- Save the response for creating a new user into the fluxexchangeresult < void > object.
- Save the value of the Location of the response headers to the newUserURI.
- Query the specified user according to the newUserURI.
Run test - failed
The test failed because the URI of the new user has not been put in Location.
java.lang.NullPointerException
Implement the logic of putting the URI of the new user into Location:
@PostMapping public ResponseEntity<Void> createNewUser(@RequestBody User user, UriComponentsBuilder builder) { userService.addNewUser(user); URI location = builder.path("/api/users/{id}").buildAndExpand(user.getId()).toUri(); return ResponseEntity.created(location).build(); }
Run test - successful
The test was successful.
Write test - when creating a new user, bad is returned if the user already exists_ HTTP Status of request
In the test class, add a new test method:
@Test @DisplayName("should return bad request for create existing user") void shouldReturnBadRequestForCreateExistingUser() { Long existingId = 11L; User user1 = new User(existingId, "John", Set.of("python", "web")); User user2 = new User(existingId, "Tom", Set.of("mysql", "redis")); // create a new user this.webTestClient .post() .uri("/api/users") .header(ACCEPT, APPLICATION_JSON_VALUE) .header(CONTENT_TYPE, APPLICATION_JSON_VALUE) .body(Mono.just(user1), User.class) .exchange() .expectStatus() .isEqualTo(HttpStatus.CREATED); // create another user with existing id this.webTestClient .post() .uri("/api/users") .header(ACCEPT, APPLICATION_JSON_VALUE) .header(CONTENT_TYPE, APPLICATION_JSON_VALUE) .body(Mono.just(user2), User.class) .exchange() .expectStatus() .isEqualTo(HttpStatus.BAD_REQUEST); }
Run test - failed
The test failed because it has not been implemented to return bad when ID is repeated_ Logic of request.
Status expected:<400> but was:<201>
The implementation returns bad when the ID is repeated_ Logic of request
Modify the createNewUser method of the UserController class:
@PostMapping public ResponseEntity<Void> createNewUser(@RequestBody User user, UriComponentsBuilder builder) { User addedUser = userService .addNewUser(user) .orElseThrow(() -> new ResponseStatusException(HttpStatus.BAD_REQUEST, String.format("User with id [%s] already exist", user.getId()))); URI location = builder.path("/api/users/{id}").buildAndExpand(addedUser.getId()).toUri(); return ResponseEntity.created(location).build(); }
explain:
- Calling userservice When the addnewuser() method returns a null value, it returns httpstatus BAD_ REQUEST.
Modify userservice Addnewuser() method:
public Optional<User> addNewUser(User user) { if (getUserById(user.getId()).isPresent()) { return Optional.empty(); } this.userList.add(user); return Optional.of(user); }
explain:
- Add judgment: if the user with the specified ID already exists, a null value will be returned.
Run test - successful
The test was successful.
Reconfiguration test method
Refactor the shouldReturnBadRequestForCreateExistingUser test method to streamline duplicate code.
@Test @DisplayName("should return bad request for create existing user") void shouldReturnBadRequestForCreateExistingUser() { Long existingId = 11L; User user1 = new User(existingId, "John", Set.of("python", "web")); User user2 = new User(existingId, "Tom", Set.of("mysql", "redis")); // create a new user createNewUserWithStatus(user1, HttpStatus.CREATED); // create another user with existing id createNewUserWithStatus(user2, HttpStatus.BAD_REQUEST); } private void createNewUserWithStatus(User user, HttpStatus httpStatus) { this.webTestClient .post() .uri("/api/users") .header(ACCEPT, APPLICATION_JSON_VALUE) .header(CONTENT_TYPE, APPLICATION_JSON_VALUE) .body(Mono.just(user), User.class) .exchange() .expectStatus() .isEqualTo(httpStatus); }
explain:
- Extract the code into the new createNewUserWithStatus method.
Run test - successful
Ensure that the post refactoring test is successful.
Case 2 - delete user
Write test
Extend the shouldcreatnewuser method above, first create a user, then find the user to confirm that it has been added, then delete the user, and then find the user to confirm that it has been deleted.
@Test @DisplayName("should create new user") void shouldCreateNewUser() { User newUser = new User(1L, "William", Set.of("java", "openshift")); // create a new user FluxExchangeResult<Void> result = this.webTestClient .post() .uri("/api/users") .header(ACCEPT, APPLICATION_JSON_VALUE) .header(CONTENT_TYPE, APPLICATION_JSON_VALUE) .body(Mono.just(newUser), User.class) .exchange() .expectStatus() .isEqualTo(HttpStatus.CREATED) .returnResult(Void.class); String newUserURI = result.getResponseHeaders().get(LOCATION).get(0); // check new created user this.webTestClient .get() .uri(newUserURI) .header(ACCEPT, APPLICATION_JSON_VALUE) .exchange() .expectStatus() .isOk() .expectBody(User.class) .isEqualTo(newUser); // delete new created user this.webTestClient .delete() .uri(newUserURI) .exchange() .expectStatus() .isOk(); // check if delete this.webTestClient .get() .uri(newUserURI) .header(ACCEPT, APPLICATION_JSON_VALUE) .exchange() .expectStatus() .isEqualTo(HttpStatus.NOT_FOUND); }
Run test - failed
The test failed because the deletion logic has not been implemented.
Status expected:<404> but was:<200>
Implement delete logic
In the UserController class, call UserService to delete the user:
@DeleteMapping("/{id}") public void deleteByUserId(@PathVariable("id") Long id) { userService.deleteById(id); }
Implement deletion logic in UserService:
public void deleteById(Long id) { this.userList.removeIf(user -> user.getId().equals(id)); }
Run test - successful
The test was successful.
Clear the data at the end of each test execution
Clear the data after each test execution to avoid mutual interference of test methods.
@AfterEach void cleanUp() { List<User> userList = this.webTestClient .get() .uri("/api/users") .header(ACCEPT, APPLICATION_JSON_VALUE) .exchange() .expectStatus() .isEqualTo(HttpStatus.OK) .expectBodyList(User.class) .returnResult() .getResponseBody(); userList.forEach(user -> this.webTestClient .delete() .uri("/api/users/{id}", user.getId()) .exchange() .expectStatus() .isOk()); }
Case 3 - find all users
Write test
@Test @DisplayName("should get all users") void shouldGetAllUsers() { this.webTestClient .get() .uri("/api/users") .header(ACCEPT, APPLICATION_JSON_VALUE) .exchange() .expectStatus() .isEqualTo(HttpStatus.OK) .expectBodyList(User.class) .hasSize(0); User user1 = new User(1L, "user1", Set.of("python", "web")); User user2 = new User(2L, "user2", Set.of("mysql", "redis")); User user3 = new User(3L, "user3", Set.of("java", "jenkins")); createNewUserWithStatus(user1, HttpStatus.CREATED); createNewUserWithStatus(user2, HttpStatus.CREATED); createNewUserWithStatus(user3, HttpStatus.CREATED); this.webTestClient .get() .uri("/api/users") .header(ACCEPT, APPLICATION_JSON_VALUE) .exchange() .expectStatus() .isEqualTo(HttpStatus.OK) .expectBodyList(User.class) .hasSize(3) .isEqualTo(List.of(user1, user2, user3)); }
Run test - failed
The test failed because the logic has not been implemented.
Response body does not contain 3 elements expected:<3> but was:<0>
Implementation logic
UserController class:
@GetMapping public List<User> getAllUsers() { return userService.getAllUsers(); }
UserService class:
public List<User> getAllUsers() { return this.userList; }
Run test - successful
The test was successful.
Case 4 - check input parameters
Write test
@Test @DisplayName("should return bad request for invalid parameters") void shouldReturnBadRequestForInvalidParameters() { User emptyUser = new User(null, "", Set.of("python", "web")); createNewUserWithStatus(emptyUser, HttpStatus.BAD_REQUEST); }
Run test - failed
The test failed because the verification logic has not been implemented.
Status expected:<400> but was:<201>
Implement verification logic
Introduce spring boot starter validation dependency:
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-validation</artifactId> </dependency>
explain:
- Spring Boot 2.4 needs to introduce spring boot starter validation dependency separately.
To usercontroller The @ Valid annotation is added to the user parameter of the createnewuser method to verify the parameter.
@PostMapping public ResponseEntity<Void> createNewUser(@RequestBody @Valid User user, UriComponentsBuilder builder) { ... }
Annotate the fields to be verified on the User.
@Data @NoArgsConstructor @AllArgsConstructor public class User { @NotNull(message = "Id is mandatory") private Long id; @NotBlank(message = "Name is mandatory") private String name; private Set<String> tags; }
explain:
- @NotNull declares that the object cannot be null.
- @NotBlank declares that the string cannot be empty.
Run test - successful
The test was successful.
Integration test summary
Summary:
- Both UserController and UserService are tested.
- Happy Path and Sad Path were tested.
- Tested assertions that return a single object or collection of objects.
- Different HTTP status codes are used to represent different states of resources.
- Clear the data after each test to avoid interference between test methods.
- The verification logic is tested.
mock UserService in unit test
After introducing UserService into UserController, the original UserControllerTest unit test class will report an error.
You need to mock UserService in the UserControllerTest unit test class.
@WebFluxTest(UserController.class) class UserControllerTest { @Autowired private WebTestClient webTestClient; @MockBean private UserService userService; @Test @DisplayName("should return all users") void shouldReturnAllUsers() { when(userService.getAllUsers()).thenReturn(null); // test } @Test @DisplayName("should create new user") void shouldCreateNewUser() { User newUser = new User(1L, "William", Set.of("java", "openshift")); when(userService.addNewUser(newUser)).thenReturn(Optional.of(newUser)); // test } @Test @DisplayName("should get user by id") void shouldGetUserById() { Long userId = 1L; when(userService.getUserById(userId)).thenReturn(Optional.of(new User(userId, "mock", Set.of("tag1", "tag2")))); // test } @Test @DisplayName("should delete user by id") void shouldDeleteUserById() { Long userId = 1L; doNothing().when(userService).deleteById(userId); // test }
Summary
If you only want to test the Controller, mock the bean of the service in the unit test of the Controller.
If you want to test the Controller and Service at the same time, write the integration test.
WebTestClient supports both API calls and streaming assertion of HTTP response. There is no need to introduce a third-party assertion tool, and it is very easy to use.
A good Controller:
- Use the correct URI
- Use the correct HTTP Method
- Use different HTTP status codes to indicate different status of resources
- Check the input parameters
For example code of this article, see:
Reference documents
HTTP
- https://httpstatuses.com/
- HTTP Status Code
- HTTP Methods
- HTTP headers - Accept
- HTTP headers - Content-Type
- HTTP headers - Location