반응형

 

 

우리는 흔히 Controller, Service, Repository 등의 계층별 모듈로 구분하여 프로그램을 작성한다.

이번 포스팅에서는 Layer 별 테스트 코드를 어떻게 작성하면 좋을지에 대한 전략에 대해서 작성해보려고 한다.

해당 포스팅의 내용은 무조건 계층별로 테스트 코드는 이렇게 작성해야 한다는 것이 아닌, 그저 테스트 코드를 작성하는 수만가지 전략 중 하나라는 것을 엄두하고 구경하면 좋을 것 같다.

 


기본사항

 

프로파일 분리

application.yml의 프로파일을 Test 전용으로 나누어 독립적인 DB를 사용할 수 있게 하자.

테스트 시 실제 환경(DB)에 영향을 주지 않고 데이터베이스를 초기화하거나 필요한 데이터를 주입하여 테스트할 수 있도록 하기 위해서이다.

spring:
  profiles:
    default: local

  datasource:
    url: YOUR_URL
    driver-class-name: org.h2.Driver
    username: YOUR_USERNAME
    password: YOUR_PWD

  # ...

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

  # ...

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

  # ...
@ActiveProfiles("test")
@SpringBootTest
class MyTest {
    
    @Test
    void test(){

        //given

        //when
        
        //then

    }


}

 


좋은 테스트 코드 작성을 위한 방법론

테스트 코드의 작성이 "왜" 필요한지와 "해당 목적에 맞게 작성하기 위한 목표"를 알아두는 것은 중요하다. 아래의 내용을 참고해보면 좋을 것 같다.

 

https://sjh9708.tistory.com/238

 

[Spring Boot] 좋은 테스트 코드 : 필요성 및 실천 방법론 개요

테스트 코드의 필요성 수동 테스트의 문제점 수동으로 테스트 한다는 것은 곧 사람이 직접 테스트를 수행한다는 것이다. 특정한 케이스를 직접 넣어보고, 출력이나 로그 따위를 살펴보거나 디

sjh9708.tistory.com

 

 


테스트 Annotation의 종류

 

@SpringBootTest

  • 테스트 코드를 실행하기 전에 애플리케이션의 전체 컨텍스트(Application Context)를 로드한다.
  • Spring Boot 애플리케이션의 모든 빈을 로드하여 실제 구동되는 환경과 유사한 상태이다.
  • 애플리케이션을 통합적으로 테스트할 때 사용된다.
    • 예를 들면 Business Layer 테스트에서 Service + Repository를 함께 테스트

@WebMvcTest

  • Spring MVC Web Layer를 테스트하기 위해 사용되며 테스트 코드 실행 이전에 MVC 관련 Bean들만 로드한다.
  • 주로 컨트롤러의 로직과 HTTP 요청/응답의 처리 과정을 테스트할 때 사용된다.
  • 서비스, 리포지토리, 데이터베이스 관련 빈들은 로드되지 않으므로 이런 의존성들은 Mock 객체로 대체하여 사용한다.

@DataJpaTest

  • JPA 관련 컴포넌트만 로드하여 데이터베이스 관련 테스트를 수행한다.
  • Transactional 처리가 자동으로 이루어지는데, 이는 장점이 될 수도 있고 단점이 될 수도 있다.

 

 


작성한 코드가 실행해보기 전 까지 결과를 모르겠으면  테스트하라.

  • 메서드 하나를 작성하더라도 잘 동작할지에 대한 확신이 없다면 테스트 코드를 작성해야 한다.
  • 예를 들어 "주문"이라는 Entity Model에서 음료의 총 금액을 계산하는 3줄의 메서드조차 결과가 생각대로 잘 나올지 실행하기 전까지는 모른다. 이런 것은 테스트 코드로 작성해주자.

 

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

   // ...

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

}

 

class OrderTest {

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

 

 

 

 

 

 

 

 

 

 

 


계층별 테스트 전략

이제 Controller(Presentation Layer), Service(Buisiness Layer), Repository(Persistence Layer) 별 테스트 코드를 작성할 전략에 대해서 말해보려고 한다.

 

아래의 포스팅은 Layer 별 주요 책임을 중심으로 알아보는 내용의 포스팅이다.

모듈 별 테스트 코드의 목적은 "주요 책임"에 대한 검증이다. 혹시 참고하기를 희망하면 포스팅을 봐도 좋을 것 같다.

 

https://sjh9708.tistory.com/239

 

[Spring Boot] 레이어드(Layerd) 아키텍쳐 : 계층별 주요 책임 및 고려할 점

레이어드 아키텍쳐 (3-tier Layered Architecture) 우리는 흔히 Spring Framework를 이용하여 Controller, Service, Repository 등의 계층별 모듈로 구분하여 프로그램을 작성하였다. 해당 구조를 3-tier Layered 아키텍쳐

sjh9708.tistory.com

 

 

 

 

 


Persistence Layer (Repository)

Repository 테스트는 실제 데이터베이스와의 상호작용을 검증하기 때문에 단위 테스트(Unit Test)에 가까운 성격을 가진다.

 

Repository 테스트의 목적

