나의 개발일지

[인프런 워밍업 클럽 3기](Day 16 미션) 레이어별 특징 및 테스트 방법 본문

스터디/인프런 워밍업 클럽 백엔드 코드 3기

[인프런 워밍업 클럽 3기](Day 16 미션) 레이어별 특징 및 테스트 방법

사각분무기 2025. 3. 24. 15:23

미션 설명

Layered Architecture 구조의 레이어별 테스트 작성법을 알아보았습니다.
레이어별로 1) 어떤 특징이 있고, 2) 어떻게 테스트를 하면 좋을지, 자기만의 언어로 다시 한번 정리해 볼까요?

나의 답

Persistence Layer

  • 특징
    • DB 데이터에 직접 접근하는 역할을 가진다
    • 데이터 제공 외의 역할을 해선 안된다 (비즈니스 가공 로직은 포함해선 안된다)
    • 데이터에 대한 CRUD에 집중한 레이어다
  • 테스트 코드 작성법
    • DB 접근을 통해 수행하는 CRUD 작업이 정상적으로 수행됐는지 검증한다
    • DataJpaTest 어노테이션을 활용하여 좀 더 가벼운(더 빠른) 테스트 코드 작성이 가능하다
      • Persistence Layer와 관련된 빈들만을 스프링 컨테이너에 올린다
    • 테스트 시 사용하는 DB는 실제 배포 환경에서 사용하는 DB를 일치시켜야 한다
      • 두 환경의 DB를 다르게 할 경우, 코드의 안정성을 보장할 수 없다
      • @ActiveProfiles("test") 어노테이션 application.yml 파일에 설정해둔 테스트 환경 전용 설정을 사용할 수 있다.

코드 예시

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

    @Autowired
    private ProductRepository productRepository;

    @DisplayName("원하는 판매상태를 가진 상품들을 조회한다.")
    @Test
    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("001", "아메리카노", SELLING),
                        tuple("002", "카페라떼", HOLD)
                );
    }

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

 

application.yml 파일 예시

spring:
  application:
    name: cafekiosk

  profiles:
    default: local

  datasource:
    url: jdbc:h2:mem:~/cafeKioskApplication
    driver-class-name: org.h2.Driver
    username: sa
    password:

  jpa:
    hibernate:
      ddl-auto: none

---
spring:
  config:
    activate:
      on-profile: local

  jpa:
    hibernate:
      ddl-auto: create
    show-sql: true
    properties:
      hibernate:
        format_sql: true
    defer-datasource-initialization: true # (2.5~) Hibernate 초기화 이후 data.sql 실행

  h2:
    console:
      enabled: true

---
spring:
  config:
    activate:
      on-profile: test

  jpa:
    hibernate:
      ddl-auto: create
    show-sql: true
    properties:
      hibernate:
        format_sql: true

  sql:
    init:
      mode: NEVER

Business Layer

  • 특징
    • 비즈니스 로직을 구현하는 역할을 가진다
    • Persistence Layer와의 상호 작용을 통해 비즈니스 로직을 전개한다
    • 트랜잭션을 보장해야 한다
  • 테스트 코드 작성법
    • 비즈니스 로직의 정상 동작을 테스트한다.
    • 엣지 케이스 위주로 작성한다
      • 테스트에 성공하는 특정 값의 범위가 있을 경우, 해당 범위의 극점을 사용함으로써 나머지 값들의 성공을 어느정도 보장할 수 있다
    • 해피 케이스 외의 눈에 보이는 예외와 눈에 보이지 않는 예외 케이스에 대한 테스트에 집착해야 한다.
    • Persistence Layer와 함께 통합 테스트로 진행한다

tearDown() vs @Transactional

각 테스트는 독립성이 보장되어야 한다. 이를 위해 테스트를 수행할때마다 초기화 작업이 진행되어야 하며 이때 두가지 방식을 사용할 수 있다.

  • tearDown()
    • @AfterEach 메소드를 붙인 tearDown() 함수를 설정하여 직접 초기화 로직을 작성한다
    • tearDown이라는 명칭은 단순 관례에 불과하며, 이는 테스트 코드 수행 후 정리하는 함수라는 의미를 가진다
  • @Transactional
    • 테스트 클래스 혹은 테스트 메소드에 어노테이션 형식으로 사용할 수 있다
    • 테스트가 끝날때마다 트랜잭션의 롤백 기능을 활용하여 초기화 작업을 수행한다
    • 실제 코드의 안정성을 보장할 수 없기 때문에 조심스럽게 활용해야 한다
      • 실제 코드의 @Transactional 누락 여부와 관계 없이 @Transactional 어노테이션이 작동한다.
      • 테스트가 실제 코드의 @Transactional 누락을 잡아낼 수 없다!

코드 예시

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

    @Autowired
    private ProductRepository productRepository;

    @Autowired
    private OrderRepository orderRepository;

    @Autowired
    private OrderProductRepository orderProductRepository;

    @Autowired
    private StockRepository stockRepository;

    @Autowired
    private OrderService orderService;

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

    @DisplayName("주문번호 리스트를 받아 주문을 생성한다.")
    @Test
    void createOrder() {
        // given
        LocalDateTime registeredDateTime = LocalDateTime.now();

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

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

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

    @DisplayName("재고가 부족한 상품으로 주문을 생성하려는 경우 예외가 발생한다.")
    @Test
    void createOrderWithNoStock() {
        // given
        LocalDateTime registeredDateTime = LocalDateTime.now();

        Product product1 = createProduct(BOTTLE, "001", 1000);
        Product product2 = createProduct(BAKERY, "002", 3000);
        Product product3 = createProduct(HANDMADE, "003", 5000);
        productRepository.saveAll(List.of(product1, product2, product3));

        Stock stock1 = Stock.create("001", 1);
        Stock stock2 = Stock.create("002", 2);
        stockRepository.saveAll(List.of(stock1, stock2));

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

        // when // then
        assertThatThrownBy(() -> orderService.createOrder(request, registeredDateTime))
                .isInstanceOf(IllegalArgumentException.class)
                .hasMessage("재고가 부족한 상품이 있습니다.");
    }

    private Product createProduct(ProductType type, String productNumber, int price) {
        return Product.builder()
                .type(type)
                .productNumber(productNumber)
                .price(price)
                .sellingStatus(SELLING)
                .name("메뉴 이름")
                .build();
    }
}

Presentation Layer

  • 특징
    • 외부 세계의 요청을 가장 먼저 받는 계층이다
    • 파라미터에 대한 최소한의 유효성 검사를 수행한다
  • 테스트 코드 작성법
    • 비즈니스 로직을 제외한, 외부 요청에 대한 유효성 검사와 응답에 대한 테스트를 진행한다
      • Business Layer 호출은 Mock 객체를 통해 이루어진다
    • MockMvc 인스턴스를 통한 모의 요청을 사용한다
      • 이때 json 형식의 body가 필요할 경우 ObjectMapper를 활용한다.

코드 예시

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

    @Autowired
    private MockMvc mockMvc;

    @Autowired
    private ObjectMapper objectMapper;

    @MockitoBean
    private OrderService orderService;

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

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

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

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