쌩로그

Practical Testing 실용적인 테스트 가이드 - Section 05. Spring & JPA 기반 테스트 (3) - Presentation Layer 본문

Spring/Test

Practical Testing 실용적인 테스트 가이드 - Section 05. Spring & JPA 기반 테스트 (3) - Presentation Layer

.쌩수. 2024. 1. 27. 00:14
반응형

목록

  1. 포스팅 개요
  2. 본론
      2-1. Presentation Layer 테스트
  3. 요약

1. 포스팅 개요

인프런에서 박우빈님의 Practical Testing: 실용적인 테스트 가이드 강의 섹션 5 Spring & JPA 기반 테스트 중 Presentation Layer(Controller 계층) 테스트 부분을 학습하며 정리한 포스팅이다.

| 참고 이전 포스팅

2. 본론

2-1. Presentation Layer 테스트

Presentation Layer

  • 외부 세계의 요청을 가장 먼저 받는 계층이다.
  • 파라미터에 대한 최소한의 검증을 수행한다.

우빈님은 Presentation Layer에서는 비즈니스로직보다 넘겨온 값들에 대한 Validation이 가장 중요한 거 같다고 하신다.
그래서 이를 중점으로 테스트를 작성해나가려 한다.

Presentation Layer를 어떻게 테스트를 할 것인가?

Persist나 Business Layer 같은 경우 통합 테스트를 진행했었다.
Presentation Layer를 테스트 할 때는 하위에 있는 두 레이어(Business, Persistence)를 Mocking처리를 하여 테스트를 진행한다.

Presentation Layer를 집중적으로 테스트한다는 것이다.
단위테스트 느낌으로 진행될 것이다.

Mock

  • 가짜라는 의미다.
  • 스프링을 사용하다보면 의존관게를 맺는 것이 참 많다.
    • 그런데 의존관계를 갖고 있는 것들이 테스트할 때는 계속 방해가 된다.
  • 테스트하고 싶은 것에 집중하지 못하고 테스트를 하기 위해서 준비해야 되는 것들이 많다.
  • 따라서 의존관계 맺는 것을 가짜로 처리하며, 잘 동작한다는 것을 가정하며 처리를 하고싶을 때 MockMvc를 사용한다.

MockMvc

  • 스프링에서 제공하는 테스트 프레임워크이다.
  • Mock(가짜) 객체를 사용스프링 MVC 동작을 재현할 수 있는 테스트 프레임워크다.

요구사항

테스트를 하기 위한 요구사항은 다음과 같다.

  • 관리자 페이지에서 판매할 상품을 등록하는 기능을 만들어보려고 한다.
    • 해당 상품에는 상품명, 타입, 판매상태, 가격을 정할 수 있다.

요구사항을 정리하면 다음과 같다.

  • 관리자 페이지에서 신규 상품을 등록할 수 있다.
  • 상품명, 상품 타입, 판매 상태, 가격 등을 입력받는다.

기존 Controller 내용이다.

import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RestController;
import sample.cafekiosk.spring.api.controller.product.dto.request.ProductCreateRequest;
import sample.cafekiosk.spring.api.service.product.ProductService;
import sample.cafekiosk.spring.api.service.product.response.ProductResponse;

import java.util.List;

@RestController
@RequiredArgsConstructor
public class ProductController {

    private final ProductService productService;


    @GetMapping("/api/v1/products/selling")
    public List<ProductResponse> getSellingProducts() {
        return productService.getSellingProducts();
    }
}

여기에 다음과 같은 메서드를 추가한다.

   @PostMapping("/api/v1/products/new")
   public void createProduct(ProductCreateRequest request) {
      productService.createProduct(request);

   }

서비스에서 createProduct(ProductCreateRequest request) 메서드를 작성해야한다.

기존의 Service클래스다.

import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import sample.cafekiosk.spring.api.controller.product.dto.request.ProductCreateRequest;
import sample.cafekiosk.spring.api.service.product.response.ProductResponse;
import sample.cafekiosk.spring.domain.Product;
import sample.cafekiosk.spring.domain.ProductRepository;
import sample.cafekiosk.spring.domain.ProductSellingStatus;

import java.util.List;
import java.util.stream.Collectors;


@RequiredArgsConstructor
@Service
public class ProductService {

    private final ProductRepository productRepository;


    public List<ProductResponse> getSellingProducts() {
        List<Product> products = productRepository.findAllBySellingStatusIn(ProductSellingStatus.forDisplay());

        return products.stream()
                .map(ProductResponse::of)
                .collect(Collectors.toList());
    }
}

여기서 createProduct() 메서드를 작성해야 한다.

   public void createProduct(ProductCreateRequest request) {
   }

다음과 같은 로직이 필요하다.

// productNumber
// 이전까지의 예 001 002 003 004
// DB에서 마지막 저장된 Product의 상품 번호를 읽어와서 +1
// 009였다면, 010으로

public void createProduct(ProductCreateRequest request) {
      // productNumber
      // 이전까지의 예 001 002 003 004
      // DB에서 마지막 저장된 Product의 상품 번호를 읽어와서 +1
      // 009 -> 010
      String latestProductNumber = productRepository.findLatestProductNumber();
}

productRepository의 findLatesProduct() 메서드로 최근 Number를 가져올 수 있어야 한다.

ProductRepository에서 findLatesProduct() 메서드를 작성해보도록 하자.

기존의 Repository는 다음과 같다.

import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.stereotype.Repository;

import java.util.List;

@Repository
public interface ProductRepository extends JpaRepository<Product, Long> {

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

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

}

기존의 productNumber를 알 수있어야 한다.
native Query를 사용할 것이다.

@Query(value = "select p.product_number from product p order by id desc limit 1", nativeQuery = true)
String findLatestProductNumber();

이와 같이 내림차순 정렬시 처음 것을 가져왔다.

그런데...
잘 동작하는지 테스트를 해보자.

기존의 given 쪽에서 빌더패턴을 사용하는 것들이 복잡하여 테스트 클래스 내에서 다음과 같이 메서드로 따로 빼놨다.

private Product createProduct(String productNumber, ProductType type, ProductSellingStatus sellingStatus, String name, int price) {
   Product product1 = Product.builder()
            .productNumber(productNumber)
            .type(type)
            .sellingStatus(sellingStatus)
            .name(name)
            .price(price)
            .build();
   return product1;
}

그리고 테스트는 다음과 같이 한다.

@Test
@DisplayName("가장 마지막으로 저장한 상품의 상품번호를 읽어온다.")
void findLatestProductNumber() {
   // given
   String targetProductNumber = "003";

   Product product1 = createProduct("001", HANDMADE, SELLING, "아메리카노", 4000);
   Product product2 = createProduct("002", HANDMADE, HOLD, "카페라떼", 4500);
   Product product3 = createProduct(targetProductNumber, HANDMADE, STOP_SELLING, "팥빙수", 7000);

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

   // when
   String latestProductNumber = productRepository.findLatestProductNumber();

   // then
   assertThat(latestProductNumber).isEqualTo(targetProductNumber);
}

테스트 결과는 다음과 같다.

native Query를 사용했는데,
native Query를 사용한 이유는 Repository의 구현내용에 관계없이 테스트를 작성해야하기 때문에 native Query로 작성했다.

메서드르 분리하더라도 다음과 같이 모든 테스트가 통과한다.