  1. 테스트를 통해 쿼리가 의도한 대로 동작하는지 확인한다. 즉 데이터베이스의 조작을 집중적으로 테스트한다.
  2. 복잡한 쿼리에서 전달되는 파라미터가 정확히 매핑되고 있는지 확인한다.
  3. JPARepository, QueryDSL 등 구현 기술이 변경되더라도 기존의 Repository가 동일하게 동작해야 한다. 테스트 코드를 작성해두면 변화에 유연하게 대응할 수 있다.

 


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

    List<Product> findAllBySellingStatusIn(List<ProductSellingStatus> sellingStatuses);

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

}

 

위와 같은 Spring Data JPA Respository에 대해서 테스트 코드를 작성한다고 가정해보자. 우리는 해당 메서드들에 파라미터들이 올바르게 전달되는가와, 데이터베이스의 상태가 의도한 대로 변경되고 몇 번을 시도하더라도 일관적인가에 대해서 검증해야 한다.

 

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

    @Autowired
    private ProductRepository productRepository;


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


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

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


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

    }


    @DisplayName("상품번호 리스트로 상품을 조회한다.")
    @Test
    void findAllByProductNumberIn(){
        //given
        Product product1 = createProduct("001", ProductType.HANDMADE, ProductSellingStatus.SELLING, "아메리카노", 4000);
        Product product2 = createProduct("002", ProductType.HANDMADE, ProductSellingStatus.HOLD, "카페라떼", 4500);
        Product product3 = createProduct("003", ProductType.HANDMADE, ProductSellingStatus.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", "아메리카노", ProductSellingStatus.SELLING),
                        tuple("002", "카페라떼", ProductSellingStatus.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();
    }
    
}

 

Respository의 테스트는 단위 테스트의 성격을 가진다고 하였다. 따라서 다른 모듈은 신경쓰지 않고 데이터베이스와의 상호작용에만 신경쓰면 된다. 

 

테스트 코드를 작성할 때에는 BDD 스타일로 given / when / then을 나누어 작성을 많이 한다.

Given: “어떤 환경에서”
When: “어떤 행동을 진행했을 때”
Then: “어떤 상태 변화가 일어난다”

 

Test Annotation으로는 @JpaRepository 혹은 @SpringBootTest를 사용한다.

앞에서 언급했듯 트랜잭션의 자동 처리 유무와 컨텍스트를 어디까지 로드하는지의 차이가 존재하므로 이를 유의하고 사용하면 된다.

 

 

 


결과 케이스에 대해서 항상 생각하자.

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

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

 

만약 Repository에 위와 같은 코드를 추가했다고 생각해보자.

기능은 "현재 저장된 상품의 마지막 상품번호를 가져오는 것"이다. 해당 쿼리의 결과는 두 가지로 나뉠 것이다.

  • 상품이 1개 이상 존재하는 경우 상품번호를 가져온다.
  • 상품이 존재하지 않는 경우 NULL이 결과로 나온다.

항상 우리는 결과의 케이스를 나누어 테스트 코드를 작성하는 습관을 들여야 한다 (경계값 분석 등 사용). 따라서 해당 두 가지 케이스로 나누어 아래와 같이 테스트 코드를 작성해 볼 수 있다. 

 

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

    @Autowired
    private ProductRepository productRepository;

    // ...

    @DisplayName("가장 마지막으로 저장한 상품번호를 읽어온다.")
    @Test
    void findLatestProductNumber(){
        //given
        Product product1 = createProduct("001", ProductType.HANDMADE, ProductSellingStatus.SELLING, "아메리카노", 4000);
        Product product2 = createProduct("002", ProductType.HANDMADE, ProductSellingStatus.HOLD, "카페라떼", 4500);
        Product product3 = createProduct("003", ProductType.HANDMADE, ProductSellingStatus.STOP_SELLING, "팥빙수", 7000);

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

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


        //then
        assertThat(latestProductNumber).isEqualTo("003");
    }

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

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

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

    }

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

 

 

 

 

 

 


Presentation Layer (Controller)

 

 

 

