Test-Driven Development That Every Developer Should Know About (With Example)

DN Tech
Geek Culture
Published in
5 min readJan 13, 2022

Thanks to Heaton Cai’s article on the TDD Process, we are able to see a high-level development of a TDD framework. However, knowing from my personal experience, most of the developers (including me), especially from non-software companies or traditional industries, are still using a code-driven framework.

One of the major disadvantages of a code-driven framework is that the code-driven approach is forward-thinking, which results in developers being too focused on the business logic of an application, neglecting the extensibility and edge cases. In contrast, a test-driven approach is backward-thinking. It allows you to actively think about the possible test cases, formulate classes and methods, and design code structure more gracefully.

In this article, I will refer to Heaton Cai’s framework to demonstrate a TDD process using an example I encountered in my work.

Preparation

1) Define the Scenarios

In my new project, I will design an application which is a cache service for my front end application. The cache service basically does two things: store data from frontend application and retrieve data from backend database.

People can use different Java cache libraries to query, store and look up data. In my case, I will start with Jedis library which is Java client for Redis cache. In the future, there might be other cache services to be added, such as GemFire. However, no matter which cache library to use, the functionalities are almost the same — data storage, search, query, save and removal.

2) Design the Interface

Interface should focus on the high-level problems, instead of specific implementations. Hence, the following functionalities are the basic methods we should have. While we are thinking of interface method, we should consider the optimal inputs and outputs of the functions:

  1. String getData(String key, String field);
  2. boolean ifDataExists(String key, String field);
  3. void deleteData(String key, String field);
  4. void saveData(String key, String field);

3) Design Test Cases

In my case, I take the scenario of retrieval of backend data as an example. Then I have a few cases:

  1. The data could be a null value
  2. The data could be a valid string
  3. The data could be non-existent

As mentioned in Heaton’s article, we do not have to be so specific and well-rounded about the test cases as test cases could be changed if we want to drive code or refactor code later.

Coding

1. Write the First Test

As a start, we write a test to get the correct result from our cache. Currently my project is written in Java and I am using JUnit5 as my test framework. In the below code, I have a rough design of the classes I should create.

@Test
public void testGetOrderDetailsWithExistingValue(){
CacheOrderService cacheOrderService = new CacheOrderServiceImpl();
Optional<OrderDTO> result = cacheOrderService.getOrderDetails("1");
assertEquals(result.value, "MyOrderDetails")
}

First we have a class cacheOrderServiceImpl which is an implementation class of a service interface cacheOrderService. It implements a method called getOrderDetails with a parameter being a string called orderId. Next we have a OrderDTO class which is a customized data transfer object storing my required fields. For testing purpose, I passed in an orderId “1”. Since the result we get could be non-existent, an Optional class is used. Since I am testing for an existing value, the result should contain something and result.value is used. And again for testing, I make it assert to “MyOrderDetails” string.

2. Write the Functionalities

In order to fulfill the test case, I write a method as below in CacheOrderServiceImpl class.

private String myOrderTable = "MyOrderTable";public Optional<OrderDTO> getOrderDetails(String orderId) {
CacheManager cacheManager = new cacheManager("myHost", "myPort");
final String result = cacheManager.getCache(myOrderTable, orderId);
final OrderDTO orderDTO = convertStringToOrder(result);
return Optional.ofNullable(orderDTO);
}

We first have a mock order table of string “MyOrderTable”. The method accepts a parameter called orderId. Inside the method, we instantiate a class CacheManager. The CacheManager focuses on operations done with regards to cache database (using Jedis library). After connection is established with cache server, we can then use getCache method from CacheManager to return a string result first because Redis will store the object data as string. Then, we need a helper method to convert the string into the object we want, OrderDTO. Finally, we return an Optional object with orderDTO value.

3. Refactor and Scale

Lastly, we need to refactor the code to make it clean and scalable. Noticed that the getOrderDetails method resides within CacheOrderServiceImpl class. We need to have an interface class CacheOrderService which CacheOrderServiceImpl class implements. In the interface class, we can specify the general methods we want to deal with the orders such as ifOrderExists, deleteOrder and saveOrder.

Furthermore, mentioned in Step 2 above, we have a CacheManager class. The difference between CacheManager and CacheOrderService is that a CacheManager deals with cache operations whereas CacheOrderService deals with order-specific operations. Whenever we need to deal with orders, we have to instantiate a CacheManager instance to manipulate data in the cache. If we follow the code above, with every order method we created, we are instantiating a new CacheManager class, which is redundant, and takes up heap space within JVM. Hence, we need to make sure that only one instance is used throughout our order processing.

private final CacheManager cacheManager;
private String myOrderTable = "MyOrderTable";

public CacheOrderServiceImpl(CacheManager cacheManager){
this.cacheManager = cacheManager;
}

public Optional<OrderDTO> getOrderDetails(String orderId) {
final String result = cacheManager.getCache(myOrderTable, orderId);
final OrderDTO orderDTO = convertStringToOrder(result);
return Optional.ofNullable(orderDTO);
}

To avoid multiple instances of CacheOrderService, we can also apply Factory and Singleton design patterns. For more details, check my previous article on it.

Repeat

As mentioned by Heaton, we write test cases one by one — finish one until code refactoring and start with the next one again. We’ve just listed one of the test cases from preparation phase in this article and there are more test cases and functionalities we need to build and they all should follow a similar pattern.

I hope you can get some insights from this article if you are not yet familiar with TDD process. It’s already a technical trend in the industry and I am sure it is a beneficial practice for your own projects.

I hope you find this article helpful. If you are like me who’s aspiring to learn something related to technology or reflect on work and life on a regular basis, follow my channel and stay updated to my inspirations from my daily work and life.

--

--

DN Tech
Geek Culture

Backend Software Engineer who shares about my daily work bits