Guide to Unit Testing Spring Boot REST APIs

Introduction

Testing the system is an important phase in a Software Development Life Cycle (SDLC). Testing promotes code reliability, robustness, and ensures high-quality software delivered to clients if implemented correctly.

Testing has been given more importance ever since Test-Driven Development (TDD) has become a prominent process in developing software. Test-driven development entails converting requirements into test cases and using these test cases to ensure code quality. Code will be considered unacceptable if it fails any of the test cases declared in a system, and the more test cases that cover product requirements, the better. The codebase is lengthened considerably but reinforces the fact that the system meets the given requirements.

REST APIs are usually rigorously tested during integration testing. However, a good developer should test REST endpoints even before integration in their Unit Tests, since they are a vital part of the code since it's the sole access point of every entity wanting to make use of the services in the server.

This guide will demonstrate how to implement unit tests for REST APIs in a Spring Boot environment. This article focuses on testing the business layer which consists of the APIs, endpoints, and controllers within the codebase.

Requirements

For this tutorial, you would need the following specifications:

  • Spring Boot v2.0+
  • JDK v1.8+
  • JUnit 5 - The most popular and widely used testing framework for Java.
  • Mockito - General-purpose framework for mocking and stubbing services and objects.
  • MockMVC - Spring's module for performing integration testing during unit testing.
  • Lombok - Convenience library for reducing boilerplate code.
  • Any IDE that supports Java and Spring Boot (IntelliJ, VSC, NetBeans, etc.)
  • Postman, curl or any HTTP client

If you're still not quite comfortable building a REST API with Spring Boot - read our Guide to Building Spring Boot REST APIs.

We'll be using Lombok as a convenience library that automatically generates getters, setters and constructors, and it's fully optional.

Project Setup

The easiest way you can get started with a skeleton Spring Boot project is via Spring Initializr:

Other than these, we'll need a couple of extra dependencies added in the pom.xml file.

Adding Unit Testing Dependencies

Let's go ahead and add the dependencies necessary for the unit testing.

For JUnit 5, the latest version, we would need to exclude JUnit 4 from the spring-boot-starter-test dependency because it adds JUnit 4 by default. To add JUnit 5 to your project, add junit-jupiter-engine to your dependencies under your main pom.xml file after excluding JUnit 4 from the springboot-starter-test dependency.

MockMVC is already included within spring-boot-starter-test by default, so unless you exclude it and use another rendition of it, then you're good to go:

<!-- ...other dependencies -->
<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-test</artifactId>
  <scope>test</scope>
  <exclusions>
    <exclusion>
      <groupId>junit</groupId>
      <artifactId>junit</artifactId>
    </exclusion>
  </exclusions>
</dependency>

<dependency>
  <groupId>org.junit.jupiter</groupId>
  <artifactId>junit-jupiter-engine</artifactId>
  <scope>test</scope>
</dependency>

Besides JUnit 5, we also need to add dependencies to enable Mockito in your system. For this, simply add mockito-core to your dependencies and put in the value test as the scope for this dependency:

<dependency>
  <groupId>org.mockito</groupId>
  <artifactId>mockito-core</artifactId>
  <scope>test</scope>
</dependency>
<!-- ...other dependencies -->

Note: If you don't specify the version for your dependencies, then it will simply get the latest available stable version of that dependency from the repository you're downloading from.

With this, we can now proceed to code the domain and persistence layers.

Domain and Persistence Layers

Domain Layer - Creating a PatientRecord Model

The sample entity that we'll be using throughout the tutorial will be of patient records containing a few typical fields for a patient record.

Don't forget to annotate your model class with @Entity to specify that the class is mapped to a table in the database. The @Table annotation can also be specified to make sure the class is pointing to the right table.

Aside from these two annotations, include the Lombok utility annotations (@Data, @No/AllArgsConstructor, @Builder) so you won't have to declare your getters, setters, and constructors as Lombok already does that for you.

The String and Integer fields are annotated with @NonNull to prevent them from having a null or an empty value for validation purposes:

