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.
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.