sol 개발 블로그 로고
Published on

3-Tier-Architecture 테스트 코드 작성하기 (1)

Authors
  • avatar
    Name
    Chan Sol OH
    Twitter

목차

개요

우리 팀이나 내가 코드를 잘 짰겠지만, 만에 하나 잘못된 코드를 작성한 경우, 혹은 정상적으로 돌아가지만 로직이 잘 못된 경우 그 오류를 찾기는 매우 힘들다. 그렇기 때문에 모듈 단위(메서드 단위)로 테스트하는 단위 테스트와 외부 요인(DB, 네트워크)를 포함한 통합 테스트를 꼭 수행할 필요가 있다. 이번 포스팅에서는 sol-coupang-restapi 과제의 테스트 코드를 짜겠다.

이번 포스팅은 ControllerService의 테스트 코드를 작성한다.

좋은 테스트의 5가지 속성 (F.I.R.S.T)

  1. Fast(빠르게) : 테스트는 빠르게 진행돼야 코드의 피드백을 빠르게 받을 수 있다. 특히 단위 테스트는 메서드 단위의 빠른 피드백이 필요하기 때문에 외부 환경을 사용하지 않거나 단순한 설정을 작성한다.
  2. Isolated(독립적) : 하나의 테스트 코드는 하나의 대상에 대해서만 수행돼야 한다. 만약 한 테스트가 다른 테스트 코드와 상호작용하거나 관리할 수 없는 외부 소스를 사용하게 되면 외부 요인으로 인해 정확한 테스트가 수행되지 않을 수 있다.
  3. Repeatable(반복 가능한) : 테스트는 외부 환경(개발 환경의 변화, 네트워크 연결 여부)과 상환 없이 반복 가능해야한다. 이는 Isolated와 밀접하게 연관돼있다.
  4. Self-Validating(자가 검증) : 테스트 코드는 그 자체만으로도 테스트의 검증이 완료돼야 한다. 따라서 테스트 결과의 정답도 함께 가지고 있어야한다. 그리고 결과값기댓값을 비교하는 작업은 자동화해야한다.
  5. Timely(적시에) : TDD를 위한 속성. 테스트 코드는 애플리케이션 코드를 구현하기 전에 완성돼야한다. 그렇지 않으면 애플리케이션읭 문제를 너무 늦게 발견하게돼고 문제 해결을 위해 소모되는 개발 비용도 커진다.

Given-When-Then 패턴

Given-When-Then 패턴은 테스크 코드를 표현한느 방식 중 하나이다.

  1. Given : 테스트를 수행하기 전에 테스트에 필요한 환경을 설정하는 단계로 테스트에 필요한 변수나 Mock 객체를 통해 특정 상황에 대한 행동을 정의한다.
  2. When : 테스트 목적을 보여주는 단계로 테스트를 통한 결과값을 가져오게 된다.
  3. Then : 테스트 결과를 검증하는 단계로 When에서 나온 결괏값검증하는 작업을 수행한다. 이 테스틀르 통해 나온 결과에서 검증해야 하는 부분이 있다면 이 단계에 포함한다.

Given-When-Then 패턴은 코드의 흐름을 볼 수 있고 기대값이 명확하기 때문에 명세 문서의 역할을 할 수 있어 단위 테스트나 통합 테스트에 자주 사용된다.

어노테이션 기반의 테스트 방식. JUnit

JUnit은 단위 테스트와 통합 테스트르 할 수 있는 테스트 프레임워크로 어노테이션을 이용해서 테스트 코드를 작성할 수 있다. 또한 assert문을 통해 테스트 케이스의 기댓값과 결괏값을 비교할 수 있다. JUnit의 구조는 JUnit Platform가 뼈대 역할하는 인터페이스를 제공하고 JUnit VintageJUnit Jupiter가 구현체 역할을 수행한다.

