Project that we are building
We are going to build a todo list API, where we will have a Project that can have multiple topics. Each of the topics will have individual tasks.
A project could have one or more topics, and a topic will have one or more tasks.
In this post, we will integrate the existing application with Spring JPA. We will then write some integration tests to make sure our application works the way we want it. We will do it in the following sequences:
- Modify our
OpenAPI3
specification to accept POST request in/projects
endpoint to save projects in the database. - Regenerate the code using the new specifications.
- Add SpringBoot integration-test to make sure our implementation works the way we want it.
- Add a POST implementation to save the
Project
in the database.
The source code for this post will be based on my previous post about Spring Boot Rest with OpenAPI 3. You can follow along with the previous post before follow along with this post, or you can just check out the code in this repo to get started at the same place with me.
If you are in a rush and like to see the ending instead, have a look at this repo.
If you want to know a little bit more about Spring Data JPA, here is an awesome post about The Persistent Layer with Spring Data JPA for your reference.
Introduction
As this post will guide through the implementation, if you want to directly see how the JPA implemented, instead of following along, you may scroll straight to the section of "JPA Implementation".
Write the Test
Objective
We will test the following scenarios:
-
Given few projects created, when user GET
/projects
then return all available projects -
Given a project created with id
1
, when user GET/projects/1
then return only project withid
1 -
Given a project created with name
project-1
, when user GET/projects?name=project-1
then return only project withname=project-1
Change the Test Mode to Spring Boot Test
@WebMvcTest
is not relevant anymore in our here, because we want to perform the test up to the repository level. According to the official javadoc, it will @WebMvcTest
will disable full auto-configuration and instead apply only configuration relevant to MVC tests. This mode will only relevant if only we will mock the repository & service and focus only on the testing up to the Controller.
@SpringBootTest
is more relevant for our case as we will use full auto-configuration to test up to database level.
@AutoConfigureMockMvc
will be required so that we could use MockMvc
object when we use @SpringBootTest
. It was not required as @WebMvcTest
will give us that object for granted.
Here how our class going to look like now, you should be able to run this with the same result.
@SpringBootTest
@AutoConfigureMockMvc
@DisplayName("ProjectController Integration Test") // junit 5 display name. This will give us a little more flexibility to name our test
@Log4j2 // this come from lombok dependency so we could log things out
class ProjectControllerIT {
@Autowired
private MockMvc mockMvc;
@Test
@DisplayName("Should Return OK Response when request sent to /projects endpoint")
public void shouldReturnOkOnProjectsEndpoint() throws Exception {
mockMvc.perform(get("/projects"))
.andExpect(status().isOk());
}
}
Creating a helper test method to create a project
These helper method helps us to create a project from the endpoint. The snippet as follows.
class ProjectControllerIT {
// previous snippet...
private ProjectResponse createOneProject() {
return createProject("project", 1).get(0);
}
private List<ProjectResponse> createProject(String prefix, int numberOfProject) {
List<ProjectResponse> listOfResponse = IntStream.range(0, numberOfProject)
.mapToObj(num -> createOneProjectWithName(format("%s-%d", prefix, num + 1)))
.collect(Collectors.toList());
return listOfResponse;
}
private ProjectResponse createOneProjectWithName(String name) {
try {
MvcResult result = mockMvc.perform(
post("/projects")
.contentType(APPLICATION_JSON)
.content(project(name))) // we want to create the payload using this method.
.andExpect(status().isCreated())
.andReturn();
String contentResponse = result.getResponse().getContentAsString();
ProjectResponse project = ProjectModelMapper.jsonToProject(contentResponse);
return project;
} catch (Exception e) {
throw new IllegalArgumentException("Invalid Request to Create Project with name" + name, e);
}
}
}
Notice that because we want to track what we have already created when we perform the POST, the following mapper will convert the content response to the ProjectResponse
object.
// we will use this class later to also map Project from database object ot the ProjectResponse
public class ProjectModelMapper {
private static ObjectMapper mapper = new ObjectMapper();
public static ProjectResponse jsonToProject(String json) throws JsonProcessingException {
return mapper.readValue(json, ProjectResponse.class);
}
}
Another helper method we want to have in our testing is the method to create the payload that we want to send to the application so the code will be a little bit more readable.
public class ProjectRequestBuilder {
private static ObjectMapper mapper = new ObjectMapper();
public static String project(String name) {
ProjectRequest pr = new ProjectRequest();
pr.setName(name);
return toJson(pr);
}
private static String toJson(Object obj) {
try {
return mapper.writeValueAsString(obj);
} catch (JsonProcessingException e) {
throw new IllegalArgumentException("Failed to parse JSON object", e);
}
}
}
Writing test code
We have everything we need now from the testing perspective, let's write the test code.
class ProjectControllerIT {
// other test written before
@Test
@DisplayName("Should return all available projects when request sent to /projects endpoint")
public void shouldReturnAllRecordsOnProjectsEndpoint() throws Exception {
createProject("project", 2);
mockMvc.perform(
get("/projects"))
.andExpect(status().isOk())
.andExpect(jsonPath("$", hasSize(2)));
createOneProject();
mockMvc.perform(
get("/projects"))
.andExpect(status().isOk())
.andExpect(jsonPath("$", hasSize(3)));
}
// other helper methods we wrote before
}
- In this test, we will call
createProject
to create aProject
. Behind the scene, this method which will effectively fire up a POST request to/projects
. It will form the JSON payload by transforming and object into JSON representation. - We created two projects in the first call, so when we call the
/projects
endpoint. we should get two projects in return. When we added another one, it will return us three results.
If we run this, we will get 501 in return because we haven't implemented anything for POST to /projects
The power of MockMVC
MockMvc
helps us a lot writing a test, you can imagine this as your Postman that helps you fire specific request. It could also act as your eyes to validate what you want to see in a project.
The way it structured is as follows:
- Perform an action The action can be a GET/POST or any other HTTP method. If you have anything in the request that you wanted to include, e.g headers or body, you can do so in the action part.
-
Assert the response
.andExpect
methods allows you to perform quite a number of assertions of the responses. You can pretty much validate any part of the response. - Return the result You can also return the result for later use. For example, the result of the previous call will be used for the future call.
JPA Implementation
Dependencies
We need spring-boot-starter-data-jpa
to helps us integrate with JPA, and we also want to use h2
database to write the implementation using in-memory database. Lets add the following dependency to the pom.xml
<!--...-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>runtime</scope>
</dependency>
<!--...-->
with those packages included in our dependencies, we need to put the configuration in our resource/application.yaml
as follows:
spring:
datasource:
url: jdbc:h2:mem:db;DB_CLOSE_DELAY=-1
username: super
password: secret
This tells spring how to connect tot he database.
Entity class
As we want to store a project in a database, we need an entity class representation of it. To mark a class as an entity, you may use @Entity
annotation.
The JPA default table name is the name of the class (minus the package) with the first letter capitalized. Each attribute of the class will be stored in a column in the table. @Table
annotation allows us to specify a table name. The others annotation is lombok annotations to keep our code concise. @Id
is used to mark the field as a primary key, and we can also specify if it is coming from auto-generated value using @GeneratedValue
.
Here is our entity class
@Entity
@Builder
@AllArgsConstructor
@NoArgsConstructor
@Data
@Table(name = "project")
public class Project {
@Id
@GeneratedValue(strategy = GenerationType.SEQUENCE)
private Long id;
private String name;
}
Repository to perform database operations
JPA will make things a little handy for us, what we need now is to create an interface that extends JPARepository
. The first generics indicate the object that will be used and the second is the type of the key.
public interface ProjectRepository extends JpaRepository<Project, Long> { }
This alone, has already give you a methods to perform basics operation such as findAll
, save
, delete
. We will write a bit more modification later in this post.
Next, is either we call directly from the controller
layer, or we wrap it in the service
layer. I would prefer to put a service layer, for us to add a business logic at some point.
@Service
public class ProjectService {
@Autowired
private ProjectRepository projectRepository;
public Project createProject(Project project) {
return projectRepository.save(project);
}
public List<Project> retrieveAllProjects() {
return projectRepository.findAll();
}
}
Notice that we actually never done any implementation to save an object, but JPARepository
keep us away from creating a lot of bolierplate code e.g prepare connection, build query, extract result set, and so on, and so forth.
Query methods offers by JPARepository
Later on in this post, we will need to query a project by a project name. That would means we need to find the project by name. However, if we look at the JPARepository
, there is no method to find anything by name.
To add that, we can simply add a query method in the ProjectRepository
interface as the following example:
public interface ProjectRepository extends JpaRepository<Project, Long> {
List<Project> findAllByName(String name);
}
For more details of what can the query method does, refer to official documentation.
Thats all, we are ready to call the service from the controller and run back our test.
Controller implementation
In this section, we are going to implement the project creation, to save the project into the database. We will also enhance our implementation of searchProject
so it will actually returns us the actual result from the database.
At this point, we have two Project
object representation, one is facing the API ProjectRequest
and ProjectResponse
, another one is Project
entity that facing the database. Therefore, when the object returned from the Repository
or Service
we need a mapper to map the entity object become the response. We will write that in this section too.
@Controller
public class ProjectController implements ProjectsApi {
@Autowired
private ProjectService service;
@Override
public ResponseEntity<List<ProjectResponse>> searchProjects(@Valid String name) {
return ResponseEntity.ok(ProjectModelMapper.toApi(service.retrieveAllProjects()));
}
@Override
public ResponseEntity<ProjectResponse> createProject(@Valid ProjectRequest projectRequest) {
Project project = service.createProject(ProjectModelMapper.toEntity(projectRequest));
return ResponseEntity.status(CREATED).body(toApi(project));
}
}
public class ProjectModelMapper {
private static ObjectMapper mapper = new ObjectMapper();
public static Project toEntity(ProjectRequest projectRequest) {
return Project.builder()
.name(projectRequest.getName())
.build();
}
public static ProjectResponse toApi(Project project) {
ProjectResponse projectResponse = new ProjectResponse();
projectResponse.setId(project.getId());
projectResponse.setName(project.getName());
return projectResponse;
}
public static List<ProjectResponse> toApi(List<Project> retrieveAllProjects) {
return retrieveAllProjects.stream()
.map(ProjectModelMapper::toApi)
.collect(Collectors.toList());
}
public static ProjectResponse jsonToProject(String json) throws JsonProcessingException {
return mapper.readValue(json, ProjectResponse.class);
}
}
We should get our test pass for now.
More tests
We have written 1 out of 3 test that we said we want to do in the previous section, here is the remaining:
-
Given a project created with id
1
, when user GET/projects/1
then return only project withid
1 -
Given a project created with name
project-1
, when user GET/projects?name=project-1
then return only project withname=project-1
Lets enhance our test code. In the following test, we will try to get the project by id. Given that the project created, we should be able to retrieved the project using the id that was returned before.
@Test
@DisplayName("Should be able to retrieve project by project id")
public void shouldRetrieveExistingProject() throws Exception {
ProjectResponse project = createOneProjectWithName("project-1");
Long projectId = project.getId();
mockMvc.perform(
get("/projects/" + projectId))
.andExpect(status().isOk())
.andExpect(jsonPath("$.name", is("project-1")))
.andExpect(jsonPath("$.id", is(projectId.intValue())));
}
Obviously the test will fail, and returns us 501
errors indicating the functionality is not implemented yet. We should then enhance our controller to include the following methods.
@Override
public ResponseEntity<ProjectResponse> getProjects(@Min(1L) Long projectId) {
Project project = service.findProject(projectId);
return ResponseEntity.ok().body(toApi(project));
}
as findProject
methods havent been implemented yet, lets get that implemented in our service
level. The getOne
method is the method that came with JPARepository
we do not need to modify our repository implementation at this point.
public Project findProject(Long projectId) {
return projectRepository.getOne(projectId);
}
And we got the new test pass now. However, we breaks the previous test. The reason why is because, the previous test was retrieving all the projects, but because of we have another test that is also adding the project into the database, the number doesn't add up correctly. The solution is pretty simple, we should reset the database state before we executing any test. To make sure our test is independent of each other.
We can add the following method into our test, this will make sure we clear up the project
table before any test is executing.
@Autowired
private JdbcTemplate jdbcTemplate;
@BeforeEach
void setUp() {
log.info("Deleting records from table");
JdbcTestUtils.deleteFromTables(jdbcTemplate, "project");
}
Now all of our tests is passed again. The last bit of the test before we wrap up with this post is the following feature:
-
Given a project created with name
project-1
, when user GET/projects?name=project-1
then return only project withname=project-1
So here is the test:
@Test
@DisplayName("Should be able to search project by name")
public void shouldRetrieveExistingProjectByName() throws Exception {
ProjectResponse projectOne = createOneProjectWithName("Project One");
ProjectResponse projectTwo = createOneProjectWithName("Project Two");
mockMvc.perform(
get("/projects").param("name", "Project One"))
.andExpect(status().isOk())
.andExpect(jsonPath("$", hasSize(1)))
.andExpect(jsonPath("$[0].id", is(projectOne.getId().intValue())))
.andExpect(jsonPath("$[0].name", is(projectOne.getName())));
mockMvc.perform(
get("/projects").param("name", "Project Two"))
.andExpect(status().isOk())
.andExpect(jsonPath("$", hasSize(1)))
.andExpect(jsonPath("$[0].id", is(projectTwo.getId().intValue())))
.andExpect(jsonPath("$[0].name", is(projectTwo.getName())));
mockMvc.perform(
get("/projects").param("name", "Project X"))
.andExpect(status().isOk())
.andExpect(jsonPath("$", hasSize(0)));
}
Again, we will fail the test because we haven't get any implementation in our controller to filter the projects by name. It should complain that the number of returned project is more than what we expect. Let's fix the code to accomodate that.
We don't have findProjectsByName
yet in our service, we may need that so our controller can use it to get some data from the repository. So lets add the new method to help us get the Project
by name. We have findAllByName
query methods added before.
public List<Project> findProjectsByName(String name) {
return projectRepository.findAllByName(name);
}
At this point, what we need to do is to enhance our controller, to perform a search by name, if there is a name specified, otherwise, we will return all projects.
@Override
public ResponseEntity<List<ProjectResponse>> searchProjects(@Valid String name) {
if (name != null) {
return ResponseEntity.ok(ProjectModelMapper.toApi(service.findProjectsByName(name)));
} else {
return ResponseEntity.ok(ProjectModelMapper.toApi(service.retrieveAllProjects()));
}
}
Wrap Up
Here is what we did so far:
- Modify our
OpenAPI3
specification to accept POST request in/projects
endpoint to save projects in the database. - Regenerate the code using the new specifications.
- Add SpringBoot integration-test to make sure our implementation works the way we want it.
- Add a POST implementation to save the
Project
in the database.
To see the complete source code, you may checkout the repo in my github here
Top comments (0)