현재 기존의 테스트도 더한 코드는 다음과 같다.

import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
import org.springframework.test.context.ActiveProfiles;

import java.util.List;

import static org.assertj.core.api.Assertions.*;
import static sample.cafekiosk.spring.domain.ProductSellingStatus.*;
import static sample.cafekiosk.spring.domain.ProductType.*;

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

    @Autowired
    private ProductRepository productRepository;


    @Test
    @DisplayName("원하는 판매상태를 가진 상품들을 조회한다.")
    void findAllBySellingStatusIn() {
        // given
        Product product1 = createProduct("001",HANDMADE, SELLING, "아메리카노", 4000);
        Product product2 = createProduct("002", HANDMADE, HOLD, "카페라떼", 4500);
        Product product3 = createProduct("003", HANDMADE, STOP_SELLING, "팥빙수", 7000);

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

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

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

    @Test
    @DisplayName("상품번호 리스트로 상품들을 조회한다.")
    void findAllByProductNumberIn() {
        // given
        Product product1 = createProduct("001", HANDMADE, SELLING, "아메리카노", 4000);
        Product product2 = createProduct("002", HANDMADE, HOLD, "카페라떼", 4500);
        Product product3 = createProduct("003", HANDMADE, STOP_SELLING, "팥빙수", 7000);

        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)
                );
    }

    @Test
    @DisplayName("가장 마지막으로 저장한 상품의 상품번호를 읽어온다.")
    void findLatestProductNumber() {
        // given
        String targetProductNumber = "003";

        Product product1 = createProduct("001", HANDMADE, SELLING, "아메리카노", 4000);
        Product product2 = createProduct("002", HANDMADE, HOLD, "카페라떼", 4500);
        Product product3 = createProduct(targetProductNumber, HANDMADE, STOP_SELLING, "팥빙수", 7000);

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

        // when
        String latestProductNumber = productRepository.findLatestProductNumber();

        // then
        assertThat(latestProductNumber).isEqualTo(targetProductNumber);
    }

    private static Product createProduct(String productNumber, ProductType type, ProductSellingStatus sellingStatus, String name, int price) {
        Product product1 = Product.builder()
                .productNumber(productNumber)
                .type(type)
                .sellingStatus(sellingStatus)
                .name(name)
                .price(price)
                .build();
        return product1;
    }

}

이렇게 Repository에 작성한 메서드의 테스트가 완료되었다.

그렇다면, 만약 상품을 맨처음 등록하는 경우는 어떤 결과가 나올 수 있을까?

🤔🤔🤔🤔🤔

상품 데이터가 하나도 없는 경우는 결과가 없어야 한다.
이에 대한 테스트를 작성하면 다음과 같다.

@Test
@DisplayName("가장 마지막으로 저장한 상품의 상품번호를 읽어올 때, 상품이 하나도 없는 경우에는 null을 반환한다.")
void findLatestProductNumberWhenProductIsEmpty() {
   // when
   String latestProductNumber = productRepository.findLatestProductNumber();

   // then
   assertThat(latestProductNumber).isNull();
}

결과는 다음과 같다.

이처럼 예외 상황까지 테스트 해봤다.

다음으로 가장 최근의 번호를 가져와서 1을 증가시켜서 그 다음 상품번호를 만드는 과정을 진행한다.
이는 비즈니스 로직이다.
이 비즈니스 로직을 TDD로 작성하고 이어서 진행해본다.

다음 두 가지를 테스트할 것이다.

  1. 처음 상품을 등록하는 시점 즉 아무 데이터가 없을 때 001로 productNumber가 부여되고, 상품이 잘 만들어지는지,
  2. 기존의 상품의 최근 번호에서 1을 증가시킨 상품번호와 함꼐 상품이 만들어지는지

이 두 가지를 테스트할 것이다.

ProductServiceTest 클래스에서 일단 다음과 같이 작성한다.

package sample.cafekiosk.spring.api.service.product;

import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import sample.cafekiosk.spring.api.controller.product.dto.request.ProductCreateRequest;
import sample.cafekiosk.spring.domain.Product;
import sample.cafekiosk.spring.domain.ProductRepository;
import sample.cafekiosk.spring.domain.ProductSellingStatus;
import sample.cafekiosk.spring.domain.ProductType;

import java.util.List;

import static org.junit.jupiter.api.Assertions.*;
import static sample.cafekiosk.spring.domain.ProductSellingStatus.*;
import static sample.cafekiosk.spring.domain.ProductType.HANDMADE;

@SpringBootTest
class ProductServiceTest {

    @Autowired
    private ProductService productService;

    @Autowired
    private ProductRepository productRepository;


    private static Product createProduct(String productNumber, ProductType type, ProductSellingStatus sellingStatus, String name, int price) {
        Product product1 = Product.builder()
                .productNumber(productNumber)
                .type(type)
                .sellingStatus(sellingStatus)
                .name(name)
                .price(price)
                .build();
        return product1;
    }

}

여기서 신규상품을 등록하는 테스트 코드를 작성할 것이다.

@Test
@DisplayName("신규 상품을 등록한다. 상품번호는 가장 최근 상품의 상품번호에서 1 증가한 값이다.")
void createProduct() {
   // given
   Product product = createProduct("001", HANDMADE, SELLING, "아메리카노", 4000);
   productRepository.save(product);

   ProductCreateRequest request =

   // when
   productService.createProduct(request);

   // then

}

현재 createProduct(request) 를 작성하던 도중
ProductCreateRequest 타입의 request를 만들어야 한다.

ProductCreateRequest에서 생성자를 추가하자.

import lombok.Builder;
import lombok.Getter;
import sample.cafekiosk.spring.domain.ProductSellingStatus;
import sample.cafekiosk.spring.domain.ProductType;

@Getter
public class ProductCreateRequest {

    private ProductType type;
    private ProductSellingStatus sellingStatus;
    private String name;
    private int price;

    @Builder
    private ProductCreateRequest(ProductType type, ProductSellingStatus sellingStatus, String name, int price) {
        this.type = type;
        this.sellingStatus = sellingStatus;
        this.name = name;
        this.price = price;
    }
}

기존에는 생성자가 작성되어있지 않았다.
생성자를 작성하고, @Builder도 만들어주었다.

