Spring & Spring boot

[Springboot] 테스트 코드 - 7 - Service Unit Test 서비스 단위 테스트

jh4dev 2024. 8. 7. 14:45
본 포스팅은, 단위 테스트 (Unit Test) 와 관련된 내용입니다.
작성된 내용 외에도 다양한 방법이 있으며, 정답은 없습니다.
<목차>

1. 서비스 단위 테스트
2. 테스트 코드
3. GitHub

 

SpringBoot 환경에서 전체적인 비즈니스 로직이 정상적으로 동작하는지 테스트 하기 위해서는 통합 테스트를 해야 하고, 각 모듈을 테스트 하고 싶다면, 단위 테스트를 진행해야 한다.

일반적인 Spring MVC 를 사용하는 구조라면, 일부 레이어에서만 단위 테스트를 수행하는게 무의미하거나 어려운 경우가 많다.

하지만! Mock 객체를 활용하여, 하위 레이어와 무관하게 동작하도록, 레이어 별 단위 테스트 코드를 작성해보도록 하겠다. 

 

앞서 JPA Example 에서 작성한 소스를 기반으로 작성된 포스팅이며,
Controller - Service - DAO - Repository 구조에서, DAO 를 제외한 구조로 재구성 하였다.

2024.08.04 - [Programming/Spring & Spring boot] - [Spring Boot] JPA - 4. CRUD Example

 

[Spring Boot] JPA - 4. CRUD Example

1. Environment2. Sources3. Test Springboot - Basic JPA CRUD API Example JPA를 바로 사용하기 보단, 동작 원리에 대해 알고 사용하는 것이 도움이 된다.JPA 관련 포스팅https://jh4dev.tistory.com/24 [Spring Boot] JPA - 1. ORM

jh4dev.tistory.com

 

[서비스 단위 테스트]

  • MVC 패턴에서 Service 는 비즈니스 로직을 담당하는 레이어로, 프레젠테이션 레이어(컨트롤러) 와 데이터 접근 레이어(DAO or Repository) 의 중간 레이어 이다.
  • Spring 환경의 Service 단위 테스트에서 고려할 부분으로는 아래 내용이 있다. 
    • 단위 테스트를 위해, 외부 요인을 모두 배제
      • 컨트롤러에서 서비스의 메서드를 호출한다는 가정 하에 테스트를 하는 것이며, 서비스의 비즈니스 로직을 테스트하기 위함이다.
      • 서비스 메서드 내, DAO 또는 Repository 의 의존성을 주입받는 부분은 Mock 객체로 대체한다.
    • Repository 객체 DI (의존성 주입)
      • 스프링에 의존하는 방법과 Mock 객체를 활용하는 방법에 대해 알아볼 예정이다.
    • 테스트의 목적 확인
      • Assertion 을 활용하여 테스트의 목적을 달성하는지 확인할 예정이다.
      • Assertion 관련 내용은, 아래를 참고

2024.08.06 - [Programming/Spring & Spring boot] - [Springboot] 테스트 코드 - 4 - Assertion 단정문

 

[Springboot] 테스트 코드 - 4 - Assertion 단정문

1. Assertion2. Jupiter Assertion 3. Jupiter Assertion Methods 테스트 코드 작성에 앞서,JUnit 라이브러리와 함께 사용할 수 있는 Jupiter Assertion 에 대해 알아보자. [ Assertion ]Jupiter Assertion 라이브러리를 알아보기

jh4dev.tistory.com

 


[테스트 코드]

