Business Layer 테스트(1)

qkdk·2024년 2월 1일
0

TDD

목록 보기
5/12

OrderService

createOrder 구현

구현과정

  1. createOrder 메서드 생성
  2. createOrder에 필요한 request, response dto 생성
  3. return을 null로 하고 임시로 테스트 구현 -> Red, Green, Blue 중 Red 과정
  4. 테스트 코드 실패 확인
  5. Green(로직 구현)중 productRepository의 새로운 기능 요구(ProductNumber로 Product 찾기)
  6. productRepository에 기능을 구현하고 테스트 진행
  7. creatOrder구현중 정적 팩토리 메서드를활용한 생성 기능 요구
  8. status, totalPrice, registerTime에 관한 테스트 코드 작성 (Red)
  9. registerTime이 테스트 하기 힘듬을 확인하고 외부에서 parameter로 값 전달
  10. registerTime과 관련된 모든 로직 변경 -> 컨트롤러에서 주입을 받을수 있도록

OrderService

@RequiredArgsConstructor
@Service
public class OrderService {

    private final ProductRepository productRepository;
    private final OrderRepository orderRepository;

    public OrderResponse createOrder(OrderCreateRequest request, LocalDateTime registeredDateTime) {
        List<String> productNumbers = request.getProductNumbers();
        // Product를 조회하는 로직
        List<Product> products = productRepository.findAllByProductNumberIn(
            productNumbers);

        Order order = Order.create(products, registeredDateTime);
        Order savedOrder = orderRepository.save(order);

        return OrderResponse.of(savedOrder);
    }
}


@ActiveProfiles("test")
@SpringBootTest
class OrderServiceTest {

    @Autowired
    private OrderService orderService;
    @Autowired
    private ProductRepository productRepository;

    @DisplayName("주문번호 리스르를 받아 주문을 생성한다")
    @Test
    void createOrder() {
        // given
        Product product1 = createProduct(HANDMADE, "001", 1000);
        Product product2 = createProduct(HANDMADE, "002", 3000);
        Product product3 = createProduct(HANDMADE, "003", 5000);
        productRepository.saveAll(List.of(product1, product2, product3));

        OrderCreateRequest request = OrderCreateRequest.builder()
            .productNumbers(List.of("001", "002"))
            .build();

        LocalDateTime registeredDateTime = LocalDateTime.now();
        // when
        OrderResponse orderResponse = orderService.createOrder(request, registeredDateTime);

        // then
        assertThat(orderResponse.getId()).isNotNull();
        
        assertThat(orderResponse)
            .extracting("registeredDateTime", "totalPrice")
            .contains(registeredDateTime, 4000);
        
        assertThat(orderResponse.getProducts())
            .hasSize(2)
            .extracting("productNumber", "price")
            .containsExactlyInAnyOrder(
                tuple("001", 1000),
                tuple("002", 3000)
            );
    }

    private static Product createProduct(ProductType type, String productNumber, int price) {

        return Product.builder()
            .productNumber(productNumber)
            .type(type)
            .sellingStatus(SELLING)
            .name("메뉴 이름")
            .price(price)
            .build();
    }
}

리스트를 테스트 하는 팁
1. hasSize를 이용해 갯수가 정확한지 확인
2. assertJ로 메서드 체이닝 이용해서 다음 테스트 로직 작성
3. extracting을 활용해 테스트에 필요한 필드만 분리
4. contains, containsExactly, containsExactlyInAnyOrder등,,, 활용해서 검사

Order

@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Table(name = "orders")
@Entity
public class Order extends BaseEntity {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Enumerated(EnumType.STRING)
    private OrderStatus orderStatus;

    private int totalPrice;

    private LocalDateTime registeredDateTime;

    @OneToMany(mappedBy = "order", cascade = CascadeType.ALL)
    private List<OrderProduct> orderProducts = new ArrayList<>();

    public Order(List<Product> products, LocalDateTime registeredDateTime) {
        this.orderStatus = OrderStatus.INIT;
        this.totalPrice = calculateTotalPrice(products);
        this.registeredDateTime = registeredDateTime;
        this.orderProducts = products.stream()
            .map(product -> new OrderProduct(this, product))
            .collect(Collectors.toList());
    }

    public static Order create(List<Product> products, LocalDateTime registeredDateTime) {
        return new Order(products, registeredDateTime);
    }

    private int calculateTotalPrice(List<Product> products) {
        return products.stream()
            .mapToInt(Product::getPrice)
            .sum();
    }
}

class OrderTest {

    @DisplayName("주문 생성 시 주문등록 시간을 기록한다.")
    @Test
    void registeredDateTime() {
        LocalDateTime registeredDateTime = LocalDateTime.now();
        // given
        List<Product> products = List.of(
            createProduct("001", 1000),
            createProduct("002", 2000));

        // when
        Order order = Order.create(products, registeredDateTime);

        // then
        assertThat(order.getRegisteredDateTime()).isEqualTo(registeredDateTime);
    }