다시 서비스 테스트 코드를 이어가보겠다.

    @Test
    @DisplayName("신규 상품을 등록한다. 상품번호는 가장 최근 상품의 상품번호에서 1 증가한 값이다.")
    void createProduct() {
        // given
        Product product = createProduct("001", HANDMADE, SELLING, "아메리카노", 4000);
        productRepository.save(product);

        ProductCreateRequest request = ProductCreateRequest.builder()
                .type(HANDMADE)
                .sellingStatus(SELLING)
                .name("카푸치노")
                .price(5000)
                .build();

        // when
        productService.createProduct(request);

현재 작성한 테스트 코드는 여기까지다.

여기서 현재 productService로 createProduct를 하고있는데,
보통 create를 했을 땐 어떤 엔티티가 생성되었는지 응답으로 주는 경우가 많다.

해당 메서드의 반환타입은 void인데, 작성해놨던 response를 반환을 하도록 한다.

    public ProductResponse createProduct(ProductCreateRequest request) {

        String latestProductNumber = productRepository.findLatestProductNumber();

        return null;
    }

반환타입이 ProductResponse로 되어있는 부분은 원래 void였다.
그리고 일단 컴파일이 정상적으로 되도록 null을 반환하도록 한다.

테스트코드를 계속 작성하자.

    @Test
    @DisplayName("신규 상품을 등록한다. 상품번호는 가장 최근 상품의 상품번호에서 1 증가한 값이다.")
    void createProduct() {
        // given
        Product product = createProduct("001", HANDMADE, SELLING, "아메리카노", 4000);
        productRepository.save(product);

        ProductCreateRequest request = ProductCreateRequest.builder()
                .type(HANDMADE)
                .sellingStatus(SELLING)
                .name("카푸치노")
                .price(5000)
                .build();

        // when
        ProductResponse productResponse = productService.createProduct(request);

        // then
        assertThat(productResponse)
                .extracting("productNumber", "type", "sellingStatus", "name", "price")
                .contains("002", HANDMADE, SELLING, "카푸치노", 5000);
    }

결과는 일단 Red이다.

이제 RED을 봣으니 GREEN을 빨리 보도록 하자.

정말 극단적으로는 이렇게 해도 된다.

    public ProductResponse createProduct(ProductCreateRequest request) {
        String latestProductNumber = productRepository.findLatestProductNumber();


        return ProductResponse.builder()
                .productNumber("002")
                .type(ProductType.HANDMADE)
                .sellingStatus(ProductSellingStatus.SELLING)
                .name("카푸치노")
                .price(5000)
                .build();
    }

정말 극단적이다..ㅋㅋㅋ

지금까지 Repository를 통해서 가장 직전의 productNumber는 읽었다.
이제는 다음 값을 nextProductNumber로 만들것이다.

일단 nextProductNumber을 구하는 메서드를 만든다.

예를 들어 최근 제품 번호가 003이었다면, 이 문자열을 숫자로 바꾼 후, 1을 더해서 다시 문자열 format을 바꿔주는 형식으로 할 것이다.

private String createNextProductNumber() {
    String latestProductNumber = productRepository.findLatestProductNumber();

    int latestProductNumberInt = Integer.parseInt(latestProductNumber);
    int nextProductNumberInt = latestProductNumberInt + 1;
    return String.format("%03d", nextProductNumberInt);
}

현재까지 작성된 서비스의 createProduct()메서드는 다음과 같다.

public ProductResponse createProduct(ProductCreateRequest request) {
    String nextProductNumber = createNextProductNumber();

    return ProductResponse.builder()
            .productNumber(nextProductNumber)
            .type(request.getType())
            .sellingStatus(request.getSellingStatus())
            .name(request.getName())
            .price(request.getPrice())
            .build();
}

private String createNextProductNumber() {
    String latestProductNumber = productRepository.findLatestProductNumber();

    int latestProductNumberInt = Integer.parseInt(latestProductNumber);
    int nextProductNumberInt = latestProductNumberInt + 1;
    return String.format("%03d", nextProductNumberInt);
}

이제 테스트를 해보자.

결과는 통과다.

그리고 한 갖의 테스트를 더해야한다.
바로 상품이 없는 경우에 대한 테스트다.

상품이 하나도 없는 경우 신규상품을 등록하면 상품번호는 001이어야 한다는 테스트다.
일단 다음과 같이 테스트 코드를 작성하고 돌려보자.

@Test
@DisplayName("상품이 하나도 없는 경우 신규 상품을 등록하면 상품번호는 001이다.")
void createProductWhenProductsIsEmpty() {
    // given
    ProductCreateRequest request = ProductCreateRequest.builder()
            .type(HANDMADE)
            .sellingStatus(SELLING)
            .name("카푸치노")
            .price(5000)
            .build();

    // when
    ProductResponse productResponse = productService.createProduct(request);

    // then
    assertThat(productResponse)
            .extracting("productNumber", "type", "sellingStatus", "name", "price")
            .contains("001", HANDMADE, SELLING, "카푸치노", 5000);
}

결과는 RED다.

현재는 실패한다.

서비스 코드로 가서 이를 처리해줘야 한다.

방금 작성한 마지막 productNumber를 가지고, 다음 number를 만들어내는 메서드를 다음과 같이 작성한다.

private String createNextProductNumber() {
    String latestProductNumber = productRepository.findLatestProductNumber();
    if(latestProductNumber == null) {
        return "001";
    }

    int latestProductNumberInt = Integer.parseInt(latestProductNumber);
    int nextProductNumberInt = latestProductNumberInt + 1;
    return String.format("%03d", nextProductNumberInt);
}

그리고 다시 테스트를 진행하면 실패하는데,,

보는 바와 같이 004번이 나온다고 한다.
이는 resource 디렉터리 안에 있는 data.sql 때문인데, 미리 더미데이터를 위해 003까지 넣고있기 때문이다.

따라서 테스트 클래스에 ActiveProfiles"test"를 넣어주도록 한다.

이제 성공한다.

다음은 전체테스트를 돌렸을 때의 결과인데, 각 테스트마다 데이터를 비워주지 않았기 때문이다.

테스트 클래스에 다음과 같이 선언하여 데이터 클렌징을 해주도록 한다.

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

성공한다.

참고로 윈도우 기준 테스트 실행은 ctrl + shift + f10인데, 하나의 테스트 안에 커서를 두고 실행하면 테스트가 하나만 실행되고,

어떤 테스트에도 속하지 않은 영역에서 해당 단축키를 실행하면 모든 테스트가 실행된다.

이건 꿀팁이다.

(이 꿀팁은 백기선님 강의 잠시 들을 때 알았긴 했다.)
중간에 동시성 이슈에 대한 얘기를 하셨는데, 개념만 알지 자세히는 모른다.

강의를 보면서 음 뭔가 빠진 거 같다는 느낌이 들긴했는데, 서비스에 Entity를 저장하지 않고 있다. 다시 살펴보자.

테스트를 정정한다...;;

@Test
@DisplayName("신규 상품을 등록한다. 상품번호는 가장 최근 상품의 상품번호에서 1 증가한 값이다.")
void createProduct() {
    // given
    Product product = createProduct("001", HANDMADE, SELLING, "아메리카노", 4000);
    productRepository.save(product);

    ProductCreateRequest request = ProductCreateRequest.builder()
            .type(HANDMADE)
            .sellingStatus(SELLING)
            .name("카푸치노")
            .price(5000)
            .build();

    // when
    ProductResponse productResponse = productService.createProduct(request);

    // then
    assertThat(productResponse)
            .extracting("productNumber", "type", "sellingStatus", "name", "price")
            .contains("002", HANDMADE, SELLING, "카푸치노", 5000);

    List<Product> products = productRepository.findAll();
    assertThat(products).hasSize(2)
            .extracting("productNumber", "type", "sellingStatus", "name", "price")
            .containsExactlyInAnyOrder(
                    tuple("001", HANDMADE, SELLING, "아메리카노", 4000),
                    tuple("002", HANDMADE, SELLING, "카푸치노", 5000)
            );
}

하나의 제품은 직접 담았고, 하나의 제품은 서비스클래스를 통해 담는데, 서비스에서 상품을 insert하지 않으니 당연히 실패한다.

일단 다른 테스트 코드도 수정한다.

@Test
@DisplayName("상품이 하나도 없는 경우 신규 상품을 등록하면 상품번호는 001이다.")
void createProductWhenProductsIsEmpty() {
    // given
    ProductCreateRequest request = ProductCreateRequest.builder()
            .type(HANDMADE)
            .sellingStatus(SELLING)
            .name("카푸치노")
            .price(5000)
            .build();

    // when
    ProductResponse productResponse = productService.createProduct(request);

    // then
    assertThat(productResponse)
            .extracting("productNumber", "type", "sellingStatus", "name", "price")
            .contains("001", HANDMADE, SELLING, "카푸치노", 5000);

    List<Product> products = productRepository.findAll();
    assertThat(products).hasSize(1)
            .extracting("productNumber", "type", "sellingStatus", "name", "price")
            .contains(
                    tuple("001", HANDMADE, SELLING, "카푸치노", 5000)
            );
}

일단 실패다.

서비스의 createProduct 메서드를 제대로 작성해본다.

public ProductResponse createProduct(ProductCreateRequest request) {
    String nextProductNumber = createNextProductNumber();

    Product product = request.toEntity(nextProductNumber);

    Product savedProduct = productRepository.save(product);

    return ProductResponse.builder()
          .id(savedProduct.getId())
          .productNumber(nextProductNumber)
          .type(request.getType())
          .sellingStatus(request.getSellingStatus())
          .name(request.getName())
          .price(request.getPrice())
          .build();
}

여기서 toEntity() 메서드가 없다.
작성해주자.

ProductCreateRequest 클래스에서 추가해준다.

public Product toEntity(String nextProductNumber) {
    return Product.builder()
            .productNumber(nextProductNumber)
            .type(type)
            .sellingStatus(sellingStatus)
            .name(name)
            .price(price)
            .build();
}

다시 테스트를 돌려보자.
그럼 성공이다!

이제 리팩터링을 한다.
Service의 createProduct메서드에서 Builder가 맘에 안 든다.

그리고 이미 ProductResponse에는 다음과 같은 메서드가 정의되어있다.

public static ProductResponse of(Product product) {
    return ProductResponse.builder()
            .id(product.getId())
            .productNumber(product.getProductNumber())
            .type(product.getType())
            .sellingStatus(product.getSellingStatus())
            .name(product.getName())
            .price(product.getPrice())
            .build();
}

따라서 createProduct() 메서드가 한결 간결해진다.

public ProductResponse createProduct(ProductCreateRequest request) {
    String nextProductNumber = createNextProductNumber();

    Product product = request.toEntity(nextProductNumber);

    Product savedProduct = productRepository.save(product);

    return ProductResponse.of(savedProduct);
}

그리고 테스트를 돌려보면..

성공한다!!

@Transactional에는 readOnly가 있는데, readOnly 값이 true일 경우 값을 읽기만 한다.
기본값은 false인데, true로 지정하면, CRUD중 R에 해당하는 읽기 외 CUD는 동작하지 않는다.
R일 경우에 성능최적화가 일어난다.
JPA 같은 경우, Dirty checking을 하게 되는데, 스냅샷이라고 하여 사실 영속성 컨텍스트에서 똑같은 id를 가지는 객체가 생긴다. 그리고 두 객체를 비교해서 변경이 일어났을 때 그에 따른 update 쿼리를 실행하한다.

그러나 readOnly에 true를 주면, 스냅샷이 일어나지않고, 변경감지가 일어나지 않아 성능 향상이 된다.

그리고 서비스 클래스를 작성할 때, CQRS(데이터 저장소로부터의 읽기와 업데이트 작업을 분리하는 패턴) 를 적용하여, Command는 Command끼리, Query는 Query끼리 따로 분리하여 서비스 클래스를 작성한다.
그리고 DB를 사용할 때에도 오로랃DB도 예를 들었지만, MySQL에는 마스터와 슬레이브가 있다.
MySQL의 마스터에는 CUD 에 해당하는 쿼리를 사용하게하고, 슬레이브에는 읽기전용으로만 쓰는 경우도 있다고 한다.
따라서 이후 서비스 클래스를 Command와 Query처럼 책임을 나누는 것이 좋을 것이다.

지금까지 상품을 등록하는 기능을 살펴보았다. 이제 Controller 게층 테스트를 해보자.

현재까지 작성된 Controller다

import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RestController;
import sample.cafekiosk.spring.api.controller.product.dto.request.ProductCreateRequest;
import sample.cafekiosk.spring.api.service.product.ProductService;
import sample.cafekiosk.spring.api.service.product.response.ProductResponse;

import java.util.List;

@RestController
@RequiredArgsConstructor
public class ProductController {

    private final ProductService productService;

    @PostMapping("/api/v1/products/new")
    public ProductResponse createProduct(ProductCreateRequest request) {
        return productService.createProduct(request);
    }

    @GetMapping("/api/v1/products/selling")
    public List<ProductResponse> getSellingProducts() {
        return productService.getSellingProducts();
    }
}

이에 대한 테스트 코드를 작성한다.

@WebMvcTest(controllers = ProductController.class)
class ProductControllerTest {

    @Autowired
    private MockMvc mockMvc;

    @MockBean
    private ProductService productService;

잠시 이 코드들을 살펴보자.

@WebMvcTest

  • 컨트롤러를 테스트하기 위한 애너테이션이다.
  • 해당 애너테이션이 있으면 컨테이너에 컨트롤러 관련 빈들만 올려주어 스프링 컨테이너에 모든 빈을 주입받는 @SpringBootTest보다 비교적 가볍게 테스트를 할 수 있도록 해주는 애너테이션이다.
  • 테스트 하고 하자는 컨트롤러를 명시해준다.
    • ex) @WebMvcTest(controllers = ProductController.class)

MockMvc

  • 서비스 Layer 하위로는 Mocking처리를 할 것인데 이를 도와주는 프레임워크이다.

@MockBean

  • 스프링 컨테이너에 Mock객체를 넣어준다.
  • @MockBean을 통해 Mock객체를 넣어주지 않으면, @WebMvcTest를 사용할 때 Service 클래스가 없다고 나올 것이다.

controller 테스트를 다음과 같이 작성했다.

@Test
@DisplayName("신규 상품을 등록한다.")
void createProduct() throws Exception {
    // given
    ProductCreateRequest request = ProductCreateRequest.builder()
            .type(ProductType.HANDMADE)
            .sellingStatus(ProductSellingStatus.SELLING)
            .name("아메리카노")
            .price(4000)
            .build();


    // when // then
    mockMvc.perform(MockMvcRequestBuilders.post("/api/v1/products/new")
                    .content(objectMapper.writeValueAsString(request))
                    .contentType(MediaType.APPLICATION_JSON)
            )
            .andExpect(MockMvcResultMatchers.status().isOk());

}

참고로 객체를 직렬화시키기 위해 ObjectMapper를 다음과 같이 주입받았다.

@Autowired
private ObjectMapper objectMapper;

perform

  • MockMvcRequestBuilders 를 통해 어떤 HTTP메서드로 어떤 URL로 요청할 것인지 지정한다.
  • 현재 코드에선 /api/v1/products/new 로 post 매핑을 한다.

content

  • 요청에 대한 RequestBody를 지정한다.
  • given에서 request를 정의하고, 이를 주입받은 ObjectMapper로 직렬화한 request 객체를 넣어주었다.

contentType

  • 요청타입이 무엇인지 정해준다.
  • 실제 HTTP header에 application/json타입을 정해주는 것처럼 여기서는 MediaType.APPLICATION_JSON으로 설정했다.

andExpect

  • 어떠한 결과가 나오는지를 검증한다.
  • MockMvcResultMatchers.status().isOk() 는 현재 mvc의 결과 상태코드가 200인지 알아본다.

이 테스트를 돌려보면 실패를 한다.

원인은 다음과 같다.

@EnableJpaAuditing이 있는데, @WebMvcTest를 올리면서 @EnableJpaAuditing 빈들도 같이 올리려고하는데, 관련 Bean을 만들 수 없기 때문에 실패결과가 나온 것이다.

위와 같이 config 패키지에 JpaAuditingConfig 클래스를 만들고, 다음과 같이 @EnableJpaAuditing를 따로 분리하였다.

@EnableJpaAuditing
@Configuration
public class JpaAuditingConfig {
}

그럼에도 불구하고 테스트가 실패했는데, 이유는 다음과 같다.

파라미터에 @RequestBody가 빠졌다.

다음과 같이 넣어주자.

@PostMapping("/api/v1/products/new")
    public ProductResponse createProduct(@RequestBody ProductCreateRequest request) {
        return productService.createProduct(request);
    }

그리고 ProductCreateRequest클래스에 @NoArgsConstructor를 넣어준다.

컨트롤러에서 JSON으로 들어온 값 즉 String으로 직렬화되어 들어온 값이 ProductCreateRequest로 매핑을 하는 역직렬화가 일어나는데, 그 역직렬화를 할 때 ObjectMapper는 기본생성자를 사용한다.
그래서 기본생성자가 필요하여 @NoArgsConstructor를 선언한다.

@Getter
@NoArgsConstructor
public class ProductCreateRequest {

다시 테스트를 돌리면 성공한다.

만약 로그를 보고 싶으면 mockMvc 체인에 다음 코드를 작성해주면 된다.

// when // then
mockMvc
        .perform(MockMvcRequestBuilders.post("/api/v1/products/new")
                .content(objectMapper.writeValueAsString(request))
                .contentType(MediaType.APPLICATION_JSON)
        )
        .andDo(MockMvcResultHandlers.print())
        .andExpect(MockMvcResultMatchers.status().isOk());

andDo(MockMvcResultHandlers.print())를 추가해주면, 보다 자세한 로그를 볼 수 있다.

이처럼 어떤 요청과 결과가 오갔는지 알 수 있다.

인텔리제이에서 제공하는 on-demand 를 이용하여 MockMvcRequestBuilders, MockMvcResultHandlers, MockMvcResultMatchers 이 세 가지 클래스를 static import 하도록 하였다.

우빈님께서 컨트롤러의 역할 중 파라미터가 잘 들어왔는지 기본적인 유효성 검사, 벨리데이션을 하는 것이 주요 기능이라고 생각한다고 하였었다.

그와 관련한 내용을 보자.

먼저 스프링에서 제공하는 Spring Bean Validation 을 주입받아야 한다.
build.gradle에서 다음 코드를 작성하여 라이브러리를 통해 의존성을 주입받는다.

implementation 'org.springframework.boot:spring-boot-starter-validation'

이제 Request에 들어온 값들을 검증해보자.

Request로 들어오는 값들에 대해 검증하려고 한다.

validation에서 제공하는 애너테이션들은 다음과 같은 것들이 있다.

validation을 적용한 Request 클래스는 다음과 같다.

import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Positive;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import sample.cafekiosk.spring.domain.Product;
import sample.cafekiosk.spring.domain.ProductSellingStatus;
import sample.cafekiosk.spring.domain.ProductType;

@Getter
@NoArgsConstructor
public class ProductCreateRequest {

    @NotNull
    private ProductType type;

    @NotNull
    private ProductSellingStatus sellingStatus;

    @NotBlank
    private String name;

    @Positive
    private int price;

    @Builder
    private ProductCreateRequest(ProductType type, ProductSellingStatus sellingStatus, String name, int price) {
        this.type = type;
        this.sellingStatus = sellingStatus;
        this.name = name;
        this.price = price;
    }

    public Product toEntity(String nextProductNumber) {
        return Product.builder()
                .productNumber(nextProductNumber)
                .type(type)
                .sellingStatus(sellingStatus)
                .name(name)
                .price(price)
                .build();
    }
}

그런데 여기서 validation을 처리해줬다면, Controller에도 적용하려면 다음과 같이 @Valid 애너테이션을 붙여줘야 한다.

    @PostMapping("/api/v1/products/new")
    public ProductResponse createProduct(@Valid @RequestBody ProductCreateRequest request) {
        return productService.createProduct(request);
    }

이렇게 @Valid 애너테이션을 붙여야 validation이 적용된다.

참고로 강의 중엔 validation의 패키지가 javax로 나오는데, 필자는 스프링 부트 3.0 이상으로 진행중이기 때문에, jakarta로 나온다.

다음으로 백엔드에서 API 통신의 응답에 대해 보다 규격화된 응답을 줄 수 있도록 Response를 만드려한다.

패키지는 이와 같이 ApiResponse 클래스를 작성하려 한다.

작성한 클래스는 다음과 같다.

import org.springframework.http.HttpStatus;
import sample.cafekiosk.spring.api.service.product.response.ProductResponse;

public class ApiResponse<T> {

    private int code;
    private HttpStatus status;
    private String message;
    private T data;


    public static <T> ApiResponse<T> of(HttpStatus httpStatus, T data) {
        return null;
    }
}

code에는 HttpStatus의 코드를 넣고,
status는 HttpStatus이고,
메세지는 에러가 발생했을 때 어떤 문제인지를 알려주는 message이고,
data는 요청에 대한 성공 데이터다.

아직 미완성이다.

일단은 이와 같이 작성했다. 그리고 Controller 메서드도 다음과 같이 수정했다.

@PostMapping("/api/v1/products/new")
public ApiResponse<ProductResponse> createProduct(@Valid @RequestBody ProductCreateRequest request) {
  return ApiResponse.of(HttpStatus.OK, productService.createProduct(request));
}

반환부와 return문이 변경되었다.

ApiResponse 클래스를 마저 작성해보면 다음과 같다.

import lombok.Getter;
import org.springframework.http.HttpStatus;

@Getter
public class ApiResponse<T> {

    private int code;
    private HttpStatus status;
    private String message;
    private T data;

    public ApiResponse(HttpStatus status, String message, T data) {
        this.code = status.value();
        this.status = status;
        this.message = message;
        this.data = data;
    }

    public static <T> ApiResponse<T> of(HttpStatus httpStatus, String message, T data) {
        return new ApiResponse<>(httpStatus, message, data);
    }

    public static <T> ApiResponse<T> of(HttpStatus httpStatus, T data) {
        return of(httpStatus, httpStatus.name(), data);
    }

    public static <T> ApiResponse<T> ok(T data) {
        return of(HttpStatus.OK, HttpStatus.OK.name(), data);
    }
}

OK용으로 만든 것은 OK를 줘야하는 경우가 많다.
자주 쓰이는 Status에 맞는 ApiResponse에 대한 Factory메서드를 만들어도 좋을 거 같다.

그리고 수정된 Contoller는 다음과 같다.

@PostMapping("/api/v1/products/new")
public ApiResponse<ProductResponse> createProduct(@Valid @RequestBody ProductCreateRequest request) {
    return ApiResponse.ok(productService.createProduct(request));
}

이제 validation 체크를 한다.
만약 ProduceCreateRequest에서 ProductType의 값이 null로 들어온 경우엔, @NotNull 애너테이션 때문에 예외가 터질 것인데, 그러한 부분 또한 ApiResponse의 형태로 보내려고 한다.

api 패키지 하위에 다음과 같은 컨트롤러 클래스를 작성한다.

package sample.cafekiosk.spring.api;


import org.springframework.http.HttpStatus;
import org.springframework.validation.BindException;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;


@ControllerAdvice
public class ApiControllerAdvice {

    @ExceptionHandler(BindException.class)
    public ApiResponse<Object> bindException(BindException e) {

        return ApiResponse.of(
                HttpStatus.BAD_REQUEST,
                e.getBindingResult().getAllErrors().get(0).getDefaultMessage()
        );
    }
}

예외가 발생하면 이 클래스에서 처리해줄 것이다.

또한 ProductCreateRequest 클래스가 변경되었다.

(코드 작성하면서 수정할 부분이 생각날 때마다 작성을 하시는데, TDD의 과정을 기록하려고 변경시마다 계속 기록하려는데,, 좀 흠.. 하핳.. 빡세다...)

다음과 같다.

package sample.cafekiosk.spring.api.controller.product.dto.request;

import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Positive;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import sample.cafekiosk.spring.domain.Product;
import sample.cafekiosk.spring.domain.ProductSellingStatus;
import sample.cafekiosk.spring.domain.ProductType;

@Getter
@NoArgsConstructor
public class ProductCreateRequest {

    @NotNull(message = "상품 타입은 필수입니다.")
    private ProductType type;

    @NotNull(message = "상품 판매상태는 필수입니다.")
    private ProductSellingStatus sellingStatus;

    @NotBlank(message = "상품 이름은 필수입니다.")
    private String name;

    @Positive(message = "상품 가격은 양수여야 합니다.")
    private int price;

    @Builder
    private ProductCreateRequest(ProductType type, ProductSellingStatus sellingStatus, String name, int price) {
        this.type = type;
        this.sellingStatus = sellingStatus;
        this.name = name;
        this.price = price;
    }

    public Product toEntity(String nextProductNumber) {
        return Product.builder()
                .productNumber(nextProductNumber)
                .type(type)
                .sellingStatus(sellingStatus)
                .name(name)
                .price(price)
                .build();
    }
}

이제 테스트 코드를 작성한다.
ProductCreateRequest의 validation에 대한 테스트를 진행할 것이다.

참고로 각 애너테이션에 파라미터로 있는 message는 예외처리시 보여줄 메세지다.

상품에 타입이 없는 경우에 대한 테스트다

@Test
@DisplayName("신규 상품을 등록할 때 상품 타입은 필수값이다.")
void createProductWithoutType() throws Exception {
    // given
    ProductCreateRequest request = ProductCreateRequest.builder()
            .sellingStatus(ProductSellingStatus.SELLING)
            .name("아메리카노")
            .price(4000)
            .build();


    // when // then
    mockMvc.perform(post("/api/v1/products/new")
                    .content(objectMapper.writeValueAsString(request))
                    .contentType(MediaType.APPLICATION_JSON)
            )
            .andDo(print())
            .andExpect(status().isBadRequest());
}

실패했다.

400을 기대했는데, 200이 나왔다.

답은 예외처리를 해주는 ApiControllerAdvice에 답이 있다.

400을 돌려주는 것 같지만,
400코드는 응답 데이터에 담아질 뿐, HTTP메서드는 200으로 처리된다.
따라서 이 코드에서 어떤 코드로 줄 것인지 정해야하는데, @ResponseStatus 애너테이션으로 값을 줄 수 있다.

수정된 코드는 다음과 같다.

import org.springframework.http.HttpStatus;
import org.springframework.validation.BindException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestControllerAdvice;


@RestControllerAdvice
public class ApiControllerAdvice {

    @ResponseStatus(HttpStatus.BAD_REQUEST)
    @ExceptionHandler(BindException.class)
    public ApiResponse<Object> bindException(BindException e) {
        return ApiResponse.of(
                HttpStatus.BAD_REQUEST,
                e.getBindingResult().getAllErrors().get(0).getDefaultMessage(),
                null
        );
    }
}

그리고 다시 테스트를 해보면 성공한다.

400코드는 검증되었다.

이 테스트 코드에 이어서 메세지도 검증해보도록 하자.
(ApiResponse에 잘 담기는지 확인해본다.)

@Test
    @DisplayName("신규 상품을 등록할 때 상품 타입은 필수값이다.")
    void createProductWithoutType() throws Exception {
        // given
        ProductCreateRequest request = ProductCreateRequest.builder()
                .sellingStatus(ProductSellingStatus.SELLING)
                .name("아메리카노")
                .price(4000)
                .build();


        // when // then
        mockMvc.perform(post("/api/v1/products/new")
                        .content(objectMapper.writeValueAsString(request))
                        .contentType(MediaType.APPLICATION_JSON)
                )
                .andDo(print())
                .andExpect(status().isBadRequest())
                .andExpect(jsonPath("$.code").value("400"))
                .andExpect(jsonPath("$.status").value("BAD_REQUEST"))
                .andExpect(jsonPath("$.message").value("상품 타입은 필수입니다."))
                .andExpect(jsonPath("$.data").isEmpty())
        ;

    }

andExpect에서 파라미터에 jsonPath("$")를 통해서 위와 같이 변수와 값이 맞는지 확인할 수 있다.
여기서 data는 null로 반환되기 때문에 isEmpty() 를 사용했다.

이 테스트는 통과한다.

계속 나머지 경우도 테스트코드를 작성해보자.

@Test
@DisplayName("신규 상품을 등록할 때 상품 판매상태은 필수값이다.")
void createProductWithoutSellingStatus() throws Exception {
    // given
    ProductCreateRequest request = ProductCreateRequest.builder()
            .type(ProductType.HANDMADE)
            .name("아메리카노")
            .price(4000)
            .build();


    // when // then
    mockMvc.perform(post("/api/v1/products/new")
                    .content(objectMapper.writeValueAsString(request))
                    .contentType(MediaType.APPLICATION_JSON)
            )
            .andDo(print())
            .andExpect(status().isBadRequest())
            .andExpect(jsonPath("$.code").value("400"))
            .andExpect(jsonPath("$.status").value("BAD_REQUEST"))
            .andExpect(jsonPath("$.message").value("상품 판매상태는 필수입니다."))
            .andExpect(jsonPath("$.data").isEmpty())
    ;
}

@Test
@DisplayName("신규 상품을 등록할 때 상품 이름은 필수값이다.")
void createProductWithoutName() throws Exception {
    // given
    ProductCreateRequest request = ProductCreateRequest.builder()
            .type(ProductType.HANDMADE)
            .sellingStatus(ProductSellingStatus.SELLING)
            .price(4000)
            .build();


    // when // then
    mockMvc.perform(post("/api/v1/products/new")
                    .content(objectMapper.writeValueAsString(request))
                    .contentType(MediaType.APPLICATION_JSON)
            )
            .andDo(print())
            .andExpect(status().isBadRequest())
            .andExpect(jsonPath("$.code").value("400"))
            .andExpect(jsonPath("$.status").value("BAD_REQUEST"))
            .andExpect(jsonPath("$.message").value("상품 이름은 필수입니다."))
            .andExpect(jsonPath("$.data").isEmpty())
    ;
}


@Test
@DisplayName("신규 상품을 등록할 때 상품 가격은 양수이다.")
void createProductWithoutZeroPrice() throws Exception {
    // given
    ProductCreateRequest request = ProductCreateRequest.builder()
            .type(ProductType.HANDMADE)
            .sellingStatus(ProductSellingStatus.SELLING)
            .name("아메리카노")
            .price(0)
            .build();


    // when // then
    mockMvc.perform(post("/api/v1/products/new")
                    .content(objectMapper.writeValueAsString(request))
                    .contentType(MediaType.APPLICATION_JSON)
            )
            .andDo(print())
            .andExpect(status().isBadRequest())
            .andExpect(jsonPath("$.code").value("400"))
            .andExpect(jsonPath("$.status").value("BAD_REQUEST"))
            .andExpect(jsonPath("$.message").value("상품 가격은 양수여야 합니다."))
            .andExpect(jsonPath("$.data").isEmpty())
    ;
}

결과는 기분 좋게 모두 성공이다.

이제 Validation 애너테이션에 대해 알아보자.

@NotNull

  • Null 이 아니어야 한다.
  • String 기준으로 "" 같은 빈문자열이나, 혹은 " " 같이 공백이 있는 문자열은 통과가 된다.

@NotEmpty

  • 공백은 통과하지만, "" 같은 빈문자열을 통과시키지 않는다.

@NotBlank

  • @NotBlank@NotNull@NotEmpty를 더한 것이다.
  • 공백, 빈문자열, null 등 다 통과시키지 않는다.

이 세 가지 중 @NotBlank를 자주 사용한다고 한다.

그리고 책임 분리를 고민해봐야 한다.

무슨 말이냐..

예를 들어 상품이름이 있을 때, 상품 이름은 20자를 제한하고 싶을 때, 다음과 같이 사용할 수 있다.

@NotBlank(message = "상품 이름은 필수입니다.")
@Max(20)
private String name;

위와 같이 줄 수 있다.
그런데 여기서 검증을 하는 것이 맞는지 고민해봐야 한다.
글자 수 20자 제한에 의해서 과연 Controller 계층에서 튕겨낼 책임이 맞는지에 대한 고민이 필요하다는 것이다.

방금 봤던 @Notxx 문자열이라면 유효한 문자열이라면 당연히 가져야 하는 속성에 대한 validation과 프로젝트의 도메인 성격에 맞는 validation을 구분할 수 있어야 한다는 것이다.

따라서 우빈님은 컨트롤러에서는 @NotBlank로만 검증을 하고,
20자 제한은 서비스 계층이나 혹은 Entity를 생성하는 시점에 검증을 하던가 하는 구분이 필요하다.

하나의 검증으로 보이지만, 성격에 따라 어느 레이어에서 검증을 할지 고민이 필요하다.

이제까지 Post에 대한 검증이었다.
이제 get에 대한 검증도 해보자.

작성했던 ProductController에서 해당 메서드 검증에 대한 테스트이다.

@GetMapping("/api/v1/products/selling")
public ApiResponse<List<ProductResponse>> getSellingProducts() {
    return ApiResponse.of(HttpStatus.OK, productService.getSellingProducts());
}

테스트 코드를 작성하면 다음과 같다.

@Test
@DisplayName("판매 상품을 조회한다")
void getSellingProducts() throws Exception {
    // given
    List<ProductResponse> result = List.of();
    when(productService.getSellingProducts()).thenReturn(result);


    // when // then
    mockMvc.perform(
                get("/api/v1/products/selling")
            )
            .andDo(print())
            .andExpect(status().isOk())
            .andExpect(jsonPath("$.code").value("200"))
            .andExpect(jsonPath("$.status").value("OK"))
            .andExpect(jsonPath("$.message").value("OK"))
            .andExpect(jsonPath("$.data").isArray())
    ;
}

결과는 다음과 같다.

서비스 하위로는 Mocking처리를 했다.
물론 데이터를 직접 저장하면서 테스트를 할 수도 있지만,
이미 두 레이어에 대한 테스트는 끝났기 때문에 이제는 Controller의 응답이 잘 오는지 검증하면 된다.

다음으로 OrderController에 한번 테스트를 해보자.

먼저 OrderController 코드다.

import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;
import sample.cafekiosk.spring.api.ApiResponse;
import sample.cafekiosk.spring.api.controller.order.request.OrderCreateRequest;
import sample.cafekiosk.spring.api.controller.order.response.OrderResponse;
import sample.cafekiosk.spring.api.service.order.OrderService;

import java.time.LocalDateTime;

@RequiredArgsConstructor
@RestController
public class OrderController {

    private final OrderService orderService;

    @PostMapping("/api/v1/orders/new")
    public ApiResponse<OrderResponse> createOrder(@Valid @RequestBody OrderCreateRequest request) {
        LocalDateTime registeredDateTme = LocalDateTime.now();
        return ApiResponse.of(HttpStatus.OK, orderService.createOrder(request, registeredDateTme));
    }

}

다음은 OrderCreateRequest 코드다.

import jakarta.validation.constraints.NotEmpty;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;

import java.util.List;

@Getter
@NoArgsConstructor
public class OrderCreateRequest {

    @NotEmpty(message = "상품 번호 리스트는 필수입니다.")
    private List<String> productNumbers;

    @Builder
    public OrderCreateRequest(List<String> productNumbers) {
        this.productNumbers = productNumbers;
    }
}

다음은 작성한 테스트 코드이다.

import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.DisplayName;
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 sample.cafekiosk.spring.api.controller.order.request.OrderCreateRequest;
import sample.cafekiosk.spring.api.service.order.OrderService;

import java.util.List;

import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;


@WebMvcTest(controllers = OrderController.class)
class OrderControllerTest {

    @Autowired
    MockMvc mockMvc;

    @MockBean
    OrderService orderService;

    @Autowired
    ObjectMapper objectMapper;

    @Test
    @DisplayName("신규 주문을 등록한다.")
    void createOrder() throws Exception {
        // given
        OrderCreateRequest request = OrderCreateRequest.builder()
                .productNumbers(List.of("001"))
                .build();

        // when // then
        mockMvc.perform(
                        post("/api/v1/orders/new")
                                .content(objectMapper.writeValueAsString(request))
                                .contentType(MediaType.APPLICATION_JSON)
                )
                .andDo(print())
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.code").value("200"))
                .andExpect(jsonPath("$.status").value("OK"))
                .andExpect(jsonPath("$.message").value("OK"))
                ;
    }

    @Test
    @DisplayName("신규 주문을 등록할 때 상품 번호는 1개 이상이어야 한다.")
    void createOrderWithEmptyProductNumbers() throws Exception {
        // given
        OrderCreateRequest request = OrderCreateRequest.builder()
                .productNumbers(List.of())
                .build();

        // when // then
        mockMvc.perform(
                        post("/api/v1/orders/new")
                                .content(objectMapper.writeValueAsString(request))
                                .contentType(MediaType.APPLICATION_JSON)
                )
                .andDo(print())
                .andExpect(status().isBadRequest())
                .andExpect(jsonPath("$.code").value("400"))
                .andExpect(jsonPath("$.status").value("BAD_REQUEST"))
                .andExpect(jsonPath("$.message").value("상품 번호 리스트는 필수입니다."))
                .andExpect(jsonPath("$.data").isEmpty())
        ;
    }

}

테스트 결과는 다음과 같다.

마지막으로 리팩터링을 해보자.
현재 주문(Order) 도메인을 보면 컨트롤러 계층으로부터 받은 DTO를 서비스 계층으로까지 내리고 있다.

하위 레이어는 상위 레이어 계층을 모르는 형태가 제일 좋은데, 지금은 하위 레이어가 상위 레이어를 알고 있는 구조다.
(물론 상위는 당연히 하위를 호출하기 때문에 당연히 알고 있다.)

이를 리팩터링 해본다.

먼저 서비스용 DTO를 따로 만든다.

위의 controller/order/request 하위의 OrderCreateRequest 클래스를

아래와 같이 service/order/request 하위로 복붙하고, 이름을 OrderCreateServiceRequest로 지정했다.

구분은 다음과 같은 방법으로 한다.

request에 다음과 같은 toServiceRequest() 메서드가 있다.
이 메서드는 다음과 같다.

import java.util.List;

@Getter
@NoArgsConstructor
public class OrderCreateRequest {

    @NotEmpty(message = "상품 번호 리스트는 필수입니다.")
    private List<String> productNumbers;

    @Builder
    public OrderCreateRequest(List<String> productNumbers) {
        this.productNumbers = productNumbers;
    }

    // 여기
    public OrderCreateServiceRequest toServiceRequest() {
        return OrderCreateServiceRequest.builder()
                .productNumbers(productNumbers)
                .build();
    }
}

주석으로 여기랴고 되어있는 부분이다.
그리고 서비스에서는 그냥 OrderCreateRequest 타입을 받고 있는데, 이를 다음과 같이 OrderCreateServiceRequest로 받고 있다.

그리고 OrderCreateServiceRequest 클래스는 다음과 같다.

import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;

import java.util.List;

@Getter
@NoArgsConstructor
public class OrderCreateServiceRequest {

    private List<String> productNumbers;

    @Builder
    public OrderCreateServiceRequest(List<String> productNumbers) {
        this.productNumbers = productNumbers;
    }
}

위와 같이 서비스용 DTO에서는 validation 요소가 필요없다.
(와우.... 왜 이걸 생각 못 했을까... validation을 잘 안 줘서 그렇지..)

만약 이를 구분하지 못 했다면, 의존성을 계속 들고가야하는 상태가 된다.
따라서 이를 구분함으로써 책임을 분리했다.

처음이야 귀찮지만, 서비스가 커지면 커질수록 책임의 분리는 반드시 해야한다..!!

Product도 Order처럼 해보자.
패키지는 다음과 같이 해준다.

ProductController 코드다.

import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;
import sample.cafekiosk.spring.api.ApiResponse;
import sample.cafekiosk.spring.api.controller.product.request.ProductCreateRequest;
import sample.cafekiosk.spring.api.service.product.ProductService;
import sample.cafekiosk.spring.api.service.product.response.ProductResponse;

import java.util.List;

@RestController
@RequiredArgsConstructor
public class ProductController {

    private final ProductService productService;

    @PostMapping("/api/v1/products/new")
    public ApiResponse<ProductResponse> createProduct(@Valid @RequestBody ProductCreateRequest request) {
        return ApiResponse.ok(productService.createProduct(request.toServiceRequest()));
    }

    @GetMapping("/api/v1/products/selling")
    public ApiResponse<List<ProductResponse>> getSellingProducts() {
        return ApiResponse.of(HttpStatus.OK, productService.getSellingProducts());
    }
}

컨트롤러에서 요청받는 ProductCreateRequest 코드다.

import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Positive;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import sample.cafekiosk.spring.api.service.product.request.ProductCreateServiceRequest;
import sample.cafekiosk.spring.domain.Product;
import sample.cafekiosk.spring.domain.ProductSellingStatus;
import sample.cafekiosk.spring.domain.ProductType;

@Getter
@NoArgsConstructor
public class ProductCreateRequest {

    @NotNull(message = "상품 타입은 필수입니다.")
    private ProductType type;

    @NotNull(message = "상품 판매상태는 필수입니다.")
    private ProductSellingStatus sellingStatus;

    @NotBlank(message = "상품 이름은 필수입니다.")
    private String name;

    @Positive(message = "상품 가격은 양수여야 합니다.")
    private int price;

    @Builder
    private ProductCreateRequest(ProductType type, ProductSellingStatus sellingStatus, String name, int price) {
        this.type = type;
        this.sellingStatus = sellingStatus;
        this.name = name;
        this.price = price;
    }


    public ProductCreateServiceRequest toServiceRequest() {
        return ProductCreateServiceRequest.builder()
                .type(type)
                .sellingStatus(sellingStatus)
                .name(name)
                .price(price)
                .build();

    }
}

다음은 서비스 계층의 creatProduct(ProductCreateServiceRequest request) 코드이다.

public ProductResponse createProduct(ProductCreateServiceRequest request) {
    String nextProductNumber = createNextProductNumber();

    Product product = request.toEntity(nextProductNumber);

    Product savedProduct = productRepository.save(product);

    return ProductResponse.of(savedProduct);
}

다음은 ProductCreateServiceRequest 코드이다.

import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import sample.cafekiosk.spring.domain.Product;
import sample.cafekiosk.spring.domain.ProductSellingStatus;
import sample.cafekiosk.spring.domain.ProductType;

@Getter
@NoArgsConstructor
public class ProductCreateServiceRequest {

    private ProductType type;
    private ProductSellingStatus sellingStatus;
    private String name;
    private int price;

    @Builder
    private ProductCreateServiceRequest(ProductType type, ProductSellingStatus sellingStatus, String name, int price) {
        this.type = type;
        this.sellingStatus = sellingStatus;
        this.name = name;
        this.price = price;
    }

    public Product toEntity(String nextProductNumber) {
        return Product.builder()
                .productNumber(nextProductNumber)
                .type(type)
                .sellingStatus(sellingStatus)
                .name(name)
                .price(price)
                .build();
    }
}

3. 요약

Controller 계층에 대한 단위테스트에 대해 알아보았다.

Controller 계층 테스트는 요청으로 인해 들어오는 값에 대하여 validation을 검증하는 것이 주 목적이다.

또한 DTO를 컨트롤러 계층에서 받은 DTO를 그대로 쓰는 것이 아니라, 서비스 계층에서 따로 사용할 수 있는 DTO로 분리하였다.
이를 통해 의존관계를 계속 가지고 가면서 서비스가 커지면 커질수록 계속 결합을 가져갈 수 밖에 없는 구조에서 벗어나 하위 계층은 상위 계층을 모르는 구조로 가는 내용 또한 알아보았다.

또한 개인적으로 강의를 보면서 어렴풋이 알았거나 혹은 "보통 이렇게 코드를 작성하구나~"
라는 내용을 알게 되었다.


TDD 도 관련되 강의다보니 TDD 과정을 다 기롤하려 했다.
그런데 일일이 수정 및 변경된 코드를 할 때마다 적었다.
그래서 좀 시간이 많이 걸렸다...;;
특히 시간이 많이 잡혀있는 챕터기도 하다.. 조만간 얼른 이 강의를 끝내고 회사의 프로젝트에 적용해보고싶다...!!

728x90
Comments