Spring Boot Test series 4 - explore using WebTestClient for API testing

Posted by kykin on Tue, 18 Jan 2022 23:31:20 +0100

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

URIHTTP MethodHTTP StatusUse Case
/api/usersGET200Query all users
/api/users/POST201, 400Create a new user
/api/users/{id}GET200, 404Query user with specified ID
/api/users/{id}DELETE200Delete 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

Spring Boot

Testing

Topics: Spring Boot unit testing TDD