    @DisplayName("주무 생성시 주문상태는 INIT이다.")
    @Test
    void init() {
        // given
        List<Product> products = List.of(
            createProduct("001", 1000),
            createProduct("002", 2000));

        // when
        Order order = Order.create(products, LocalDateTime.now());

        // then
        assertThat(order.getOrderStatus()).isEqualByComparingTo(OrderStatus.INIT);
    }

    @DisplayName("주문 생성 시상품 리스트에서 주문의 총 금액을 계산한다.")
    @Test
    void calculateTotalPrice() {
        // given
        List<Product> products = List.of(
            createProduct("001", 1000),
            createProduct("002", 2000));

        // when
        Order order = Order.create(products, LocalDateTime.now());

        // then
        assertThat(order.getTotalPrice()).isEqualTo(3000);
    }

    private static Product createProduct(String productNumber, int price) {

        return Product.builder()
            .productNumber(productNumber)
            .type(HANDMADE)
            .sellingStatus(SELLING)
            .name("메뉴 이름")
            .price(price)
            .build();
    }
}

OrderTest구현중 LocalDateTime.now() 로 인해 테스트하기 어려운 상황 발생
Order 로직부터 Parameter로 registerTime을 입력받도록 설정해 문제를 해결
Blue단계에서 메서드 분할 리펙토링 진행후 다시 테스트(calculateTotalPrice)

Enum 타입은 isEqualByComparingTo를 이용해 비교를 진행한다.

ProductRepository

public interface ProductRepository extends JpaRepository<Product, Long> {

    /**
     * select * from product where selling_status in ('SELLING', 'HOLD');
     */
    List<Product> findAllBySellingStatusIn(List<ProductSellingStatus> sellingStatuses);

    List<Product> findAllByProductNumberIn(List<String> productNumbers);

}

@ActiveProfiles("test")
@SpringBootTest
class ProductRepositoryTest {

    @Autowired
    private ProductRepository productRepository;

    @DisplayName("원하는 판매 상태를 가지는 상품을 조회한다.")
    @Test
    void findAllBySellingStatusIn() {
        // given
        Product product1 = Product.builder()
            .productNumber("001")
            .type(HANDMADE)
            .sellingStatus(SELLING)
            .name("아메리카노")
            .price(4000)
            .build();

        Product product2 = Product.builder()
            .productNumber("002")
            .type(HANDMADE)
            .sellingStatus(HOLD)
            .name("카페라떼")
            .price(4500)
            .build();

        Product product3 = Product.builder()
            .productNumber("003")
            .type(HANDMADE)
            .sellingStatus(STOP_SELLING)
            .name("팥빙수")
            .price(7000)
            .build();

        productRepository.saveAll(List.of(product1, product2, product3));

        // when
        List<Product> products = productRepository.findAllBySellingStatusIn(
            List.of(HOLD, SELLING));

        // then
        assertThat(products)
            .hasSize(2)
            .extracting("productNumber", "name", "sellingStatus")
            .containsExactlyInAnyOrder(
                tuple("001", "아메리카노", SELLING),
                tuple("002", "카페라떼", HOLD)
            );
    }

    @DisplayName("상품번호 리스트로 상품을 조회한다.")
    @Test
    void findAllByProductNumberIn() {
        // given
        Product product1 = Product.builder()
            .productNumber("001")
            .type(HANDMADE)
            .sellingStatus(SELLING)
            .name("아메리카노")
            .price(4000)
            .build();

        Product product2 = Product.builder()
            .productNumber("002")
            .type(HANDMADE)
            .sellingStatus(HOLD)
            .name("카페라떼")
            .price(4500)
            .build();

        Product product3 = Product.builder()
            .productNumber("003")
            .type(HANDMADE)
            .sellingStatus(STOP_SELLING)
            .name("팥빙수")
            .price(7000)
            .build();

        productRepository.saveAll(List.of(product1, product2, product3));

        // when
        List<Product> products = productRepository.findAllByProductNumberIn(
            List.of("001", "002"));

        // then
        assertThat(products)
            .hasSize(2)
            .extracting("productNumber", "name", "sellingStatus")
            .containsExactlyInAnyOrder(
                tuple("001", "아메리카노", SELLING),
                tuple("002", "카페라떼", HOLD)
            );
    }
}

기능 구현중 새로운 기능이 요구되면 그 기능에 대한 단위 테스트를 먼저 작성

중복 구현 테스트

