Unit Testing CRUD Endpoints of a Spring Boot Java Web Service/API

Overview

In software development, testing each part of a program is crucial to assert that all individual parts are correct.

In the previous article we covered some testing strategies, which you can check it here.

A unit is the smallest testable part of the software and in object-oriented programming it’s also called a method, which may belong to a super class, abstract class or a child class. Either way, unit tests are an important step in the development phase of an application.

Here are some key reasons to not skip the proccess of creating unit tests :

  • They help to fix bugs early in the development cycle and save costs;
  • Understanding the code base is essential and unit tests are some of the best way of enabling developers to learn all they can about the application and make changes quickly;
  • Good unit tests may also serve as documentation for your software;
  • Code is more reliable and reusable because in order to make unit testing possible, modular programming is the standard technique used;

Guidelines

  • Our test case should be independent;
  • Test only one code at a time;
  • Follow clear and consistent naming conventions;
  • Since we are doing unit tests, we need to isolate dependencies. We will use mocks for that. Mocks are objects that “fake”, or simulate, the real object behavior. This way we can control the behavior of our dependencies and test just the code we want to test. Later on we will do integration tests which uses no mocks and tests the actual behavior of all components and their integration.

Setting up our environment

In this project, we’ll be working with a CRUD RESTful API that we’ve developed using Spring Boot, if you want to know how we did that, you can click here.

For each operational endpoint, we’ll need to test its controller and service by unitary approach, simulating its expected result and comparing with the actual result through a mock standpoint.

Our testing framework of choice will be JUnit, it provides assertions to identify test method, so be sure to include in your maven pom.xml file :

<!-- Junit 5 -->
<dependency>
    <groupId>org.junit.jupiter</groupId>
    <artifactId>junit-jupiter-api</artifactId>
    <scope>test</scope>
</dependency>
<dependency>
    <groupId>org.junit.jupiter</groupId>
    <artifactId>junit-jupiter-engine</artifactId>
    <scope>test</scope>
</dependency>
<dependency>
    <groupId>org.junit.platform</groupId>
    <artifactId>junit-platform-launcher</artifactId>
    <scope>test</scope>
</dependency>
 
<!-- Mockito extention -->
<dependency>
    <groupId>org.mockito</groupId>
    <artifactId>mockito-junit-jupiter</artifactId>
    <scope>test</scope>
</dependency>

Our tests will be grouped in separate folders, the following way :

Unit tests folder separation regarding controllers and services

Testing the Service layer

Our Service layer implements our logic and depends on our Repository so we’ll need to mock its behavior through Mockito annotations and then verify the code with known inputs and outputs.

For a quick recap of our Service layer code that will be tested, these are all our endpoints service classes pasted into one section of code :

@Service
public class CreateUserService {

    @Autowired
    UserRepository repository;

    public User createNewUser(User user) {
        return repository.save(user);
    }
}

@Service
public class DeleteUserService {

    @Autowired
    UserRepository repository;

    public void deleteUser(Long id) {

        repository.findById(id)
                .orElseThrow(() -> new UserNotFoundException(id));

        repository.deleteById(id);
    }
}

@Service
public class DetailUserService {

    @Autowired
    UserRepository repository;

    public User listUser(Long id) {
        return repository.findById(id)
                .orElseThrow(() -> new UserNotFoundException(id));
    }
}

@Service
public class ListUserService {

    @Autowired
    UserRepository repository;

    public List<User> listAllUsers() {
        return repository.findAll();
    }
}

@Service
public class UpdateUserService {

    @Autowired
    UserRepository repository;

    public User updateUser(Long id, User user) {

        repository.findById(id)
                .orElseThrow(() -> new UserNotFoundException(id));

        user.setId(id);
        return repository.save(user);
    }
}

Create a new user service

Starting with our CreateUserService class, we’ll create a test class named CreateUserServiceTest.

src/test/java/com/usersapi/endpoints/unit/service/CreateUserServiceTest.java

@RunWith(MockitoJUnitRunner.class)
public class CreateUserServiceTest {

    @Mock
    private UserRepository userRepository;

    @InjectMocks
    private CreateUserService createUserService;

    @Test
    public void whenSaveUser_shouldReturnUser() {
        User user = new User();
        user.setName("Test Name");

        when(userRepository.save(ArgumentMatchers.any(User.class))).thenReturn(user);

        User created = createUserService.createNewUser(user);

        assertThat(created.getName()).isSameAs(user.getName());
        verify(userRepository).save(user);
    }
}

If you notice in the code, we’re using the assertThat and verify methods to test different things.

By calling the verify method, we’re checking that our repository was called and by calling assertThat we’re checking that our service answered our call with the correct expected value.

