Go back to Blogs
Guide to Unlocking TDD in API Development
Understanding TDD
In traditional software development, developers add the feature first and then writing test cases considering different scenarios and corner cases. Test Driven Development, in essence, flips the traditional development process on its head. Instead of writing code first and then testing it, developers begin by crafting tests for the desired functionality before writing the actual code. This iterative cycle typically follows three steps: Red, Green, and Refactor.
- RED – Start by writing a test that describes the behavior you want to implement. This test should fail initially since the corresponding functionality hasn’t been implemented yet.
- GREEN – Write the simplest code necessary to make the failing test pass. Avoid writing more code than needed to pass the test.
- REFACTOR – refactor the code to improve its design without changing its behavior. This step ensures that the code remains clean, maintainable, and easy to understand.
The flowchart below shows the TDD Cycles:
Building a Student Record Application with Spring Boot using TDD cycles
This blog demonstrates the TDD cycle within a Spring Boot application, where we write failing tests first, implemented the simplest code to pass those tests, and iteratively improved our codebase while ensuring that all tests continued to pass. TDD helps maintain a high level of confidence in the correctness of our code, especially in larger and more complex applications.
Prerequisites
- Java
- Basic spring boot starter
- JUnit
Setting Up the Project
Setup your project using either spring boot intializr (Spring boot starter) or in your preferred IDE (Intellij Idea or Eclipse). For our environment I have added three dependencies First, let’s set up our Spring Boot project with dependencies for H2 database and Spring Data JPA.
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.2.5</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>
<groupId>com.consultancy</groupId>
<artifactId>employeerecord</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>employeerecord</name>
<description>employeerecord</description>
<properties>
<java.version>17</java.version>
</properties>
<dependencies>
<!--
https://mvnrepository.com/artifact/org.springframework.boot
/spring-boot-starter-web
-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!--
https://mvnrepository.com/artifact/org.springframework.boot
/spring-boot-starter-data-jpa
-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<!-- https://mvnrepository.com/artifact/com.h2database/h2 -->
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>runtime</scope>
</dependency>
<!--
https://mvnrepository.com/artifact/org.springframework.boot
/spring-boot-starter-test
-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<!--
https://mvnrepository.com/artifact/org.springframework.boot
/spring-boot-maven-plugin -->
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
I added the following dependencies in the pom.xml file
- Spring boot starter Web – Used for building RESTful web services that uses Apache Tomcat as the default embedded container.
- Spring Data JPA – Provides persistent data store with Java Persistence API using Spring Data and Hibernate.
- H2 Database – Provides fast in-memory database that supports JDBC API and R2DBC access.
- Spring boot starter test – Used for writing JUnit classes for our TDD cycles.
The IoC Container
To manage our application and all the dependencies, I created the main class with @SpringBootApplication annotation, which incorporates @EnableAutoConfiguration, @ComponentScan and @Configuration Spring boot annotations all together.
// Spring Boot Container
package com.consultancy.employeerecord;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class EmployeerecordApplication {
public static void main(String[] args) {
SpringApplication.run(EmployeerecordApplication.class, args);
}
}
API Contracts
Developing an Web Service application requires an API Contract which answers questions about the service behavior, like:
- How the consumers interact with our API?
- What does the consumers need to send in various scenarios?
- What data should the service return?
- How does the service respond in case of errors?
GET endpoint API Contract – below is a sample API Contract for our Employee Record Application for GET (getEmployee/id) service endpoint:
Request
URI: /employee/getEmployee/{id}
HTTP Verb: GET
Body: None
Response:
HTTP Status:
200 OK if the user is authorized and employee
information is successfully retrieved
401 UNAUTHORIZED if the user is unauthenticated or unauthorized
404 NOT FOUND if the user is authenticated and authorized
but the employee information cannot be found
Response Body Type: JSON
Response Body:
{
"id": 201,
"name": "Martin Tylor",
"department": "Engineering",
"role": "Software Engineer"
}
In the following section, we use the TDD cycles [RED, GREEN and REFACTORING] to develop our GET endpoint getEmployee/id.
The steps to follow are:
- Write a test method for the getEmployee/id endpoint
- Create the REST Controller and add GET endpoint to the controller
- Refactor the code to optimize the implementation and run all tests.
Write a Failing Test
We use Spring boot test dependency (@SpringBootTest) to implement our test method. But first thing is firs, before creating the new test method, check if our project build without any error. Open terminal and navigate to our project directory and writing ‘mvn clean test’, if the project builds without any error, we are good to go.
Let’s create a test class EmployeerecordApplicationTests in the package com.consultancy.employeerecord in our project test directory.
package com.consultancy.employeerecord;
import com.jayway.jsonpath.DocumentContext;
import com.jayway.jsonpath.JsonPath;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.client.TestRestTemplate;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import static org.junit.jupiter.api.Assertions.assertEquals;
@SpringBootTest(webEnvironment = SpringBootTest
.WebEnvironment.RANDOM_PORT)
class EmployeeRecordApplicationTests {
@Autowired
TestRestTemplate restTemplate;
@Test
void employee_record_exists_get_test() {
ResponseEntity<String> response = restTemplate
.getForEntity("/employee/getEmployee/201",
String.class);
// Assert that the response is OK
assertEquals(HttpStatus.OK, response.getStatusCode());
// Retrieve the response body
DocumentContext context = JsonPath.parse(response.getBody());
// Check employee record (id, name, department and role)
Number id = context.read("$.id");
assertEquals(201, id);
String name = context.read("$.name");
assertEquals ("Jannet Jackson", name);
String department = context.read("$.department");
assertEquals("Engineering", department);
String role = context.read("$.role");
assertEquals("Software Engineer", role);
}
}
Let’s understand what the test does:-
The annotation at the top of the class, @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT), starts our Spring Boot application and makes it available four the test.
The @Autowired annotation injects TestRestTemplate dependency into our test which allows us to make HTTP request to a locally running Spring boot application (@Autowired annotation better used only in the test environment for dependency injection).
We use the restTemplate to make HTTP GET request to our GET service endpoint, then we use JUnit Jupiter API library to check if the response is OK.
We added some tests in the test class, such as, whether the response body contains HttpStatus code OK, and to retrieve employee information from the REST endpoint (id, name, department and role)
Run the command – “mvn clean test” in terminal within the project directory. We did not implement the api endpoint yet. Hence, the test is expected to fails with the following output:-
[ERROR] Failures:
[ERROR] EmployeerecordApplicationTests
.shouldReturnACashCardWhenDataIsSaved:22
expected: 200 OK> but was: 404 NOT_FOUND>
[INFO]
[ERROR] Tests run: 1, Failures: 1, Errors: 0, Skipped: 0
[INFO]
We are requesting the Spring Web to handle GET employee/getEmployee/201, but we didn’t implement the REST endpoint, the Spring Web is responding that the endpoint is NOT_FOUND
Implement GET REST Controller
Let’s do a simple implementation of GET REST Controller in order to resolve the failure in our test above. We add the REST Controller class, EmployeeRecordController for our GET endpoint:
package com.consultancy.employeerecord;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class EmployeeRecordController {
@GetMapping("employee/getEmployee/{requestId}")
public ResponseEntity<EmployeeRecord> getEmployee(
@PathVariable Long requestId) {
// Hard-coded Employee Record
EmployeeRecord employeeRecord = new EmployeeRecord();
employeeRecord.setId(requestId);
employeeRecord.setName("Jannet Jackson");
employeeRecord.setDepartment("Engineering");
employeeRecord.setRole("Software Engineer");
return ResponseEntity.ok(employeeRecord);
}
}
We also need to add simple POJO class, EmployeeRecord to represent the employee record:
package com.consultancy.employeerecord;
public class EmployeeRecord {
private Long id;
private String name;
private String department;
private String role;
public EmployeeRecord() {
this.id = id;
this.name = name;
this.department = department;
this.role = role;
}
// Getters and Setters
}
The Rest Controller class, EmployeeRecordController, is a simple implementation of our controller to pass the test. The annotation @RestController, tells Spring that this class is a Component of type RestController and capable of handling HTTP requests. The @GetMapping(…) marks the method as a handler method with address employee/getEmployee/{requestId}. The get request that matches the path will be handled by this method.
If we go to the terminal window and run “mvn clean test” command, we get the following output:
[INFO] Results:
[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0
[INFO] ---------------------------------------------------
[INFO] BUILD SUCCESS
[INFO] ---------------------------------------------------
The test successfully passes for the specified scenario, i.e, the REST endpoint returns employee record with id 201.
But what if we request employee record with id 1001? We want our controller endpoint to only return employee record whose data is present, but not for those whose data is absent.
Let’s write a test case for the employee record that doesn’t exist:
@Test
void employee_record_does_not_exist_get_test() {
ResponseEntity<String> response = restTemplate
.getForEntity("/employee/getEmployee/1001",
String.class);
// Assert that the response is NOT_FOUND
assertEquals(HttpStatus.NOT_FOUND, response.getStatusCode());
assertNull(response.getBody());
}
We want out endpoint to return the HttpStatus NOT_FOUND for the employees whose record is not available. Running the test gives us the following result:-
[INFO] Results:
[INFO][ERROR] Failures:
[ERROR] EmployeeRecordApplicationTests
.employee_record_does_not_exist_get_test:49
expected: 404 NOT_FOUND> but was: 200 OK>
The test fails, because we were expecting the Controller to return the status code NOT_FOUND, but the actual result from our endpoint is OK.
Now let’s update a new implementation logic in the controller to to fix this issue.The updated code is given bellow:
package com.consultancy.employeerecord;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RestController;
import java.util.Objects;
@RestController
public class EmployeeRecordController {
@GetMapping("employee/getEmployee/{requestId}")
public ResponseEntity<EmployeeRecord> getEmployee(
@PathVariable Long requestId) {
if (Objects.equals(requestId, 201L)) {
// Hard-coded Employee Record
EmployeeRecord employeeRecord = new EmployeeRecord();
employeeRecord.setId(requestId);
employeeRecord.setName("Jannet Jackson");
employeeRecord.setDepartment("Engineering");
employeeRecord.setRole("Software Engineer");
return ResponseEntity.ok(employeeRecord);
} else {
return ResponseEntity.notFound().build();
}
}
}
Now all our tests pass successfully as shown below:-
[INFO] Results:
[INFO] Tests run: 2, Failures: 0, Errors: 0, Skipped: 0
--------------------------------------------------------
[INFO] BUILD SUCCESS
--------------------------------------------------------
Refactor the code to use Repository and Database
Refactoring is a method of updating / altering the implementation of our application without altering its behavior. Our tests will allow us to change the implementation of our Employee Record API’s data management.
Repository and Spring Data
In our current implementation, the REST Controller system returns a hard-coded employee record, but this is a violation of Separation of Concerns. The web Controller should not manage the data, there should be a distinct layer between our web controller and our data.
What we want to return is real data from the database and a Separate layer for our data. Let’s add Spring Data to our application by creating a repository interface, EmployeeRecordRepository, and make it available to our controller via Dependency Injection and use it for data management.
In our scenario, we are using In-memory database (H2 Database) to manage data.
Add EmployeeRecordRepository
For our repository selection, we’ll use Sring Data’s CrudRepository. With the new repository interface, EmployeeRecordRepository, provided below, we can call any predefined methods of CrudRepository, such as, findById, findAll, etc. The code is given below:-
package com.consultancy.employeerecord;
import org.springframework.data.repository.CrudRepository;
public interface EmployeeRecordRepository extends
CrudRepository<EmployeeRecord, Long> {
}
Spring Data takes care of the implementation for us during the IoC container setup thime, the Spring runtime will then expose the repository as another bean that we can reference wherever needed in our application.
Let’s update our EmployeeRepositoryDTO with JPA annotations:
package com.consultancy.employeerecord;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
@Entity
public class EmployeeRecord {
@Id
private Long id;
private String name;
private String department;
private String role;
public EmployeeRecord() {
}
// Getters and Setters
}
The JPA repository requires Default public constructor in the DTO and we added Getters and Setters for the fields. We have updated our DTO with the following Spring JPA annotations:
The @Entity annotation – Spring Boot treats this class as an Entity during startup.
The @Id annotation – the field is treated as id.
The @GeneratedValue(strategy=GenerationType.AUTO) annotation: the id field is a GeneratedValue with Generation Strategy of Type AUTO.
Let’s check nothing is broken in our existing application by running the command ‘mvn clean test’ in the terminal:-
[INFO] Results:
[INFO]
[INFO] Tests run: 2, Failures: 0, Errors: 0, Skipped: 0
[INFO]
[INFO]
-----------------------------------------------------------
[INFO] BUILD SUCCESS
[INFO]
-----------------------------------------------------------
Injecting the EmployeeRecordRepository
Instead of hardcoding the data inside our controller, let’s use our EmployeeRecordRepository to manage the data in our controller
The updated EmployeeRecordController is shown below:
package com.consultancy.employeerecord;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.Optional;
@RestController
@RequestMapping("employee")
public class EmployeeRecordController {
private final EmployeeRecordRepository repository;
public EmployeeRecordController(EmployeeRecordRepository repository) {
this.repository = repository;
}
@GetMapping("/getEmployee/{requestId}")
public ResponseEntity<EmployeeRecord> getEmployee(
@PathVariable Long requestId) {
Optional<EmployeeRecord> employeeRecord = repository
.findById(requestId);
return employeeRecord
.map(ResponseEntity::ok)
.orElseGet(() -> ResponseEntity
.notFound()
.build());
}
}
In the updated code:
- we inject the EmployeeRecordRepository with constructor injection.
- Use the CrudRepository’s findById(id) method to get an Optional<EmployeeRecord> with the requested id from the database.
- If the employee record is present in the database, it returns a Response entity with OK HttpStatus code, other with, it returns NOT_FOUND status to the caller.
Let’s run the test with ‘mvn clean test’ and check if something is broken:-
[INFO] Results:
[ERROR] Failures:
[ERROR] EmployeeRecordApplicationTests
.employee_record_exists_get_test:25
expected: 200 OK> but was: 404 NOT_FOUND>
[INFO]
[ERROR] Tests run: 2, Failures: 1, Errors: 0, Skipped: 0
The test is failing and the cause of the error is that the result is not found in our database. Since there is no more hard-coded data in our controller and we didn’t create anything in our In-memory database, a call to the API endpoint “/employee/getEmployee/201” returns an empty result.
Configure In-memory Database
Let’s create the database schema and table in src/main/resource (schema.sql) and add some data in the table in the test environment in src/test/resource (data.sql) to resolve the above test failure.
Add the sql schema creation code in src/main/resource/schema.sql
CREATE SCHEMA IF NOT EXISTS employee;
SET SCHEMA employee;
CREATE TABLE IF NOT EXISTS employee_record
(
ID BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
NAME VARCHAR (255),
DEPARTMENT VARCHAR (255),
ROLE VARCHAR (255)
);
Add the following sql code in src/test/resource/data.sql for the tests to access all the available employee records:-
INSERT INTO EMPLOYEE_RECORD(ID, NAME, DEPARTMENT, ROLE)
VALUES (201, 'Jannet Jackson', 'Engineering', 'Software Engineer');
We also need to add the following in the following in the application.properties, which instructs the application JPA to use schema.sql file to create the schema instead of Hibernate to create the schema automatically:-
spring.jpa.hibernate.ddl-auto=none
Now running the test in terminal gives us the following output:
[INFO] Results:
[INFO] Tests run: 2, Failures: 0, Errors: 0, Skipped: 0
--------------------------------------------------------
[INFO] BUILD SUCCESS
--------------------------------------------------------
Thankfully, all the tests pass successfully without any error for the GET endpoint.
We have successfully refactored the Employee Record API so that the data is managed by Spring Data JPA instead of hard-coding the data inside the controller. Spring Data is creating the in-memory H2 database and loading it with test data, data.sql. The test guided us to the correct implementation of the API.
Let’s now add the POST API endpoint to our EmployeeRecordController.
Implementing POST Endpoint
Let’s continue the TDD software development approach to implement our POST API endpoint.
API Contract of POST endpoint – The API Contract for the POST endpoint is provided below:
Request
URI: /employee/addEmployee
Method: POst
Body:
{
"name": "Stefano Banucchi",
"department": "Engineering",
"role": "Senior Software Engineer"
}
Response:
HTTP Status:
201 CREATED if the user is authorized and employee
information is successfully retrieved
401 UNAUTHORIZED if the user is unauthenticated or unauthorized
Header:
Content-Type: application/json
Let’s add a test method for the POST endpoint; the snippet of the test code is provided below:
@Test
void test_create_new_employee_record_test() {
EmployeeRecord employeeRecord = new EmployeeRecord();
employeeRecord.setName("Alexandar Pontaja");
employeeRecord.setDepartment("Engineering");
employeeRecord.setRole("Senior Software Engineer");
ResponseEntity<Void> response = restTemplate
.postForEntity(
"/employee/addEmployee",
employeeRecord,
Void.class
);
assertEquals(HttpStatus.CREATED, response.getStatusCode());
}
The test creates an EmployeeRecord instance and sets all the fields except the Id field which is auto generated by the JPA Repository. Running the test in terminal gives us the following result:
[ERROR] Failures:
[ERROR] EmployeeRecordApplicationTests
.test_create_new_employee_record_test:60
expected: <201 CREATED> but was: <404 NOT_FOUND>
The test is expecting HttpStatus code <201 CREATED>, but since we didn’t create our POST Endpoint yet, the actual returned HttpStatus code is <404 NOT_FOUND>
Simple implementation of POST Endpoint to pass the test is given below:
@PostMapping("/addEmployee")
public ResponseEntity<Void> addEmployee(
@RequestBody EmployeeRecord employeeRecord) {
repository.save(employeeRecord);
return ResponseEntity
.created(URI.create("/path/to/newEmployeeRecord"))
.build();
}
All our tests have successfully passed; the output is provided below:
[INFO] Tests run: 3, Failures: 0, Errors: 0, Skipped: 0
[INFO]
[INFO] -------------------------------------------------
[INFO] BUILD SUCCESS
[INFO] -------------------------------------------------
Add Test for semantic correctness
We want our Employee Record API to behave as semantically correct as possible, i.e, users of our API should have a clear view of the API behavior. Let’s refer to the official Request for Comments for HTTP Semantics and Content (RFC 9110) for guidance as to how our API should behave.
For our POST endpoint, review this section about HTTP POST; which states that the POST method requests that the target resource process the representation enclosed in the request according to the resource’s own specific semantics.
Let’s update the POST test method to satisfy the above requirements:
@Test
void test_create_new_employee_record_test() {
EmployeeRecord employeeRecord = new EmployeeRecord();
employeeRecord.setName("Alexandar Pontaja");
employeeRecord.setDepartment("Engineering");
employeeRecord.setRole("Senior Software Engineer");
// 1) Create Employee Record
ResponseEntity<Void> createResponse = restTemplate
.postForEntity("/employee/addEmployee",
employeeRecord,
Void.class);
assertEquals(HttpStatus.CREATED, createResponse.getStatusCode());
// 2) Retrieve the location of the created Employee Record
URI location = createResponse.getHeaders().getLocation();
ResponseEntity<String> getResponse = restTemplate
.getForEntity(location, String.class);
assertEquals(HttpStatus.OK, getResponse.getStatusCode());
// 3) Retrieve the response body
DocumentContext context = JsonPath.parse(getResponse.getBody());
String name = context.read("$.name");
assertEquals ("Alexandar Pontaja", name);
String department = context.read("$.department");
assertEquals("Engineering", department);
String role = context.read("$.role");
assertEquals("Senior Software Engineer", role);
}
If we run the test, the result is a failure as provided below:
[ERROR] Failures:
EmployeeRecordApplicationTests.test_create_new_employee_record_test:67
expected: <200 OK> but was: <404 NOT_FOUND>
What is happening? In our previous simple POST Endpoint implementation, we save the new employee record in the database, but we do not retrieve the url of the record. In the updated version of the test, we are expecting <200 OK> HttpStatus code but the actual output is <404 NOT_FOUND>, which means we could not retrieve the new record with url.
Let’s update the implementation of our POST Controller to properly return the location of our new Employee Record:
@PostMapping("/addEmployee")
public ResponseEntity<Void> addEmployee(
@RequestBody EmployeeRecord employeeRecord,
UriComponentsBuilder uriComponentsBuilder) {
// Save Employee Record
EmployeeRecord savedEmployeeRecord = repository
.save(employeeRecord);
// Retrieve Saved Employee URI
URI recordPath = uriComponentsBuilder
.path("employee/getEmployee/{id}")
.buildAndExpand(savedEmployeeRecord.getId())
.toUri();
return ResponseEntity.created(recordPath).build();
}
In the updated version of the code above, we properly retrieve the URI of the new Employee Record using UriComponentsBuilder and include it in the Controller ResponseEntity.
Now all our tests successfully passes and we can retrieve all the new employee record as provided in the output below:
[INFO] Results:
[INFO] Tests run: 3, Failures: 0, Errors: 0, Skipped: 0
[INFO] -------------------------------------------------
[INFO] BUILD SUCCESS
[INFO] -------------------------------------------------
Conclusion:
In this blog, we explored the Test-Driven Development (TDD) cycles within a Spring Boot application. We started by writing failing tests, then developed a basic hard-coded API GET endpoint to pass these tests. Then, we enhanced our codebase to integrate Spring Boot Data JPA, ensuring all tests continued to pass. We also introduced a POST API endpoint for saving new employee records in the database, adhering to TDD principles. Throughout this process, we applied key TDD concepts, Red-Green-Refactor, which involves writing a failing test (Red), making the test pass with minimal code (Green), and then refactoring the code while keeping tests green.
Test Driven Development is a powerful technique that helps maintain a high level of confidence in the correctness of our code, especially in large and more complex applications. This practice also promotes cleaner code design, as it encourages writing only the necessary code to pass the tests. Moreover, TDD supports continuous integration and delivery practices by ensuring that new features do not break existing functionality. By writing tests before code, developers can ensure that their software is robust, reliable, and bug-free.