@Entity
@Table(name = "patient_record")
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class PatientRecord {
    @Id
    @GeneratedValue(strategy = GenerationType.AUTO)
    private Long patientId;
    
    @NonNull
    private String name;
 
    @NonNull
    private Integer age;
    
    @NonNull 
    private String address;
}

Persistence Layer - Creating a PatientRecordRepository

The next step is to create a JPA repository to provide methods to easily retrieve and manipulate patient records in the database, without the hassle of manual implementation.

Let's annotate an interface with @Repository and extend JpaRepository to create a properly working JPA repository interface. For this tutorial, the JPA repository won't have any custom methods so the body should be empty:

@Repository
public interface PatientRecordRepository extends JpaRepository<PatientRecord, Long> {}

Now that we've built our simple domain and persistence layer, let's move on to coding the components for our business layer.

Business Layer

The business layer is made up of controllers that allow communication to the server and provides access to the services that it provides.

For this tutorial, let's make a controller that exposes 4 simple REST endpoints, one for each CRUD operation: Create, Read, Update, and Delete.

Instantiating a Controller Class - PatientRecordController

Firstly, annotate your controller class with the @RestController annotation to inform the DispatcherServlet that this class contains request mapping methods.

If you haven't worked with Rest Controllers before, read our guide on The @Controller and @RestController annotations.

To provide CRUD services for the methods, declare the PatientRecordRepository interface within the controller class and annotate it with @Autowired to implicitly inject the object so you won't need to instantiate it manually.

You can also annotate the class with @RequestMapping with a value property to initialize a base path for all the request mapping methods within the class. Let's set the value property to /patientRecord for the base path to be intuitive:

@RestController
@RequestMapping(value = "/patient")
public class PatientRecordController {
    @Autowired PatientRecordRepository patientRecordRepository;
    // CRUD methods to be added
}

Now, let's create several methods that constitute the CRUD functionality that we'll be unit testing.

Retrieving Patients - GET Request Handler

Let's create two different GET methods: one to get all the patient records within the database, and one to get a single record given a patient ID.

To specify that a method is mapped by GET, annotate it with the @GetMapping annotation:

@GetMapping
public List<PatientRecord> getAllRecords() {
    return patientRecordRepository.findAll();
}

@GetMapping(value = "{patientId}")
public PatientRecord getPatientById(@PathVariable(value="patientId") Long patientId) {
    return patientRecordRepository.findById(patientId).get();
}

If you're unfamiliar with the derived variants of @RequestMapping - you can read our guide on Spring Annotations: @RequestMapping and its Variants.

Since the getPatientById() method needs a parameter (patientId), we'll provide it via the path, by annotating it with @PathVariable and providing the value property of the variable. Also, set the value property of the @GetMapping annotation to map the path variable to its actual place in the base path.

Creating Patients - POST Request Handler

Adding new patient records will need a POST-mapping method. The method will accept a PatientRecord parameter annotated by @RequestBody and @Valid. The @Valid annotation ensures that all the constraints within the database and in the entity class are cross-checked before the data is manipulated.

If you're unfamiliar with the process of deserializing HTTP requests to Java objects - read our guide on How to Get HTTP Post Body in Spring Boot with @RequestBody:

@PostMapping
public PatientRecord createRecord(@RequestBody @Valid PatientRecord patientRecord) {
    return patientRecordRepository.save(patientRecord);
}

Before proceeding to the other request methods, let's create a single general exception for all the exceptions encountered in the codebase and call it InvalidRequestException. For the status code, let's use the BAD_REQUEST status code 400.

To handle exceptions and convert it into a status code to return to the caller, let's declare an simple exception class that extends the RuntimeException class:

@ResponseStatus(HttpStatus.BAD_REQUEST)
class InvalidRequestException extends RuntimeException {
    public InvalidRequestException(String s) {
        super(s);
    }
}

Updating Patients - PUT Request Handler