Some notes about the annotations used :

  • @RunWith(MockitoJUnitRunner.class) : Invokes the class MockitoJUnitRunner to run the tests instead of running in the standard built in class.
  • @Mock : Used to simulate the behavior of a real object, in this case, our repository
  • @InjectMocks : Creates an instance of the class and injects the mock created with the @Mock annotation into this instance
  • @Test : Tells JUnit that the method to which this annotation is attached can be run as a test case

Our other endpoints will follow the same pattern, with the exception of those that depend upon an id.

List all users service

src/test/java/com/usersapi/endpoints/unit/service/ListUserServiceTest.java

@RunWith(MockitoJUnitRunner.class)
public class ListUserServiceTest {

    @Mock
    private UserRepository userRepository;

    @InjectMocks
    private ListUserService listUserService;

    @Test
    public void shouldReturnAllUsers() {
        List<User> users = new ArrayList();
        users.add(new User());

        given(userRepository.findAll()).willReturn(users);

        List<User> expected = listUserService.listAllUsers();

        assertEquals(expected, users);
        verify(userRepository).findAll();
    }
}

Delete an existing user service

For all endpoints that rely upon a given id like this one, we need to throw an exception for when the id doesn’t exist. This exception needs to be also handled on unit tests.

src/test/java/com/usersapi/endpoints/unit/service/DeleteUserServiceTest.java

@RunWith(MockitoJUnitRunner.class)
public class DeleteUserServiceTest {

    @Mock
    private UserRepository userRepository;

    @InjectMocks
    private DeleteUserService deleteUserService;

    @Test
    public void whenGivenId_shouldDeleteUser_ifFound(){
        User user = new User();
        user.setName("Test Name");
        user.setId(1L);

        when(userRepository.findById(user.getId())).thenReturn(Optional.of(user));

        deleteUserService.deleteUser(user.getId());
        verify(userRepository).deleteById(user.getId());
    }

    @Test(expected = RuntimeException.class)
    public void should_throw_exception_when_user_doesnt_exist() {
        User user = new User();
        user.setId(89L);
        user.setName("Test Name");

        given(userRepository.findById(anyLong())).willReturn(Optional.ofNullable(null));
        deleteUserService.deleteUser(user.getId());
    }
}

Update an existing user service

src/test/java/com/usersapi/endpoints/unit/service/UpdateUserServiceTest.java

@RunWith(MockitoJUnitRunner.class)
public class UpdateUserServiceTest {

    @Mock
    private UserRepository userRepository;

    @InjectMocks
    private UpdateUserService updateUserService;

    @Test
    public void whenGivenId_shouldUpdateUser_ifFound() {
        User user = new User();
        user.setId(89L);
        user.setName("Test Name");

        User newUser = new User();
        user.setName("New Test Name");

        given(userRepository.findById(user.getId())).willReturn(Optional.of(user));
        updateUserService.updateUser(user.getId(), newUser);

        verify(userRepository).save(newUser);
        verify(userRepository).findById(user.getId());
    }

    @Test(expected = RuntimeException.class)
    public void should_throw_exception_when_user_doesnt_exist() {
        User user = new User();
        user.setId(89L);
        user.setName("Test Name");

        User newUser = new User();
        newUser.setId(90L);
        user.setName("New Test Name");

        given(userRepository.findById(anyLong())).willReturn(Optional.ofNullable(null));
        updateUserService.updateUser(user.getId(), newUser);
    }
}

List an existing user service

src/test/java/com/usersapi/endpoints/unit/service/DetailUserServiceTest.java

@RunWith(MockitoJUnitRunner.class)
public class DetailUserServiceTest {

    @Mock
    private UserRepository userRepository;

    @InjectMocks
    private DetailUserService detailUserService;

    @Test
    public void whenGivenId_shouldReturnUser_ifFound() {
        User user = new User();
        user.setId(89L);

        when(userRepository.findById(user.getId())).thenReturn(Optional.of(user));

        User expected = detailUserService.listUser(user.getId());

        assertThat(expected).isSameAs(user);
        verify(userRepository).findById(user.getId());
    }

    @Test(expected = UserNotFoundException.class)
    public void should_throw_exception_when_user_doesnt_exist() {
        User user = new User();
        user.setId(89L);
        user.setName("Test Name");

        given(userRepository.findById(anyLong())).willReturn(Optional.ofNullable(null));
        detailUserService.listUser(user.getId());
    }
}

Testing the Controller layer

Our unit tests regarding our controllers should consist of a request and a verifiable response that we’ll need to check if its what we expected or not.

By using the MockMvcRequestBuilders class, we can build a request and then pass it as a parameter to the method which executes the actual request. We then use the MockMvc class as the main entry point of our tests, executing requests by calling its perform method.