테스트를 진행할 Service 는 다음과 같다.

    import com.springboot.test.data.dto.ProductDto;
    import com.springboot.test.data.dto.ProductResponseDto;
    import com.springboot.test.data.entity.Product;
    import com.springboot.test.data.repository.ProductRepository;
    import com.springboot.test.service.ProductService;
    import lombok.extern.slf4j.Slf4j;
    import org.springframework.beans.factory.annotation.Autowired;
    import org.springframework.stereotype.Service;

    import java.util.Optional;

    @Service
    @Slf4j
    public class ProductServiceImpl implements ProductService {

        private final ProductRepository productRepository;

        @Autowired
        public ProductServiceImpl(ProductRepository productRepository) {
            this.productRepository = productRepository;
        }

        @Override
        public ProductResponseDto getProduct(Long number) {
            log.info("[getProduct()] product number = {}", number);
            Optional<Product> op = productRepository.findById(number);
            Product product = op.get();

            log.info("[getProduct()] find product number : {}, name : {}", product.getNumber(), product.getName());

            return ProductResponseDto.builder()
                    .number(product.getNumber())
                    .name(product.getName())
                    .price(product.getPrice())
                    .stock(product.getStock())
                    .build();
        }

        @Override
        public ProductResponseDto saveProduct(ProductDto productDto) {
            log.info("[saveProduct()] productDto : {}", productDto.toString());

            Product product = Product.builder()
                .name(productDto.getName())
                .price(productDto.getPrice())
                .stock(productDto.getStock())
                .build();

            Product savedProduct = productRepository.save(product);
            log.info("[saveProduct()] savedProduct : {}", savedProduct.toString());

            return ProductResponseDto.builder()
                    .number(savedProduct.getNumber())
                    .name(savedProduct.getName())
                    .price(savedProduct.getPrice())
                    .stock(savedProduct.getStock())
                    .build();
        }
    }

 

 

테스트할 ProductService 를 살펴보자.

  • DI (의존성 주입)
    • ProductRepository 를 주입받아 사용하고 있다.
  • getProduct()
    • 컨트롤러로부터 전달받은 Product Entity 의 Id 필드인 "number" 로 ProductRepository 를 통하여 Product 인스턴스를 조회하여 리턴한다.
  • saveProduct()
    • 컨트롤러로부터 전달받은 ProductDto 를 ProductEntity 로 변환한 후, ProductRepository 를 통하여 DB에 저장하고, 저장된 Product 인스턴스를 ProductResponseDto로 변환한 후 리턴한다.

 

 

<Mock 객체를 활용하는 방법>

이러한 ProductService 에 대하여, Mock 객체를 사용하는 단위 테스트 코드는 아래와 같이 작성할 수 있다.

    public class ProductServiceTest {

        //Repository 를 Mock 객체로 주입
        private ProductRepository productRepository = Mockito.mock(ProductRepository.class);

        //ProductService 
        private ProductServiceImpl productService;

        //주입받은 Repository 를 초기화
        /**
         * JUnit 생명주기 어노테이션
         * BeforeEach   각 테스트 메서드가 실행되기 전에 호출되는 메서드
         * BeforeAll    테스트 시작 전, 1회 호출되는 메서드
         * AfterEach    각 테스트 메서드가 실행된 후에 호출되는 메서드
         * AfterAll     테스트 종료 후, 1회 호출되는 메서드
         * */
        @BeforeEach
        public void setUpTest() {
            productService = new ProductServiceImpl(productRepository);
        }

        @Test
        void getProductTest() {

            //Given
            Product givenProduct = Product.builder().number(123L).name("파인애플펜").price(4000).stock(200).build();
            Mockito.when(productRepository.findById(123L)).thenReturn(Optional.of(givenProduct));

            //When
            ProductResponseDto productResponseDto = productService.getProduct(123L);

            //Then        
            Assertions.assertEquals(productResponseDto.getNumber(), givenProduct.getNumber());
            Assertions.assertEquals(productResponseDto.getName(), givenProduct.getName());
            Assertions.assertEquals(productResponseDto.getPrice(), givenProduct.getPrice());
            Assertions.assertEquals(productResponseDto.getStock(), givenProduct.getStock());

            verify(productRepository).findById(123L);
        }

        @Test
        void saveProductTest() {
            //Given
            Mockito.when(productRepository.save(any(Product.class))).then(returnsFirstArg());

            //When
            ProductResponseDto productResponseDto = productService.saveProduct(new ProductDto("연필", 1400, 420));

            //Then
            Assertions.assertEquals(productResponseDto.getName(), "연필");
            Assertions.assertEquals(productResponseDto.getPrice(), 1400);
            Assertions.assertEquals(productResponseDto.getStock(), 420);

            verify(productRepository).save(any());
        }
    }

 