To handle updates - for the PUT method, let's annotate it with a @PutMapping and require a parameter annotated by @RequestBody that contains the updated PatientRecord, similar to the POST mapping.

We'll want to make sure that the record exists for validation purposes by using the patientId. Since this is a PUT request, the record to be updated should exist within the database, otherwise this is an invalid request. Also, throw an InvalidRequestException if the request body or the patientId field is null:

@PutMapping
public PatientRecord updatePatientRecord(@RequestBody PatientRecord patientRecord) throws NotFoundException {
    if (patientRecord == null || patientRecord.getPatientId() == null) {
        throw new InvalidRequestException("PatientRecord or ID must not be null!");
    }
    Optional<PatientRecord> optionalRecord = patientRecordRepository.findById(patientRecord.getPatientId());
    if (optionalRecord.isEmpty()) {
        throw new NotFoundException("Patient with ID " + patientRecord.getPatientId() + " does not exist.");
    }
    PatientRecord existingPatientRecord = optionalRecord.get();

    existingPatientRecord.setName(patientRecord.getName());
    existingPatientRecord.setAge(patientRecord.getAge());
    existingPatientRecord.setAddress(patientRecord.getAddress());
    
    return patientRecordRepository.save(existingPatientRecord);
}

Deleting Patients - DELETE Request Handler

Now, we'll also want to be able to delete patients. This method will be annotated by @DeleteMapping and will accept a patientId parameter and delete the patient with that ID if it exists. The method will return an exception and a 400 status code if the patient doesn't exist. Like the GET method that retrieves a patient by ID, add a value property to the @DeleteMapping annotation, as well as the @PathVariable:

@DeleteMapping(value = "{patientId}")
public void deletePatientById(@PathVariable(value = "patientId") Long patientId) throws NotFoundException {
    if (patientRecordRepository.findById(patientId).isEmpty()) {
        throw new NotFoundException("Patient with ID " + patientId + " does not exist.");
    }
    patientRecordRepository.deleteById(patientId);
}

Now, our business layer is primed and ready! We can go ahead and write unit tests for it.

If you'd like to read a more detailed guide to creating REST APIs in Spring Boot - read our Guide to Building Spring Boot REST APIs.

Free eBook: Git Essentials

Check out our hands-on, practical guide to learning Git, with best-practices, industry-accepted standards, and included cheat sheet. Stop Googling Git commands and actually learn it!

Let's move on to creating unit tests for the REST APIs in our controller class using JUnit, Mockito, and MockMVC.

Unit Testing Spring Boot REST APIs

MockMVC is a solution to allow web layer unit testing. Usually, testing REST APIs is done during integration testing, which means the app needs to be run in a container to test whether the endpoints are working or not. MockMVC enables testing the web layer (A.K.A business layer or controller layer) during unit testing with the proper configurations but without the overhead of having to deploy the app.

Having unit tests for the web layer also will significantly increase the test code coverage for your app and will reflect in tools like Sonar and JaCoCo.

The unit test directory is usually in the same source directory under a test/java/package directory. By default, the unit test file structure would look like this:

Project:
├─src
  ├───main
  │   ├───java
  │   └───resources
  └───test
      └───java

It's also good practice and standard convention to name your test classes the same as the controllers you're testing, with a -Test suffix. For example, if we want to test the PatientRecordController, we'll make a PatientRecordControllerTest class in the appropriate package under src/test/java.

Instead of annotating your test class with @SpringBootTest, we'll use the @WebMvcTest annotation so that the dependencies that will be loaded when you run the test class are the ones directly affecting the controller class. Any services, repositories, and database connections will not be configured and loaded once the test is ran so you will have to mock all of these components with the help of Mockito.

In this case, we only need to specify a single controller - PatientRecordController.class, for the @WebMvcTest annotation. If in case there are multiple controllers injected in a single test class, separate the controllers with a comma , and wrap them with a pair of curly braces {}:

@WebMvcTest(PatientRecordController.class)
public class PatientRecordControllerTest {
    @Autowired
    MockMvc mockMvc;
    @Autowired
    ObjectMapper mapper;
    