Lastly, we can write assertions for the received response by using the static methods of the MockMvcResultMatchers class.

For a quick recap of our Controller layer code that will be tested, these are all our endpoints controller classes pasted into one section of code :

@RestController
@RequestMapping("/users")
public class CreateUserController {

    @Autowired
    CreateUserService service;

    @PostMapping
    @ResponseStatus(HttpStatus.CREATED)
    public ResponseEntity<User> createNewUser_whenPostUser(@RequestBody User user) {

        User createdUser = service.createNewUser(user);

        URI uri = ServletUriComponentsBuilder.fromCurrentRequest()
                .path("/{id}")
                .buildAndExpand(createdUser.getId())
                .toUri();

        return ResponseEntity.created(uri).body(createdUser);
    }
}

@RestController
@RequestMapping("/users/{id}")
public class DeleteUserController {

    @Autowired
    DeleteUserService service;

    @DeleteMapping
    @ResponseStatus(HttpStatus.NO_CONTENT)
    public void deleteUser_whenDeleteUser(@PathVariable Long id) {
        service.deleteUser(id);
    }
}

@RestController
@RequestMapping("/users/{id}")
public class DetailUserController {

    @Autowired
    DetailUserService service;

    @GetMapping
    @ResponseStatus(HttpStatus.OK)
    public ResponseEntity<User> list(@PathVariable Long id) {
        return ResponseEntity.ok().body(service.listUser(id));
    }
}

@RestController
@RequestMapping("/users")
public class ListUserController {
    @Autowired
    ListUserService service;

    @GetMapping
    @ResponseStatus(HttpStatus.OK)
    public ResponseEntity<List<User>> listAllUsers_whenGetUsers() {
        return ResponseEntity.ok().body(service.listAllUsers());
    }
}

@RestController
@RequestMapping("/users/{id}")
public class UpdateUserController {

    @Autowired
    UpdateUserService service;

    @PutMapping
    @ResponseStatus(HttpStatus.OK)
    public ResponseEntity<User> updateUser_whenPutUser(@RequestBody User user, @PathVariable Long id) {
        return ResponseEntity.ok().body(service.updateUser(id, user));
    }
}

Create a new user controller

Applying these steps in our CreateUserControllerTest we should have :

src/test/java/com/usersapi/endpoints/unit/controller/CreateUserControllerTest.java

@RunWith(SpringRunner.class)
@WebMvcTest(CreateUserController.class)
public class CreateUserControllerTest {
    @Autowired
    private MockMvc mockMvc;

    @MockBean
    private CreateUserService service;

    @Test
    public void createUser_whenPostMethod() throws Exception {

        User user = new User();
        user.setName("Test Name");

        given(service.createNewUser(user)).willReturn(user);

        mockMvc.perform(post("/users")
                .contentType(MediaType.APPLICATION_JSON)
                .content(JsonUtil.toJson(user)))
                .andExpect(status().isCreated())
                .andExpect(jsonPath("$.name", is(user.getName())));
    }
}

About the @MockBean annotation, it’s from Spring Boot and is being used in our service, as well as being registered in our application context to verify the behavior of the mocked class.

Our JsonUtil class is being used to map and generate a JSON request for our “Create a new user” endpoint :

src/test/java/com/usersapi/endpoints/util/JsonUtil.java

public class JsonUtil {
    public static byte[] toJson(Object object) throws IOException {
        ObjectMapper mapper = new ObjectMapper();
        mapper.setSerializationInclusion(JsonInclude.Include.NON_NULL);
        return mapper.writeValueAsBytes(object);
    }
}

List all users controller

src/test/java/com/usersapi/endpoints/unit/controller/ListUserControllerTest.java

@RunWith(SpringRunner.class)
@WebMvcTest(ListUserController.class)
public class ListUserControllerTest {

    @Autowired
    private MockMvc mvc;

    @MockBean
    private ListUserService listUserService;

    @Test
    public void listAllUsers_whenGetMethod()
            throws Exception {

        User user = new User();
        user.setName("Test name");

        List<User> allUsers = Arrays.asList(user);

        given(listUserService
                .listAllUsers())
                .willReturn(allUsers);

        mvc.perform(get("/users")
                .contentType(MediaType.APPLICATION_JSON))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$", hasSize(1)))
                .andExpect(jsonPath("$[0].name", is(user.getName())));
    }
}

Delete an existing user controller

As done with our service layer unit tests, we’ll need to treat all endpoints that depend upon an id with additional configuration for a possible RunTimeException (UserNotFound) error that we specified previously :

src/test/java/com/usersapi/endpoints/unit/controller/DeleteUserControllerTest.java

@RunWith(SpringRunner.class)
@WebMvcTest(DeleteUserController.class)
public class DeleteUserControllerTest {