Presencation Layer는 외부 세계의 요청을 가장 먼저 받는 계층이다.

 

Controller 테스트의 목적

  1. 사용자의 요청이 올바르게 처리되고 있는지 확인한다. 이는 HTTP 요청과 응답의 흐름을 집중적으로 검증하는 과정이다.
  2. 사용자의 입력 데이터가 정확하게 매핑되고, 유효성 검증이 제대로 이루어지는지 확인한다.
  3. Controller의 비즈니스 로직이 변경되거나 API가 확장되더라도 기존의 기능이 동일하게 동작하는지 검증하여, 변화에 유연하게 대응할 수 있다.

 

단위 테스트의 성격과 의존성 격리

Controller의 테스트에서는 하위 티어의 Layer의 모듈과 격리하여 단위 테스트의 성격을 가진다.

Presentation Layer은 최상위 Layer이다. 따라서 하위 모듈들에 대한 의존성이 존재한다.

그렇지만 해당 Layer에서의 목적은 비즈니스 로직의 검증이 아니다. 위에서 말한 내용들에 집중되어야 하며 하위 모듈의 검증은 다른 Layer의 테스트 코드에서 수행되어야 한다.

따라서 하위 Layer의 격리를 위해 Mocking을 사용하여, "동작하는 척 하는 가짜 객체"로 대체하는 방법을 주로 사용한다.

 

 


@RestController
@RequiredArgsConstructor
public class ProductController {
    private final ProductService productService;

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

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

    }
}

 

위와 같이 "상품 목록 조회"와 "상품 추가"에 대한 테스트 코드를 작성해야 한다고 생각해보자.

 

 

@WebMvcTest(controllers = ProductController.class) // Presentation Layer의 관련 Bean들만 사용하기 위한 테스트
class ProductControllerTest {

    @Autowired
    private MockMvc mockMvc;

    @Autowired
    private ObjectMapper objectMapper;

    @MockBean
    private ProductService productService; // 가짜(Mock) 의존성

}

 

  • @WebMvcTest : Spring MVC의 컨트롤러를 테스트하기 위해 사용되는 어노테이션이다.
    • controllers = ProductController.class는 테스트할 특정 컨트롤러(ProductController)만 로드하도록 지정한다.
  • MockMvc : Spring MVC의 컨트롤러를 실제로 서블릿 컨테이너를 시작하지 않고 테스트할 수 있게 해준다. 실제 요청이 서블릿 컨테이너에 의해 처리되는 것과 유사한 방식으로 테스트를 진행할 수 있다.
    • HTTP 요청을 만들어 컨트롤러의 동작을 검증할 수 있다.
  • @MockBean : ProductService는 ProductController의 하위 Layer에 속하는 의존성이다. 그렇지만 우리는 하위 모듈에 대한 검증에 대한 책임은 맡지 않는다고 하였다. 따라서 가짜 의존성인 Mock 객체로 대체하여 해당 모듈에 대한 검증에만 집중할 수 있도록 한다.

 

 


POST 요청 테스트하기

  • MockMvc의 perform() 메서드를 사용하여 HTTP 요청을 만들어 동작을 시험해 볼 수 있다.
  • HTTP의 Header 및 Body에 필요한 파라미터를 설정할 수 있다.
  • 검증 사항으로는 요청 시 데이터의 유효성 검증, 응답의 HTTP Status, 데이터 타입 등을 체크한다.
  • Persistence Layer에도 언급했듯이 하나의 기능에 대해서도 발생할 수 있는 케이스를 나누어 검증한다. 
@WebMvcTest(controllers = ProductController.class) // Presentation Layer의 관련 Bean들만 사용하기 위한 테스트
class ProductControllerTest {

    @Autowired
    private MockMvc mockMvc;

    @Autowired
    private ObjectMapper objectMapper;

    @MockBean
    private ProductService productService; // 가짜(Mock) 의존성

    @DisplayName("신규 상품을 등록한다.")
    @Test
    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)
        )
                .andDo(MockMvcResultHandlers.print())
                .andExpect(status().isOk());

    }

    @DisplayName("신규 상품을 등록할 때 상품 타입은 필수값이다.")
    @Test
    void createProductWithoutType() throws Exception {
        //given
        ProductCreateRequest request = ProductCreateRequest.builder()
                .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)
                )
                .andDo(MockMvcResultHandlers.print())
                .andExpect(status().isBadRequest())
                .andExpect(jsonPath("$.code").value("400"))
                .andExpect(jsonPath("$.status").value("BAD_REQUEST"))
                .andExpect(jsonPath("$.message").value("상품 타입은 필수입니다."))
                .andExpect(jsonPath("$.data").isEmpty());

    }

    @DisplayName("신규 상품을 등록할 때 상품 판매상태는 필수값이다.")
    @Test
    void createProductWithoutSellingStatus() throws Exception {
        // ...
    }

    @DisplayName("신규 상품을 등록할 때 상품이름은 필수값이다.")
    @Test
    void createProductWithoutName() throws Exception {
        // ...

    }

    @DisplayName("신규 상품을 등록할 때 가격은 양수여야 한다.")
    @Test
    void createProductInvalidPrice() throws Exception {
        // ...

    }
}

 

 