    @MockBean
    PatientRecordRepository patientRecordRepository;
    
    PatientRecord RECORD_1 = new PatientRecord(1l, "Rayven Yor", 23, "Cebu Philippines");
    PatientRecord RECORD_2 = new PatientRecord(2l, "David Landup", 27, "New York USA");
    PatientRecord RECORD_3 = new PatientRecord(3l, "Jane Doe", 31, "New York USA");
    
    // ... Test methods TBA
}

Here, we've declared a MockMvc object and annotated it with @Autowired, which is allowed in this context because MockMvc is auto-configured and part of the dependencies that are loaded for this test class. We've also autowired the ObjectMapper object; this will be used later on.

The PatientRecordRepository interface is used in all of the API endpoints, so we've mocked it with @MockBean. Finally, we've created a few PatientRecord instances for testing purposes.

Unit Testing the GET Request Handlers

Now, we can go ahead and make our first test case - also known as unit test. We'll be testing the getAllRecords() method, our GET request handler. For each unit test, we'll create a single method that test another one. Each unit test is annotated with @Test so that JUnit can pick them up and put them in a list of all the tests that need to be run:

@Test
public void getAllRecords_success() throws Exception {
    List<PatientRecord> records = new ArrayList<>(Arrays.asList(RECORD_1, RECORD_2, RECORD_3));
    
    Mockito.when(patientRecordRepository.findAll()).thenReturn(records);
    
    mockMvc.perform(MockMvcRequestBuilders
            .get("/patient")
            .contentType(MediaType.APPLICATION_JSON))
            .andExpect(status().isOk())
            .andExpect(jsonPath("$", hasSize(3)))
            .andExpect(jsonPath("$[2].name", is("Jane Doe")));
}

The Mockito when().thenReturn() chain method mocks the getAllRecords() method call in the JPA repository, so every time the method is called within the controller, it will return the specified value in the parameter of the thenReturn() method. In this case, it returns a list of three preset patient records, instead of actually making a database call.

MockMvc.perform() accepts a MockMvcRequest and mocks the API call given the fields of the object. Here, we built a request via the MockMvcRequestBuilders, and only specified the GET path and contentType property since the API endpoint does not accept any parameters.

After perform() is run, andExpect() methods are subsequently chained to it and tests against the results returned by the method. For this call, we've set 3 assertions within the andExpect() methods: that the response returns a 200 or an OK status code, the response returns a list of size 3, and the third PatientRecord object from the list has a name property of Jane Doe.

The statically referenced methods here - jsonPath(), hasSize() and is() belong to the MockMvcResultMatchers and Matchers classes respectively:

import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
import static org.hamcrest.Matchers.*;

You can, of course, statically reference them:

.andExpect(MockMvcResultMatchers.jsonPath("$", Matchers.hasSize(3)))
.andExpect(MockMvcResultMatchers.jsonPath("$[2].name", Matchers.is("Jane Doe")));

Though, if you have a lot of andExpect() statements chained together - this will get repetitive and annoying fairly quickly.

Note: All of these assertions should not fail for the unit test to pass. Running this code results in:

Now, let's add another test case for the getPatientById() method. Right beneath the previous unit test, we can write up a new one:

@Test
public void getPatientById_success() throws Exception {
    Mockito.when(patientRecordRepository.findById(RECORD_1.getPatientId())).thenReturn(java.util.Optional.of(RECORD_1));

    mockMvc.perform(MockMvcRequestBuilders
            .get("/patient/1")
            .contentType(MediaType.APPLICATION_JSON))
            .andExpect(status().isOk())
            .andExpect(jsonPath("$", notNullValue()))
            .andExpect(jsonPath("$.name", is("Rayven Yor")));
}

Here, we're checking if the result is null, asserting that it isn't and checking if the name field of the returned object is equal to "Rayven Yor". If we run the entire PatientRecordControllerTest class now, we'd be greeted with:

Unit Testing the POST Request Handlers

Now that we've tested the APIs ability to retrieve individual, identifiable records, as well as a list of all records - let's test its ability to persist records. The POST request handler accepts a POST request and maps the provided values into a PatientRecord POJO via the @RequestBody annotation. Our test unit will also accept JSON and map the values into a PatientRecord POJO via the ObjectMapper we've autowired before. We'll also save a reference to the returned MockHttpServletRequestBuilder after it's been generated by MockMvcRequestBuilders so that we can test the returned values:

@Test
public void createRecord_success() throws Exception {
    PatientRecord record = PatientRecord.builder()
            .name("John Doe")
            .age(47)
            .address("New York USA")
            .build();

    Mockito.when(patientRecordRepository.save(record)).thenReturn(record);

    MockHttpServletRequestBuilder mockRequest = MockMvcRequestBuilders.post("/patient")
            .contentType(MediaType.APPLICATION_JSON)
            .accept(MediaType.APPLICATION_JSON)
            .content(this.mapper.writeValueAsString(record));

    mockMvc.perform(mockRequest)
            .andExpect(status().isOk())
            .andExpect(jsonPath("$", notNullValue()))
            .andExpect(jsonPath("$.name", is("John Doe")));
    }

Running the class yet again results in:

Unit Testing the PUT Request Handlers

The PUT request handler has a bit more logic to it than the two before this. It checks whether we've provided an ID, resulting in an exception if it's missing. Then, it checks if the ID actually belongs to a record in the database, throwing an exception if it doesn't. Only then does it actually update a record in the database, if the ID isn't null and it does belong to a record.

We'll create three test methods to check if all three facets of this method are working: one for success, and one for each of the erroneous states that can occur:

@Test
public void updatePatientRecord_success() throws Exception {
    PatientRecord updatedRecord = PatientRecord.builder()
            .patientId(1l)
            .name("Rayven Zambo")
            .age(23)
            .address("Cebu Philippines")
            .build();

    Mockito.when(patientRecordRepository.findById(RECORD_1.getPatientId())).thenReturn(Optional.of(RECORD_1));
    Mockito.when(patientRecordRepository.save(updatedRecord)).thenReturn(updatedRecord);

    MockHttpServletRequestBuilder mockRequest = MockMvcRequestBuilders.post("/patient")
            .contentType(MediaType.APPLICATION_JSON)
            .accept(MediaType.APPLICATION_JSON)
            .content(this.mapper.writeValueAsString(updatedRecord));

    mockMvc.perform(mockRequest)
            .andExpect(status().isOk())
            .andExpect(jsonPath("$", notNullValue()))
            .andExpect(jsonPath("$.name", is("Rayven Zambo")));
}

Though, in cases where either the input data isn't right or the database simply doesn't contain the entity we're trying to update, the application should respond with an exception. Let's test that:

@Test
public void updatePatientRecord_nullId() throws Exception {
    PatientRecord updatedRecord = PatientRecord.builder()
            .name("Sherlock Holmes")
            .age(40)
            .address("221B Baker Street")
            .build();

    MockHttpServletRequestBuilder mockRequest = MockMvcRequestBuilders.post("/patient")
            .contentType(MediaType.APPLICATION_JSON)
            .accept(MediaType.APPLICATION_JSON)
            .content(this.mapper.writeValueAsString(updatedRecord));

    mockMvc.perform(mockRequest)
            .andExpect(status().isBadRequest())
            .andExpect(result ->
                assertTrue(result.getResolvedException() instanceof PatientRecordController.InvalidRequestException))
    .andExpect(result ->
        assertEquals("PatientRecord or ID must not be null!", result.getResolvedException().getMessage()));
    }

