PromleeBlog
sitemap
aboutMe

posting thumbnail
기능 확장 시 TDD 적용 흐름 - Spring Boot로 시작하는 TDD 12편
Flow of Applying TDD When Extending Functionality - TDD with Spring Boot Part 12

📅

🚀

들어가기 전에 🔗

우리는 지금까지 상품을 등록하고, 조회하고, 유효성을 검사하는 기능을 만들었습니다.
잘 돌아가는 코드에 손을 대는 것은 언제나 두려운 일입니다.
"이거 고쳤다가 아까 만든 기능이 안 되면 어떡하지?" 하는 걱정 때문입니다.

하지만 TDD와 함께라면 쉽게 극복할 수 있습니다.
우리가 만들어둔 촘촘한 테스트 코드가
안전망(Safety Net)
역할을 해주기 때문입니다.
오늘은 새로운 할인 정책을 추가하면서, 기존 코드를 망가뜨리지 않고 기능을 확장하는 흐름을 배워보겠습니다.

🚀

새로운 요구사항 분석 🔗

기획자에게서 새로운 요청이 왔습니다.
할인 정책 추가
상품 가격이 30,000원 이상이면, 1,000원을 자동으로 할인해 주세요.
(예: 35,000원 입력 -> 34,000원으로 저장)
이 기능을 구현하기 위해
Service
if 문을 넣을 수도 있지만, 우리는 도메인 중심 설계를 배웠으므로
Product(도메인)
객체 스스로가 할인을 결정하도록 만들겠습니다.

🚀

1단계: 실패하는 테스트 작성 (Red) 🔗

가장 먼저 할 일은 Product 객체를 테스트하는 것입니다.
아직 할인 로직은 없지만, 할인이 적용되어야 한다고 우기는 테스트를 먼저 짭니다.
src/test/java/com/example/tdd_study/product/ProductTest.java
package com.example.tdd_study.product;
 
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
 
class ProductTest {
 
    @Test
    void 가격이_3만원_이상이나면_1000원_할인된다() {
        // given
        String name = "비싼 커피";
        int originalPrice = 35000;
        int expectedPrice = 34000; // 1000원 할인 기대
 
        System.out.println("[Test Start] 할인 정책 테스트 시작");
        System.out.println("입력 가격: " + originalPrice);
 
        // when
        Product product = new Product(name, originalPrice);
 
        // then
        System.out.println("검증 결과: 실제 저장된 가격 = " + product.getPrice());
        
        assertThat(product.getPrice()).isEqualTo(expectedPrice);
        System.out.println("[Test Pass] 테스트 성공! 할인이 정상 적용됨");
    }
 
    @Test
    void 가격이_3만원_미만이면_할인이_없다() {
        // given
        int price = 20000;
        
        // when
        Product product = new Product("일반 커피", price);
 
        // then
        assertThat(product.getPrice()).isEqualTo(price);
    }
}
이 테스트를 실행하면 당연히
실패(Red)
합니다.
아직 로직을 구현하지 않았기 때문에 35000원이 그대로 저장되기 때문입니다.
콘솔에는 검증 결과: 실제 저장된 가격 = 35000이라고 찍히며 기대값(34000)과 다르다고 에러가 날 것입니다.
RED ERROR
RED ERROR

🚀

2단계: 기능 구현 (Green) 🔗

이제 테스트를 통과시키기 위해 Product 클래스를 수정합니다.
생성자 내부에서 가격을 판단하여 할인을 적용합니다.
src/main/java/com/example/tdd_study/product/Product.java
package com.example.tdd_study.product;
 
import jakarta.persistence.*;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;
import org.springframework.util.Assert;
 
@Entity
@Table(name = "products")
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Product {
 
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private String name;
    private int price;
 
    public Product(String name, int price) {
        Assert.hasText(name, "상품명은 필수입니다.");
        Assert.isTrue(price > 0, "상품 가격은 0보다 커야 합니다.");
        Assert.isTrue(price % 1000 == 0, "상품 가격은 1000원 단위여야 합니다.");
 
        // [추가] 할인 정책 적용
        if (price >= 30000) {
            System.out.println("[Logic] 3만원 이상 감지! 1000원 할인 적용함.");
            price = price - 1000;
        }
 
        this.name = name;
        this.price = price;
    }
}
이제 다시 ProductTest를 실행해 봅니다.