GET 요청 테스트하기

  • 조회 API도 마찬가지로 테스트 할 수 있다.
  • 우리는 ProductService에 대한 의존성을 격리하고 Mock 객체로 대체하였었다. 따라서 비즈니스 로직의 결과를 받을 수 없다.
  • 해당 하위 모듈에 대해서 직접 실행하지는 않지만 모듈을 실행했을 때 예상되는 결과값을 지정하여 사용한다.
    • when(productService.getSellingProducts()).thenReturn(result);
@DisplayName("판매 상품을 조회한다.")
@Test
void getSellingProducts() throws Exception {

    //given
    List<ProductResponse> result = List.of();
    when(productService.getSellingProducts()).thenReturn(result); // Mock 객체에 대해서 예상 응답을 지정한다.

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

}

 

 

<함께 보기> 
Test Double : Mock을 사용한 테스트 코드의 의존성 격리

https://sjh9708.tistory.com/219

 

[Spring Boot] 테스트 : Test Double : 모듈 의존성 격리(Mocking)

이전 포스팅에서 @SpringBootTest를 사용해서 통합 테스트를 작성하는 방식에 대해서 알아보았다.이번 포스팅에서는 테스트 코드에서의 모듈 의존성 격리의 필요성과, 어떤 경우에 대해 적용되어야

sjh9708.tistory.com

 

 


Buisness Layer (Service)

 

 

Service 테스트의 목적

  1. 비즈니스 로직 검증: Business Layer에서는 주로 비즈니스 규칙이 구현되기 때문에, 이 로직이 올바르게 동작하는지 검증해야 한다. 특히 경계값 분석 등을 통해서 동작 케이스와 예외 케이스에 대해서 특히 신경써서 테스트를 작성해야 한다.
  2. 데이터 가공 및 변환: Service는 Presentation Layer나 Persistence Layer 사이에 위치하므로 빈번한 데이터 가공 및 변환 과정이 이루어진다. 따라서 데이터 가공의 정합성을 체크해주어야 한다.

 

통합 테스트

Buisiness Layer의 테스트는 통합 테스트의 성격으로 많이 작성된다. 

 

Buisiness Layer에서는 Repository와 같은 Persistence Layer 및 다른 Buisiness Layer의 모듈과의 잦은 데이터 교환을 통해서 비즈니스 로직의 구현이 이루어진다.

따라서 의존성을 가지는 Repository, Service를 비롯한 다른 모듈들과와 함께 통합 테스트를 한다.

 

그렇지만 전략적으로 @MockBean을 사용하여 일부 외부 모듈들을 Mocking 처리하는 것이 좋은 경우도 있을 수 있다. 예시로는 다른 Service와의 상호작용이 일방적이라면 의존성을 격리시켜 테스트 주체의 행위에 집중도를 높이는 전략을 사용할 수 있다.

 

특히 Open API 등 외부 시스템과의 연동을 담당하는 모듈 등에 대해서는 Mocking 처리하여 외부 시스템의 결과에 테스트의 결과가 영향을 미치지 않도록 하는 편이 좋다.

 

 

 


@Service
@Transactional(readOnly = true)
@RequiredArgsConstructor
public class ProductService {
    private final ProductRepository productRepository;

    // 상품 추가
    @Transactional
    public ProductResponse createProduct(ProductCreateRequest request) {
        // productNumber : DB에서 마지막 저장된 Product의 상품번호를 읽어와서 + 1
        String nextProductNumber = createNextProductNumber();

        Product product = request.toEntity(nextProductNumber);
        Product savedProduct = productRepository.save(product);

        return ProductResponse.of(savedProduct);

    }

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

 

다음과 같은 ProductService에 대한 테스트 코드를 작성해보자. 

"판매 상품을 추가하는 기능"을 작성하였다. 상품번호는 001부터 시작하여 차례로 증가하여 부여한다.

 

 

 

@SpringBootTest
@ActiveProfiles("test")
class ProductServiceTest {
    @Autowired
    private ProductService productService;