    @Autowired
    private MockMvc mvc;

    @MockBean
    private DeleteUserService deleteUserService;

    @Test
    public void removeUserById_whenDeleteMethod() throws Exception {
        User user = new User();
        user.setName("Test Name");
        user.setId(89L);

        doNothing().when(deleteUserService).deleteUser(user.getId());

        mvc.perform(delete("/users/" + user.getId().toString())
                .contentType(MediaType.APPLICATION_JSON))
                .andExpect(status().isNoContent());
    }

    @Test
    public void should_throw_exception_when_user_doesnt_exist() throws Exception {
        User user = new User();
        user.setId(89L);
        user.setName("Test Name");

        Mockito.doThrow(new UserNotFoundException(user.getId())).when(deleteUserService).deleteUser(user.getId());

        mvc.perform(delete("/users/" + user.getId().toString())
                .contentType(MediaType.APPLICATION_JSON))
                .andExpect(status().isNotFound());

    }
}

List an existing user controller

src/test/java/com/usersapi/endpoints/unit/controller/DetailUserControllerTest.java

@RunWith(SpringRunner.class)
@WebMvcTest(DetailUserController.class)
public class DetailUserControllerTest {

    @Autowired
    private MockMvc mvc;

    @MockBean
    private DetailUserService detailUserService;

    @Test
    public void listUserById_whenGetMethod() throws Exception {

        User user = new User();
        user.setName("Test Name");
        user.setId(89L);

        given(detailUserService.listUser(user.getId())).willReturn(user);

        mvc.perform(get("/users/" + user.getId().toString())
                .contentType(MediaType.APPLICATION_JSON))
                .andExpect(status().isOk())
                .andExpect(jsonPath("name", is(user.getName())));
    }

    @Test
    public void should_throw_exception_when_user_doesnt_exist() throws Exception {
        User user = new User();
        user.setId(89L);
        user.setName("Test Name");

        Mockito.doThrow(new UserNotFoundException(user.getId())).when(detailUserService).listUser(user.getId());

        mvc.perform(get("/users/" + user.getId().toString())
                .contentType(MediaType.APPLICATION_JSON))
                .andExpect(status().isNotFound());
    }
}

Update an existing user service

This endpoint differs from others because it needs a message body to be sent along with the request. For this we’ll be using the ObjectMapper class to write into the content.

src/test/java/com/usersapi/endpoints/unit/controller/UpdateUserControllerTest.java

@RunWith(SpringRunner.class)
@WebMvcTest(UpdateUserController.class)
public class UpdateUserControllerTest {
    @Autowired
    private MockMvc mvc;

    @MockBean
    private UpdateUserService updateUserService;

    @Test
    public void updateUser_whenPutUser() throws Exception {

        User user = new User();
        user.setName("Test Name");
        user.setId(89L);
        given(updateUserService.updateUser(user.getId(), user)).willReturn(user);

        ObjectMapper mapper = new ObjectMapper();

        mvc.perform(put("/users/" + user.getId().toString())
                .content(mapper.writeValueAsString(user))
                .contentType(MediaType.APPLICATION_JSON))
                .andExpect(status().isOk())
                .andExpect(jsonPath("name", is(user.getName())));
    }

    @Test
    public void should_throw_exception_when_user_doesnt_exist() throws Exception {
        User user = new User();
        user.setId(89L);
        user.setName("Test Name");

        Mockito.doThrow(new UserNotFoundException(user.getId())).when(updateUserService).updateUser(user.getId(), user);
        ObjectMapper mapper = new ObjectMapper();

        mvc.perform(put("/users/" + user.getId().toString())
                .content(mapper.writeValueAsString(user))
                .contentType(MediaType.APPLICATION_JSON))
                .andExpect(status().isNotFound());
    }
}

Endnotes

Through testing we learn about our application and make sure its code is safe and reliable.

Next, let’s add some integration tests, since we also want to test the integration, and not just mock their behavior.

We hope this article was useful for you, if you’d like to ask any question about it, make sure to contact us.

Source code

You can find all the source code from this article available in our github page here.

Thanks for reading !! Feel free to leave any comment.

About the author

Website | + Posts

Software Consultant with more than 11 years experience, most of that in Finance area. I love building things and see them running and making a difference.

Specialised in Golang (2 years), Java (11 years), Javascript, React and Angular (1 year), PHP (2 years), project management and delivery leading (2 years), mentoring and coaching (1 year).

Website | + Posts

Electrical engineering student from Brazil passionate about learning and teaching people.

Had some professional and academic engineering experiences, now my focus is on studying and developing software as a full time job.

Leave a Reply

Your email address will not be published. Required fields are marked *