Red

    @DisplayName("중복되는 상품번호 리스트로 주문을 생성할 수 잇다.")
    @Test
    void createOrderWithDuplicateProductNumber() {
        // given
        Product product1 = createProduct(HANDMADE, "001", 1000);
        Product product2 = createProduct(HANDMADE, "002", 3000);
        Product product3 = createProduct(HANDMADE, "003", 5000);
        productRepository.saveAll(List.of(product1, product2, product3));

        OrderCreateRequest request = OrderCreateRequest.builder()
            .productNumbers(List.of("001", "001"))
            .build();

        LocalDateTime registeredDateTime = LocalDateTime.now();
        // when
        OrderResponse orderResponse = orderService.createOrder(request, registeredDateTime);

        // then
        assertThat(orderResponse.getId()).isNotNull();

        assertThat(orderResponse)
            .extracting("registeredDateTime", "totalPrice")
            .contains(registeredDateTime, 2000);

        assertThat(orderResponse.getProducts())
            .hasSize(2)
            .extracting("productNumber", "price")
            .containsExactlyInAnyOrder(
                tuple("001", 1000),
                tuple("001", 1000)
            );
    }

Green

    public OrderResponse createOrder(OrderCreateRequest request, LocalDateTime registeredDateTime) {
        List<String> productNumbers = request.getProductNumbers();
        // Product를 조회하는 로직
        List<Product> products = productRepository.findAllByProductNumberIn(
            productNumbers);

        Map<String, Product> productMap = products.stream()
            .collect(Collectors.toMap(Product::getProductNumber, p -> p));

        List<Product> duplicateProducts = productNumbers.stream()
            .map(productMap::get)
            .toList();

        Order order = Order.create(duplicateProducts, registeredDateTime);
        Order savedOrder = orderRepository.save(order);

        return OrderResponse.of(savedOrder);
    }

정상적으로 동작은 되나 코드가 정신없어 보이고 이해하기 쉽지 않다

Blue

    public OrderResponse createOrder(OrderCreateRequest request, LocalDateTime registeredDateTime) {
        List<String> productNumbers = request.getProductNumbers();
        // Product를 조회하는 로직
        List<Product> products = findProductsBy(productNumbers);

        Order order = Order.create(products, registeredDateTime);
        Order savedOrder = orderRepository.save(order);

        return OrderResponse.of(savedOrder);
    }

    private List<Product> findProductsBy(List<String> productNumbers) {
        List<Product> products = productRepository.findAllByProductNumberIn(
            productNumbers);

        Map<String, Product> productMap = products.stream()
            .collect(Collectors.toMap(Product::getProductNumber, p -> p));

        return productNumbers.stream()
            .map(productMap::get)
            .toList();
    }

메서드 분할을 통해 가독성이 좋아진 코드의 모습

  • 테스트 코드를 재실행 함으로써 코드의 무결성을 검사할 수 있다.

오류

    @DisplayName("중복되는 상품번호 리스트로 주문을 생성할 수 잇다.")
    @Test
    void createOrderWithDuplicateProductNumber() {
        // given
        Product product1 = createProduct(HANDMADE, "001", 1000);
        Product product2 = createProduct(HANDMADE, "002", 3000);
        Product product3 = createProduct(HANDMADE, "003", 5000);
        productRepository.saveAll(List.of(product1, product2, product3));

	...}

    @DisplayName("주문번호 리스르틀 받아 주문을 생성한다")
    @Test
    void createOrder() {
        // given
        Product product1 = createProduct(HANDMADE, "001", 1000);
        Product product2 = createProduct(HANDMADE, "002", 3000);
        Product product3 = createProduct(HANDMADE, "003", 5000);
        productRepository.saveAll(List.of(product1, product2, product3));
        
    ...}

두개의 테스트가 동일한 Repository를 사용하는데 같은키를 입력해 중복 오류가 발생한다.

데이터 클랜징 작업을 추가한다.

    @AfterEach
    void tearDown() {
        orderProductRepository.deleteAllInBatch();
        productRepository.deleteAllInBatch(); 
        orderRepository.deleteAllInBatch();
    }

DataJpaTest vs SrpingBootTest

  1. DataJpaTest는 @Transactional 어노테이션이 할당되어 있어서 자동적으로 테스트가 끝날때마다 rollback이 이루어진다.
  2. DataJpaTest는 Jpa 관련 빈만 등록하므로 빠르다.
그렇다면 @Transactional 어노테이션을 Test 클래스에 할당한다면?
  • 자동적으로 롤백이 이루어진다.
  • Service로직에 @Transactional이 없어도 @Transacional이 있는것처럼 테스트 코드가 작동한다.
    • Service로직에 Transactional설정이 없으면 Jpa가 변경감지를 하지못해 Update 문이 발생하지 않는다.
    • 하지만 Test문에 Transactional 어노테이션이 붙어있으면 Jpa가 변경감지가 되어서 오류를 찾기 힘들어진다.
profile
qkdk

0개의 댓글

관련 채용 정보