    @Autowired
    private ProductRepository productRepository;

}

 

Service의 테스트는 통합 테스트의 성격을 채택한다고 하였다.

따라서 @SpringBootTest 어노테이션을 통해서 전체 컨텍스트를 로드하여 테스트 코드를 작성하려고 한다.

 

 

 

@SpringBootTest
@ActiveProfiles("test")
class ProductServiceTest {
    @Autowired
    private ProductService productService;

    @Autowired
    private ProductRepository productRepository;

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

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

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

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

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

    }


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

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

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

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

    }

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

}

 

해당 내용을 검증하기 위해서 테스트 코드를 작성하였다. 마찬가지로 경계값으로 테스트 케이스를 나누어주었다.

  • 상품을 처음 등록할 때에는 등록된 상품의 번호는 "001"이다.
  • 상품을 일반적으로 등록할 때에는 등록된 상품의 번호는 "이전 상품의 번호 + 1"이다.

 


테스트 코드는 독립적으로 작동해야 한다.

 

해당 테스트 코드들을 일괄적으로 실행하면 일부 테스트가 실패하는 것을 확인할 수 있다.

이유는 @SpringBootTest에 있다. @DataJpaTest와 달리 트랜잭션이 적용되지 않기 때문에 테스트 종료 이후 롤백되지 않고 이전에 실행된 테스트 코드에서 저장한 상품이 그대로 데이터베이스에 남아있기 때문이다. 

 

클렌징

Cleansing은 이전 테스트의 상태나 데이터를 정리하여, 각 테스트가 독립적으로 실행되고 서로 영향을 주지 않도록 하는 과정을 의미한다. 테스트 환경을 일관된 상태로 유지하기 위해 중요하고 테스트 간의 상호 의존성을 제거하여 신뢰할 수 있는 테스트 결과를 보장한다.

 

 

1. AfterEach를 통한 테스트 데이터 정리

@AfterEach나 @AfterAll 어노테이션을 사용하여, 각 테스트 메서드 실행 후 또는 전체 테스트 클래스 실행 후에 데이터베이스 내역을 지워서 클렌징 작업을 수행하는 방법을 수행할 수 있다.

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

    @Autowired
    private ProductService productService;

    @Autowired
    private ProductRepository productRepository;

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

    @DisplayName("신규 상품을 등록한다. 상품번호는 가장 최근 상품의 상품번호에서 1 증가한 값이다.")
    @Test
    void createProduct(){
		// ...
    }


    @DisplayName("상품이 하나도 없는 경우에는 상품번호는 001이다.")
    @Test
    void createProductWhenProductsIsEmpty(){
		// ...
    }

}

 

2. 트랜잭션 적용

테스트 코드에서 트랜잭션을 적용할 시, 테스트 코드 종료 이후 자동적으로 롤백된다. 일반적으로는 해당 방식을 많이 사용하는 편이다.

주의할 점은 테스트 코드에서는 Transaction이 적용되어 실제 코드에 Transaction이 적용되지 않았는데 마치 적용된 것 처럼 보일 수 있다는 것을 알아두자. 이로 인한 테스트와 실제 코드의 부정합성에 주의해야 한다.

@SpringBootTest
@ActiveProfiles("test")
class ProductServiceTest {
    @Autowired
    private ProductService productService;

    @Autowired
    private ProductRepository productRepository;

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

    @DisplayName("신규 상품을 등록한다. 상품번호는 가장 최근 상품의 상품번호에서 1 증가한 값이다.")
    @Transactional
    @Test
    void createProduct(){
        // ...

    }


    @DisplayName("상품이 하나도 없는 경우에는 상품번호는 001이다.")
    @Transactional
    @Test
    void createProductWhenProductsIsEmpty(){
       // ...

    }


}

 

 

 

 

 


정리

 

 

 

 

 

 

 


References

https://www.inflearn.com/course/practical-testing-%EC%8B%A4%EC%9A%A9%EC%A0%81%EC%9D%B8-%ED%85%8C%EC%8A%A4%ED%8A%B8-%EA%B0%80%EC%9D%B4%EB%93%9C

 

Practical Testing: 실용적인 테스트 가이드 강의 | 박우빈 - 인프런

박우빈 | 이 강의를 통해 실무에서 개발하는 방식 그대로, 깔끔하고 명료한 테스트 코드를 작성할 수 있게 됩니다. 테스트 코드가 왜 필요한지, 좋은 테스트 코드란 무엇인지 궁금하신 모든 분을

www.inflearn.com

 

 

반응형

BELATED ARTICLES

more