In Part 10, we learned how to use validation to reject invalid inputs.
While developing, you may find that
the code is written but you cannot figure out how to test it
. This often happens when the
current time
,
random values
, or
external systems
are buried deep inside the code.
Today we will look at what makes
code difficult to test
and refactor it into a
structure that is easier to test
.
What Makes Code Difficult to Test? ๐
Suppose we receive a new requirement.
Requirement:
Products can only be registered during business hours (09:00โ18:00).
The simplest implementation is to check the current time inside the service.
But that approach causes a problem. Why?
Bad Example: A Hidden Dependency ๐
src/main/java/com/example/tdd_study/product/BadProductService.java package com.example.tdd_study.product;
import java.time.LocalDateTime;
public class BadProductService {
public void register (String name , int price ) {
// ... (์๋ต)
// [๋ฌธ์ ์ ] ๋ฉ์๋ ๋ด๋ถ์์ 'ํ์ฌ ์๊ฐ'์ ์ง์ ์์ฑํ๊ณ ์์ต๋๋ค.
// ์ด๋ฌ๋ฉด ํ
์คํธ๋ฅผ ์คํํ๋ ์๊ฐ์ ๋ฐ๋ผ ๊ฒฐ๊ณผ๊ฐ ๋ฌ๋ผ์ง๋๋ค.
// ๋ฐค์ ํ
์คํธ๋ฅผ ๋๋ฆฌ๋ฉด ๋ฌด์กฐ๊ฑด ์คํจํ๊ฒ ๋ฉ๋๋ค!
LocalDateTime now = LocalDateTime. now ();
if (now. getHour () < 9 || now. getHour () >= 18 ) {
throw new IllegalStateException ( "์์
์๊ฐ์ด ์๋๋๋ค." );
}
// ... (์ ์ฅ ๋ก์ง)
}
}
The
current time is an uncontrollable value
hidden inside this code, so we cannot test the code on our own terms.
That is what makes it
difficult to test
.
Refactor the Code for Testability ๐
The solution is simple.
Have the service receive values it cannot control
through injection
. Instead of looking up the time itself, the service asks the caller what time it is through a parameter.
1. Update the Service ๐
Change the register method on ProductService to accept the current time as a parameter.
src/main/java/com/example/tdd_study/product/ProductService.java package com.example.tdd_study.product;
import com.example.tdd_study.notification.NotificationCenter;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
@ Service
public class ProductService {
private final ProductRepository productRepository;
private final NotificationCenter notificationCenter;
public ProductService (ProductRepository productRepository ,
NotificationCenter notificationCenter ) {
this .productRepository = productRepository;
this .notificationCenter = notificationCenter;
}
@ Transactional
// [๋ณ๊ฒฝ] LocalDateTime์ ํ๋ผ๋ฏธํฐ๋ก ๋ฐ๋๋ก ๋ณ๊ฒฝ (์์กด์ฑ ์ฃผ์
)
public void register (String name , int price , LocalDateTime now ) {
Product product = new Product (name, price);
// [๊ฒ์ฆ] ์ด์ ํ
์คํธ ์ฝ๋์์ ์ํ๋ ์๊ฐ์ ๋ง์๋๋ก ๋ฃ์ ์ ์์ต๋๋ค.
if (now. getHour () < 9 || now. getHour () >= 18 ) {
throw new IllegalStateException ( "์์
์๊ฐ์ด ์๋๋๋ค." );
}
productRepository. save (product);
notificationCenter. send ( "์๋ก์ด ์ํ์ด ๋ฑ๋ก๋์์ต๋๋ค: " + name);
}
}
2. Update the Controller ๐
The service's method signature has changed, so we also need to update the controller that calls it. The controller obtains the current time and passes it in.
src/main/java/com/example/tdd_study/product/ProductController.java package com.example.tdd_study.product;
import com.example.tdd_study.product.dto.AddProductRequest;
import jakarta.validation.Valid;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;
import java.time.LocalDateTime;
@ RestController
public class ProductController {
private final ProductService productService;
public ProductController (ProductService productService ) {
this .productService = productService;
}
@ PostMapping ( "/products" )
public void register (@ RequestBody @ Valid AddProductRequest request ) {
// [๋ณ๊ฒฝ] Controller๊ฐ ํ์ฌ ์๊ฐ์ ์์ฑํด์ Service์ ๋๊ฒจ์ค๋๋ค.
productService. register (request. getName (), request. getPrice (), LocalDateTime. now ());
}
}
Write Deterministic Tests ๐
We can now control the time supplied to the service.
Let's write
deterministic tests
that pass consistently regardless of when we run them.
Success case:
A registration request at noon should succeed.
Failure case:
A registration request at 1 a.m. should fail.
src/test/java/com/example/tdd_study/product/ProductServiceTest.java package com.example.tdd_study.product;
import com.example.tdd_study.notification.NotificationCenter;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.time.LocalDateTime;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.verify;
@ ExtendWith (MockitoExtension.class)
class ProductServiceTest {
@ Mock
private ProductRepository productRepository;
@ Mock
private NotificationCenter notificationCenter;
@ InjectMocks
private ProductService productService;
@ Test
void ์์
์๊ฐ_๋ด์๋_์ํ_๋ฑ๋ก์ด_๊ฐ๋ฅํ๋ค () {
// given
String name = "์๋ฉ๋ฆฌ์นด๋
ธ" ;
int price = 4000 ;
// [ํต์ฌ] ํ
์คํธ๋ฅผ ์ํด '๋ฎ 12์'๋ผ๋ ๊ณ ์ ๋ ์๊ฐ์ ๋ง๋ญ๋๋ค.
LocalDateTime fixedTime = LocalDateTime. of ( 2025 , 1 , 1 , 12 , 0 );
// when
productService. register (name, price, fixedTime);
// then
verify (productRepository). save ( any (Product.class));
}
@ Test
void ์์
์๊ฐ_์ธ์๋_์ํ_๋ฑ๋ก์ด_๋ถ๊ฐ๋ฅํ๋ค () {
// given
String name = "์๋ฉ๋ฆฌ์นด๋
ธ" ;
int price = 4000 ;
// [ํต์ฌ] ํ
์คํธ๋ฅผ ์ํด '์๋ฒฝ 1์'๋ผ๋ ๊ณ ์ ๋ ์๊ฐ์ ๋ง๋ญ๋๋ค.
LocalDateTime fixedTime = LocalDateTime. of ( 2025 , 1 , 1 , 1 , 0 );
// when & then: ์์ธ๊ฐ ๋ฐ์ํ๋์ง ๊ฒ์ฆ
assertThatThrownBy (() -> productService. register (name, price, fixedTime))
. isInstanceOf (IllegalStateException.class)
. hasMessage ( "์์
์๊ฐ์ด ์๋๋๋ค." );
}
}
Run the Tests and Analyze the Results ๐
Run the tests and check the results.
The service tests are Mockito unit tests, so they run very quickly.
In your IDE, such as IntelliJ, click the Run button beside the ProductServiceTest class.
Test results
3. Update the Controller Test ๐
Finally, update the controller test to finish the change.
src/test/java/com/example/tdd_study/product/ProductControllerTest.java package com.example.tdd_study.product;
import com.example.tdd_study.product.dto.AddProductRequest;
import com.google.gson.Gson;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders;
import org.springframework.test.web.servlet.result.MockMvcResultHandlers; // ๋ก๊ทธ ์ถ๋ ฅ์ฉ
import org.springframework.test.web.servlet.result.MockMvcResultMatchers;
import java.time.LocalDateTime; // ์๊ฐ ํด๋์ค import
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.Mockito.verify;
@ WebMvcTest (ProductController.class)
class ProductControllerTest {
@ Autowired
private MockMvc mockMvc;
@ MockBean
private ProductService productService;
@ Test
void ์ํ_๋ฑ๋ก_์ฑ๊ณต () throws Exception {
// given
AddProductRequest request = new AddProductRequest ( "์๋ฉ๋ฆฌ์นด๋
ธ" , 4000 );
Gson gson = new Gson ();
String jsonContent = gson. toJson (request);
// when & then
mockMvc. perform (MockMvcRequestBuilders. post ( "/products" )
. contentType (MediaType.APPLICATION_JSON)
. content (jsonContent))
. andExpect (MockMvcResultMatchers. status (). isOk ())
. andDo (MockMvcResultHandlers. print ()); // [๋ก๊ทธ] ์์ฒญ/์๋ต ๋ด์ฉ ์ถ๋ ฅ
// [ํต์ฌ] Controller๊ฐ Service์ ์ด๋ค ํ๋ผ๋ฏธํฐ(์๊ฐ ํฌํจ)๋ฅผ ๋๊ธฐ๋์ง ๊ฒ์ฆ
// Controller ๋ด๋ถ์์ LocalDateTime.now()๋ฅผ ์ฐ๋ฏ๋ก, ์ ํํ ์๊ฐ๋ณด๋ค๋
// 'LocalDateTime ํ์
์ด ๋์ด๊ฐ๋์ง'๋ฅผ ํ์ธํฉ๋๋ค.
verify (productService). register ( anyString (), anyInt (), any (LocalDateTime.class));
}
}
Today we learned how to make difficult code easier to test.
The next time you encounter hard-to-test code, ask yourself whether injecting the value from outside would make it testable.
Next time, we will apply TDD when extending a feature and see how it helps when adding a new requirement to working code.