PromleeBlog
sitemap
aboutMe

posting thumbnail
테스트 관점에서 본 코드 구조 개선 - Spring Boot로 시작하는 TDD 11편
Improving Code Structure from a Testing Perspective - TDD with Spring Boot Part 11

📅

🚀

들어가기 전에 🔗

지난 10편에서는 유효성 검사를 통해 잘못된 입력값을 막아내는 방법을 배웠습니다.

하지만 개발을 하다 보면,
코드는 짠 것 같은데 도저히 테스트를 못 만들겠는 상황
을 마주하게 됩니다.
주로
현재 시간
,
랜덤 값
,
외부 시스템
등이 코드 깊숙이 숨어 있을 때 발생합니다.

오늘은
테스트하기 어려운 코드
가 무엇인지 알아보고, 이를
테스트하기 쉬운 구조
로 개선하는 리팩토링 과정을 다뤄보겠습니다.

🚀

테스트하기 어려운 코드란? 🔗

새로운 요구사항이 추가되었다고 가정해 봅시다.
요구사항:
상품 등록은 '영업 시간(09:00 ~ 18:00)'에만 가능하다.
가장 쉬운 구현 방법은 Service 안에서 현재 시간을 체크하는 것입니다.
하지만 이렇게 짜면 큰일이 납니다. 왜일까요?

[나쁜 예] 숨겨진 의존성 (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("영업 시간이 아닙니다.");
        }
        
        // ... (저장 로직)
    }
}
이 코드는
제어할 수 없는 값(현재 시간)
이 내부에 숨어있어서, 우리가 원하는 대로 테스트할 수가 없습니다.
이것을
테스트하기 어려운 코드
라고 부릅니다.

🚀

테스트하기 쉬운 코드로 리팩토링 🔗

해결책은 간단합니다.
제어할 수 없는 값을
외부에서 주입(Injection)
받으면 됩니다.
Service가 스스로 시간을 확인하지 말고, "지금 몇 시야?"라고 파라미터로 물어보게 만드는 것이죠.

1. Service 코드 수정 🔗

ProductServiceregister 메서드가 현재 시간을 파라미터로 받도록 수정합니다.
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. Controller 수정 🔗

Service의 메서드 모양(Signature)이 바뀌었으니, 이를 호출하는 Controller도 수정해야 합니다.
Controller에서 현재 시간을 만들어서 넘겨줍니다.
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());
    }
}

🚀

결정론적 테스트 작성 (Deterministic Test) 🔗

이제 우리는 시간이라는 변수를 마음대로 조종할 수 있습니다.
언제 테스트를 실행하든 항상 성공하는
결정론적(Deterministic) 테스트
를 작성해 보겠습니다.

테스트 케이스 🔗

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("영업 시간이 아닙니다.");
    }
}

🚀

테스트 실행 및 결과 분석 🔗

이제 작성한 테스트를 실행하고 결과를 확인해 봅니다.
Service 테스트는 Mockito 기반의 단위 테스트이므로 실행 속도가 매우 빠릅니다.

1. 테스트 방법 🔗

IDE(IntelliJ 등)에서 ProductServiceTest 클래스 옆의 재생 버튼(Run)을 클릭합니다.

2. 예상 결과 🔗

테스트 결과
테스트 결과

3. Controller 테스트 🔗

마지막으로 Controller 테스트까지 수정하여 완벽하게 마무리합시다.
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));
    }
}

🚀

결론 🔗

오늘은 테스트하기 어려운 코드를 테스트하기 쉽게 개선하는 방법을 배웠습니다.


이제 여러분은 어떤 난해한 코드를 만나더라도 "이걸 밖에서 주입받으면 테스트할 수 있겠는데?"라는 시각을 갖게 되었습니다.
다음 시간에는
기능 확장 시 TDD 적용 흐름
을 통해, 이미 완성된 코드에 새로운 요구사항을 얹을 때 TDD가 얼마나 강력한지 체험해 보겠습니다.

참고 🔗