JUnit의 생명주기

  • @Test : 테스트 코드를 포함한 메서드를 정의
  • @BeforeAll : 테스트 시작 전에 호출되는 메서드를 정의
  • @BeforeEach : 각 테스트 메서드가 실행되기 에 동작하는 메서드를 정의
  • @AfterAll : 테스트를 종료하면서 호출되는 메서드를 정의
  • @AfterEach : 테스트 메서드가 종료되면서 호출되는 메서드를 정의

따라서 테스트가 진행되면서 BeforeAll과 AfterAll는 한번만 실행되고 BeforeEach와 AfterEach는 메서드가 실행될 때마다 실행된다. 아래는 그 예시

13:58:04.924 [Test worker] INFO com.example.solcoupang.TestLifeCycle -- --BeforAll 호출
13:58:04.934 [Test worker] INFO com.example.solcoupang.TestLifeCycle -- --BeforEach 호출
13:58:04.935 [Test worker] INFO com.example.solcoupang.TestLifeCycle -- --test 시작
13:58:04.935 [Test worker] INFO com.example.solcoupang.TestLifeCycle -- --afterEach 호출
13:58:04.937 [Test worker] INFO com.example.solcoupang.TestLifeCycle -- --BeforEach 호출
13:58:04.937 [Test worker] INFO com.example.solcoupang.TestLifeCycle -- --displayNameTest 시작
13:58:04.937 [Test worker] INFO com.example.solcoupang.TestLifeCycle -- --afterEach 호출
13:58:04.938 [Test worker] INFO com.example.solcoupang.TestLifeCycle -- --AfterAll 호출

Controller 테스트

Controller는 개발자가 직접 작성한 코드 중 가장 클라이언트와 가까운 부분이다. 특징으로는 외부로 입,출력 그리고 Service 계층으로 입,출력을 담당한다. Controller를 테스트하기 위해선 Service 계층의 Mock 객체가 필요하다. 그렇지 않으면 비즈니스 로직을 거치지 않은 단순 외부 입출력만 가능하기 때문이다. 테스트할 ProductControllerV1.java는 다음과 같다.

@RestController
@RequiredArgsConstructor
@RequestMapping("/api/v1/products")
@Slf4j
public class ProductControllerV1 {

    private final ProductService productService;

    @GetMapping(value = "/{productId}")
    public BaseResponse<ProductDto> getProductDetail(@PathVariable Long productId){
        ProductDto productDto = productService.findByProductId(productId);
        return new BaseResponse(productDto);
    }
}

위와 같은 메서드는 아래와 같은 조건을 충족시키는 Test가 필요하다.

  1. Service 객체를 대신할 Mock 객체 생성 : BDDMockito.given
  2. Mock 객체 출력 선언 : willReturn
  3. 가상의 HTTP 입력에 따른 Controller 결괏값 : MockMVC.perform
  4. 결괏값과 기댓값 비교 : Mockito.verify
ProductControllerTest.java
@WebMvcTest(ProductControllerV1.class) //슬라이스 테스트
//@RequiredArgsConstructor
public class ProductControllerTest {
    private final MockMvc mockMvc;
    @Autowired
    public ProductControllerTest(MockMvc mockMvc) { // Mock 객체 불러오기
        this.mockMvc = mockMvc;
    }
    // 서비스 Mock 객체 만들기
    @MockBean
    ProductService productService;
    @Test
    @DisplayName("Mock MVC를 통한 Product 데이터 가져오기 테스트")
    void getProductDetail() throws Exception{ // 서비스 Mock 객체의 입출력 선언

      // 컨트롤러가 사용하는 Mock ProductService 객체를 선언
        given(productService.findByProductId(1L))
                .willReturn(
                        ProductDto.builder().productId(1L)
                                .productName("test1")
                                .sellerId(1L)
                                .registrationDate(LocalDate.parse("2023-11-16"))
                                .countryOfManufacture("한국")
                                .weight(1000L)
                                .kcCertificationInformation("한국 : 응! 인정!")
                                .manufacturer("당근")
                                .importer("한국물류")
                                .productContentDtoList(List.of("https:s3//djsfdl","https:s3//djsfdl","https:s3//djsfdl"))
                                .build()
                );

        String productId = "1";

        mockMvc.perform(
                get("/api/v1/products/detail/"+productId) // Controller에 입력 선언
        ).andExpect(status().isOk()) // Controller 출력 검증
                .andExpect(jsonPath("$.isSuccess").exists())
                .andExpect(jsonPath("$.productId").exists())
                .andExpect(jsonPath("$.sellerId").exists())
                .andDo(print());

        verify(productService).findByProductId(1L); // findByProductId 메서드가 실행됐는지 확인
    }
}