실행 결과 로그 분석 🔗

실행 결과 로그
실행 결과 로그
[Test Start] 할인 정책 테스트 시작
입력 가격: 35000
[Logic] 3만원 이상 감지! 1000원 할인 적용함.
검증 결과: 실제 저장된 가격 = 34000
[Test Pass] 테스트 성공! 할인이 정상 적용됨
로그를 보니 입력값(35000)이 로직을 거쳐(Logic 로그 출력) 최종값(34000)으로 변하는 과정이 한눈에 보입니다.
테스트는
성공(Green)
했습니다.

🚀

3단계: 기존 테스트 점검 (Regression Test) 🔗

여기서 끝내면 안 됩니다.
우리가 코드를 수정했기 때문에,
혹시라도 기존 기능이 망가지지 않았는지
확인해야 합니다.
이것을
회귀 테스트(Regression Test)
라고 합니다.

Service 테스트 수정 🔗

할인 정책이 적용된 것을 반영하여 통합 테스트도 업데이트해 줍니다.
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.ArgumentCaptor;
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.assertThat;
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 // [중요] 이 부분이 없어서 NullPointerException이 발생했습니다.
    private NotificationCenter notificationCenter;
 
    @InjectMocks
    private ProductService productService;
 
    @Test
    void 상품_등록시_고가_상품은_할인되어_저장된다() {
        // given
        String name = "고급 시계";
        int inputPrice = 40000; // 3만원 이상
        LocalDateTime fixedTime = LocalDateTime.of(2025, 1, 1, 12, 0);
 
        System.out.println(">>> Service 테스트 시작: " + name + ", 가격: " + inputPrice);
 
        // when
        productService.register(name, inputPrice, fixedTime);
 
        // then
        // Repository.save()가 호출될 때, 넘겨진 Product 객체를 낚아챕니다(Capture).
        ArgumentCaptor<Product> productCaptor = ArgumentCaptor.forClass(Product.class);
        verify(productRepository).save(productCaptor.capture());
 
        // [추가] 알림 발송도 검증 (여기서 NPE가 났었음)
        verify(notificationCenter).send(any(String.class));
 
        Product savedProduct = productCaptor.getValue();
 
        System.out.println(">>> Repository에 저장된 가격: " + savedProduct.getPrice());
 
        // 40000원이 아닌 39000원이 저장되어야 정상 (1000원 할인)
        assertThat(savedProduct.getPrice()).isEqualTo(39000);
        System.out.println(">>> Service 테스트 통과 완료");
    }
 
    // 기존 테스트 (영업시간 관련)도 함께 유지해야 합니다.
    @Test
    void 영업시간_외에는_상품_등록이_불가능하다() {
        // given
        String name = "아메리카노";
        int price = 4000;
        LocalDateTime fixedTime = LocalDateTime.of(2025, 1, 1, 1, 0); // 새벽 1시
 
        System.out.println(">>> 영업시간 외 테스트 시작 (시간: " + fixedTime + ")");
 
        // when & then
        assertThatThrownBy(() -> productService.register(name, price, fixedTime))
                .isInstanceOf(IllegalStateException.class)
                .hasMessage("영업 시간이 아닙니다.");
 
        System.out.println(">>> 예외 발생 확인 완료");
    }
}

실행 결과 로그 분석 🔗

실행 결과
실행 결과
>>> Service 테스트 시작: 고급 시계, 가격: 40000
[Logic] 3만원 이상 감지! 1000원 할인 적용함.
>>> Repository에 저장된 가격: 39000
>>> Service 테스트 통과 완료
이렇게 로그를 통해
Product
의 내부 로직이
Service
의 흐름 속에서도 잘 동작하고 있음을 명확하게 확인할 수 있습니다.

🚀

결론 🔗

오늘은 이미 운영 중인 코드에 새로운 기능을 안전하게 추가하는 방법을 배웠습니다.


테스트 코드가 있다면 어떤 복잡한 요구사항이 들어와도 두렵지 않습니다.

다음 시간에는
단위 테스트와 통합 테스트의 역할
을 비교하며, 언제 어떤 테스트를 짜야 효율적인지 정리하는 시간을 갖겠습니다.

참고 🔗