@Test
public void updatePatientRecord_recordNotFound() throws Exception {
    PatientRecord updatedRecord = PatientRecord.builder()
            .patientId(5l)
            .name("Sherlock Holmes")
            .age(40)
            .address("221B Baker Street")
            .build();

    Mockito.when(patientRecordRepository.findById(updatedRecord.getPatientId())).thenReturn(null);

    MockHttpServletRequestBuilder mockRequest = MockMvcRequestBuilders.post("/patient")
            .contentType(MediaType.APPLICATION_JSON)
            .accept(MediaType.APPLICATION_JSON)
            .content(this.mapper.writeValueAsString(updatedRecord));

    mockMvc.perform(mockRequest)
            .andExpect(status().isBadRequest())
            .andExpect(result ->
                assertTrue(result.getResolvedException() instanceof NotFoundException))
    .andExpect(result ->
        assertEquals("Patient with ID 5 does not exist.", result.getResolvedException().getMessage()));
}

Since we've mapped the InvalidRequestException with a @ResponseStatus(HttpStatus.BAD_REQUEST), throwing the exception will result in the method returning a HttpStatus.BAD_REQUEST. Here, we've tested the ability of our REST API to return appropriate status codes when faced with either faulty data or when someone's trying to update a non-existing entity.

Unit Testing the DELETE Request Handlers

Finally, let's test the functionality of our DELETE request handler - creating a test for the successful outcome and a test for the unsuccessful outcome:

@Test
public void deletePatientById_success() throws Exception {
    Mockito.when(patientRecordRepository.findById(RECORD_2.getPatientId())).thenReturn(Optional.of(RECORD_2));

    mockMvc.perform(MockMvcRequestBuilders
            .delete("/patient/2")
            .contentType(MediaType.APPLICATION_JSON))
            .andExpect(status().isOk());
}

@Test
public void deletePatientById_notFound() throws Exception {
    Mockito.when(patientRecordRepository.findById(5l)).thenReturn(null);

    mockMvc.perform(MockMvcRequestBuilders
            .delete("/patient/2")
            .contentType(MediaType.APPLICATION_JSON))
    .andExpect(status().isBadRequest())
            .andExpect(result ->
                    assertTrue(result.getResolvedException() instanceof NotFoundException))
    .andExpect(result ->
            assertEquals("Patient with ID 5 does not exist.", result.getResolvedException().getMessage()));
}

Now, let's use Maven to clean the project, compile it and run the tests.

Running the Program with Unit Testing

First off, we need to add the Maven Surefire plug-in in the pom.xml file so that we can run the mvn clean test command. We'll also add an additional configuration tag to include the PatientRecordControllerTest.java test class to include it in Maven tests:

<plugins>
    <plugin>
        <artifactId>maven-surefire-plugin</artifactId>
        <version>2.21.0</version>
        <configuration>
            <includes>
                <include>PatientRecordControllerTest.java</include>
            </includes>
        </configuration>
    </plugin>
    
    <!-- Other plugins -->
</plugins>

Then, in our project's directory, using a terminal, let's run:

$ mvn clean test

Which results in:

[INFO]
[INFO] -------------------------------------------------------
[INFO]  T E S T S
[INFO] -------------------------------------------------------
[INFO] Running com.example.demo.PatientRecordControllerTest
[INFO] Tests run: 8, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.504 s - in com.example.demo.PatientRecordControllerTest
[INFO]
[INFO] Results:
[INFO]
[INFO] Tests run: 8, Failures: 0, Errors: 0, Skipped: 0
[INFO]
[INFO] ------------------------------------------------------------------------
[INFO] BUILD SUCCESS
[INFO] ------------------------------------------------------------------------
[INFO] Total time:  5.633 s
[INFO] Finished at: 2021-05-25T19:51:24+02:00
[INFO] ------------------------------------------------------------------------

Conclusion

In this guide, we've taken a look at how to create and test a Spring Boot REST API with CRUD functionality using JUnit, Mockito and MockMvc.

Was this article helpful?

Improve your dev skills!

Get tutorials, guides, and dev jobs in your inbox.

No spam ever. Unsubscribe at any time. Read our Privacy Policy.

© 2013-2024 Stack Abuse. All rights reserved.

AboutDisclosurePrivacyTerms