위와 같이 테스트를 작성하면 아래와 같은 에러가 발생한다. 그 이유는 출력에 productId가 없기 때문이다.

MockHttpServletResponse:
           Status = 200
    Error message = null
          Headers = [Content-Type:"application/json"]
     Content type = application/json
             Body = {"isSuccess":true,"code":"OK","message":"요청에 성공하였습니다.","result":{"productId":1,"sellerId":1,"registrationDate":"2023-11-16","productName":"test1","countryOfManufacture":"한국","weight":1000,"kcCertificationInformation":"한국 : 응! 인정!","manufacturer":"당근","importer":"한국물류","productContentDtoList":["https:s3//djsfdl","https:s3//djsfdl","https:s3//djsfdl"]}}
    Forwarded URL = null
   Redirected URL = null
          Cookies = []
java.lang.AssertionError: No value at JSON path "$.productId"

아래처럼 검증 부분을 수정하면 정상적인 테스트가 가능하다.

mockMvc.perform(
                get("/api/v1/products/detail/"+productId)
        ).andExpect(status().isOk())
                .andExpect(jsonPath("$.isSuccess").exists())
                .andExpect(jsonPath("$.result.productId").exists())
                .andExpect(jsonPath("$.result.sellerId").exists())
                .andDo(print());

슬라이스 테스트

@WebMvcTest 어노테이션을 이용한 테스트는 슬라이스(Clice) 테스트라고 한다. 슬라이스 테스트는 단위 테스트와 통합 테스트의 중간 개념으로, 3-Tier-Architecture를 기준으로 각 레이어별로 나누어 테스트를 진행한다는 의미를 가진다. 단위 테스트는 의미상 외부와 모든 연결을 차단하고 테스트해야하지만, Controller는 역할 자체가 연결이기 때문에 슬라이스 테스트가 적합하다.

mock.perform을 이용하면 서버로 RESTAPI 요청을 보내는 것처럼 컨트롤러를 테스트할 수 있다. 이번 테스트는 Get 요청에대해서만 테스트했지만, MockMVCRequestBuildersPOST, PUT, DELETE에 매핑되는 메서드를 제공한다.

Service 테스트

Service 객체를 테스트하기 위해 Repository 객체의 Mocking만 수행하고 모든 외부와 연결을 차단한체 단위 테스트를 진행하는 것이 좋을 것이다. 테스트할 ProductService는 다음과 같다.

ProductService.java
@Service
@RequiredArgsConstructor
public class ProductService {
    private final ProductDao productDao;
    private final ProductRepository productRepository;

    public ProductDto findByProductId(Long id){
        Product product = productRepository.findByProductIdWithFetch(id);
        return ProductDto.fromEntity(product);
    }
}

서비스 코드의 테스트는 아래처럼 작성할 수 있다.

ProductServiceTest.java
public class ProductServiceTest { // 단위 테스트, 외부 연결 X

    private ProductRepository productRepository = Mockito.mock(ProductRepository.class);
    private ProductServiceV2 productService;

    @BeforeEach
    public void setUpTest(){ // ProductServiceV2 객체 생성을 위한 생성자를 통한 DI
        productService = new ProductServiceV2(productRepository);
    }