작성된 테스트 코드에 대해 하나씩 살펴보자.

  1. 외부 요인 배제
    • 외부 요인을 모두 배제하기 위해, @SpringBootTest, @WebMvcTest 등의 어노테이션을 사용하지 않는다.
  2. Mockito.mock()
    • Mockito의 mock() 메서드를 통해 ProductRepository 를 Mock 객체로 주입받았다.
    • Spring 컨텍스트에 등록하지 않고 직접 객체를 초기화하여 사용하는 방식
  3. ProductService 초기화
    • JUnit 생명주기 어노테이션인 @BeforeEach 를 사용하여,
      매 테스트가 실행되기 전, ProductService 객체를, 주입받은 Mock ProductRepository 를 기반으로 초기화하여 테스트를 진행한다.
  4. Given - When - Then 패턴
    • getProductTest() 메서드로 설명하겠다.
    • Given
      • 25번 라인
        ProductService 에서 호출하고 있는 ProductRepository.findById() 메서드를 정의한다.
        Mockito 의 when() 메서드를 사용하여 findById() 를 통해 전달받을 Product 엔티티 인스턴스를 정의한다.
    • When
      • 29번 라인
        ProductService.getProduct() 테스트 진행
    • Then
      • 32번 라인 
      • When 단계에서 리턴받은 productResponseDto 에 대한 값을 검증한다.
        Jupiter Assertions 을 사용하여 리턴된 결과를 검증

<스프링 컨테이너를 활용하는 방법>

 

 

    @ExtendWith(SpringExtension.class) //스프링 컨텍스트를 사용하도록 설정
    @Import({ProductServiceImpl.class})
    public class ProductServiceTest2 {

        @MockBean
        ProductRepository productRepository;

        @Autowired
        ProductService productService;

        @Test
        void getProductTest() {
            Product givenProduct = Product.builder().number(123L).name("파인애플펜").price(4000).stock(200).build();

            Mockito.when(productRepository.findById(123L)).thenReturn(Optional.of(givenProduct));
            ProductResponseDto productResponseDto = productService.getProduct(123L);

            Assertions.assertEquals(productResponseDto.getNumber(), givenProduct.getNumber());
            Assertions.assertEquals(productResponseDto.getName(), givenProduct.getName());
            Assertions.assertEquals(productResponseDto.getPrice(), givenProduct.getPrice());
            Assertions.assertEquals(productResponseDto.getStock(), givenProduct.getStock());

            verify(productRepository).findById(123L);
        }

        @Test
        void saveProductTest() {
            Mockito.when(productRepository.save(any(Product.class))).then(returnsFirstArg());

            ProductResponseDto productResponseDto = productService.saveProduct(new ProductDto("연필", 1400, 420));

            Assertions.assertEquals(productResponseDto.getName(), "연필");
            Assertions.assertEquals(productResponseDto.getPrice(), 1400);
            Assertions.assertEquals(productResponseDto.getStock(), 420);

            verify(productRepository).save(any());
        }
    }

 

각 테스트 메서드의 내용은 동일하며, 코드 상단부에 대한 설명을 추가하겠다.

  • ProductRepository 초기화
    • 스프링의 테스트 어노테이션을 통하여 Mock 객체를 생성하고 의존성을 주입받고 있다.
    • Mock 객체를 사용한다는 점은 동일하지만, @MockBean 을 사용하는 방식은 Spring Context 에 Mock 객체를 등록하여 주입받는 형식이다.
    • @ExtendWith(SpringExtension.class) 어노테이션을 사용하여, Spring Test Context 를 사용하도록 설정
    • ProductService 의존성 주입을 위해, @Autowired 어노테이션을 사용하며, 이를 위해 @Import 어노테이션으로 import 해준다. 

<테스트 결과>

  • getProductTest()

  • saveProductTest()

 


[GitHub]

전체 소스
ProductService Test 소스

 


[참고 도서]

 

<스프링 부트 핵심 가이드>
저자 : 장정우

출판사 : 위키북스