    @Test
    void getProductByIdTest(){
        Seller seller = Seller.builder()
                .sellerId(1L)
                .build();

        Product givenProduct = Product.builder()
                .productId(1L)
                .seller(seller)
                .productName("삼애성플")
                .productContents(new ArrayList<>())
                .build();

        // Repository 객체의 동작을 가정한다.
        Mockito.when(productRepository.findByProductIdWithFetch(1L))
                .thenReturn(givenProduct);

        // Serivce 객체 결과값 얻기
        ProductDto productDto = productService.findByProductId(1L);

        // 결과값과 기대값 비교
        assertEquals(productDto.getProductId(), givenProduct.getProductId());
        assertEquals(productDto.getProductName(), givenProduct.getProductName());
        assertEquals(productDto.getSellerId(), givenProduct.getSeller().getSellerId());

        // 메서드 사용했는지 확인
        verify(productRepository).findByProductIdWithFetch(1L);
    }
}
  1. Product 객체 선언
  2. Mockito로 ProductRepoistory의 입,출력 가정
  3. ProductService의 결과값 생성
  4. Assertions.assertEquals를 이용해서 결과값과 기대값 비교
  5. verify를 통해 메서드 사용 확인

주의할 점

  1. Jupiter는 스프링 빈을 직접 가져오지 못한다. : 테스트 실행주체는 Spring이 아닌 Jupiter이기 때문에 @Autowired를 명시적으로 선언해줘야 Jupiter가 Spring Container에게 빈 주입을 요청한다. 아래는 MockMvc 빈을 주입받는 예시

    Autowired.java
    private final MockMvc mockMvc;
    @Autowired
     public ProductControllerTest(MockMvc mockMvc) {
         this.mockMvc = mockMvc;
     }
    
  2. verify를 메서드가 테스트 중 실행되는지 확인할 수 있다. 만약 Mocking된 메서드가 실행되지 않는다면 아래와 같은 에러가 발생하고 테스트는 실패된다. 에러는 호출되지 않은 메서드와 정상적으로 호출된 메서드를 보여준다.

    Wanted but not invoked:
    productRepository.findByProductId(1L);
    -> at com.example.solcoupang.service.ProductServiceTest.getProductByIdTest(ProductServiceTest.java:59)
    
    However, there was exactly 1 interaction with this mock:
    productRepository.findByProductIdWithFetch(
    1L
    );
    -> at com.example.solcoupang.product.service.ProductServiceV2.findByProductId(ProductServiceV2.java:15)
    
  3. Service의 save를 테스트할 때는 아래와 같이 작성했다. 그러나 문제는 ProductServiceProductRepoistory로 부터 seller 객체를 얻지 못하는 상황이 발생했다.

    ProductServiceTest.java
    ...
    @Test
     void saveProductTest(){
         Mockito.when(productRepository.save(any(Product.class)))
                 .then(returnsFirstArg());
    
         ProductDto productDto = productService.saveProduct(ProductRequestDto.builder()
                                                             .sellerId(1L)
                                                             .productName("삼애성플")
                                                             .productContents(new ArrayList<>())
                                                             .build());
         assertEquals(productDto.getProductName(), "삼애성플");
         assertEquals(productDto.getSellerId(), 1L);
         assertEquals(productDto.getProductContentDtoList(), new ArrayList<>());
    
         verify(productRepository).save(any());
     }
    
    java.lang.NullPointerException: Cannot invoke "com.example.solcoupang.product.domain.Seller.getSellerId()"
    because the return value of "com.example.solcoupang.product.domain.Product.getSeller()" is null
    

    위와 같은 문제를 해결하기 위해선 saveProduct 메서드 내부의 SellerRepository의 동작을 아래처럼 Mokito가정해줘야한다.

    Mockito.when(sellerRepository.findBySellerId(1L))
                 .thenReturn(seller);
    

    위처럼 sellerRepositoryproductRepository의 동작을 가정하고 테스트하면 saveProduct 메서드의 입출력 그리고 내부 비즈니스 로직을 테스트할 수 있다.