본문 바로가기

Development/Spring

[Spring] 스프링 부트에서 테스트 코드 작성하기

스프링 부트의 테스트 설정

  • 스프링 부트는 테스트 환경을 쉽게 설정할 수 있도록 spring-boot-starter-test 프로젝트를 지원한다.
  • 이 프로젝트를 사용하려면 의존성을 추가해야 한다.

Gradle (build.gradle)

dependencies {
	...
	testImplementation 'org.springframework.boot:spring-boot-starter-test'
}

 

Maven (pom.xml)

<dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-test</artifactId>
        <scope>test</scope>
</dependency>

 

스프링 부트에서 제공하는 spring-boot-starter-test 라이브러리는 JUnit, Mockito, assertJ등의 다양한 테스트 도구를 제공한다.

spring-boot-starter-test 라이브러리에서 제공하는 대표적인 라이브러리는 다음과 같다.

  • JUnit 5: 자바 애플리케이션의 단위 테스트를 지원
  • Spring Test & Spring Boot Test: 스프링 부트 애플리케이션에 대한 유틸리티와 통합 테스트를 지원
  • AssertJ: 다양한 단정문(assert)을 지원하는 라이브러리
  • Hamcrest: Matcher를 지원하는 라이브러리
  • Mockito: 자바 Mock 객체를 지원하는 프레임워크
  • JSONassert: JSON용 단정문 라이브러리
  • JsonPath: JSON용 XPath를 지원

테스트 클래스에서 사용되는 어노테이션들

@SpringbootTest

  • 애플리케이션 컨텍스트(Application Context)를 전체 로드하여 실제 애플리케이션 환경과 유사한 환경에서 테스트를 실행할 수 있다.
  • 통합 테스트에 주로 사용되며, 전체 Spring 컨텍스트를 로드하기 때문에 애플리케이션의 규모가 커질수록 테스트 실행 속도가 느려질 수 있다.

@WebMvcTest(테스트 대상 클래스.class)

  • 웹에서 사용되는 요청과 응답에 대한 테스트를 수행할 수 있다.
  • 대상 클래스만 로드해 테스트를 수행하며, 만약 대상 클래스를 추가하지 않으면 @Controller, @RestController, @ControllerAdvice 등의 컨트롤러 관련 빈 객체가 모두 로드된다.
  • @SpringbootTest 보다 가볍게 테스트하기 위해 사용된다.

@MockBean

  • @MockBean은 실제 빈 객체가 아닌 Mock(가짜) 객체를 생성해서 주입하는 역할을 수행한다.
  • @MockBean이 선언된 객체는 실제 객체가 아니기 때문에 실제 행위를 수행하지 않는다.  그렇기 때문에 Mockito의 given() 메서드를 통해 동작을 정의해야 한다.

@Test

  • 테스트 코드가 포함돼 있다고 선언하는 어노테이션이며, JUnit Jupiter에서는 이 어노테이션을 감지해서 테스트 계획에 포함시킨다.

@DisplayName

  • 테스트 메서드의 이름이 복잡해서 가독성이 떨어질 경우 이 어노테이션을 통해 테스트에 대한 표현을 정의할 수 있다.

각 레이어 별 테스트 (슬라이스 테스트)

  • 일반적으로 @WebMvcTest 어노테이션을 사용한 테스트는 슬라이스(Slice) 테스트라고 부른다.
  • 슬라이스 테스트는 단위 테스트와 통합 테스트의 중간 개념으로 이해하면 되는데, 레이어드 아키텍처를 기준으로 각 레이어별로 나누어 테스트를 진행한다는 의미이다.

Controller

  • 컨트롤러는 클라이언트로부터 요청을 받아 요청에 걸맞은 서비스 컴포넌트로 요청을 전달하고, 그 결괏값을 가공해서 클라이언트에게 응답하는 역할을 수행한다. 
  • Controller는 일반적으로 Service 객체의 의존성을 주입받는다. 이때 Controller만 테스트하려는 경우에 Service 객체는 Mock 객체를 활용하여 외부 요인에 영향을 받지 않도록 해야한다. 
@WebMvcTest(ProductController.class)
class ProductControllerTest {

    // 서블릿 컨테이너의 구동 없이, 가상의 MVC 환경에서 모의 HTTP 서블릿을 요청하는 유틸리티 클래스
    @Autowired
    private MockMvc mockMvc;

    // ProductController에서 잡고 있는 Bean 객체에 대해 Mock 형태의 객체를 생성해줌
    @MockBean
    ProductServiceImpl productService;

    // http://localhost:8080/api/v1/product-api/product/{productId}
    @Test
    @DisplayName("MockMvc를 통한 Product 데이터 가져오기 테스트")
    void getProductTest() throws Exception {

        // given : Mock 객체가 특정 상황에서 해야하는 행위를 정의하는 메소드
        given(productService.getProduct(123L)).willReturn(
            new ProductResponseDto(123L, "pen", 5000, 2000));

        String productId = "123";

        // andExpect : 기대하는 값이 나왔는지 체크해볼 수 있는 메소드
        mockMvc.perform(
                get("/product?number=" + productId))
            .andExpect(status().isOk())
            .andExpect(jsonPath(
                "$.number").exists()) // json path의 depth가 깊어지면 .을 추가하여 탐색할 수 있음 (ex : $.productId.productIdName)
            .andExpect(jsonPath("$.name").exists())
            .andExpect(jsonPath("$.price").exists())
            .andExpect(jsonPath("$.stock").exists())
            .andDo(print());

        // verify : 해당 객체의 메소드가 실행되었는지 체크해줌
        verify(productService).getProduct(123L);
    }

    // http://localhost:8080/api/v1/product-api/product
    @Test
    @DisplayName("Product 데이터 생성 테스트")
    void createProductTest() throws Exception {
        //Mock 객체에서 특정 메소드가 실행되는 경우 실제 Return을 줄 수 없기 때문에 아래와 같이 가정 사항을 만들어줌
        given(productService.saveProduct(new ProductDto("pen", 5000, 2000)))
            .willReturn(new ProductResponseDto(12315L, "pen", 5000, 2000));

        ProductDto productDto = ProductDto.builder()
            .name("pen")
            .price(5000)
            .stock(2000)
            .build();

        Gson gson = new Gson();
        String content = gson.toJson(productDto);

        mockMvc.perform(
                post("/product")
                    .content(content)
                    .contentType(MediaType.APPLICATION_JSON))
            .andExpect(status().isOk())
            .andExpect(jsonPath("$.number").exists())
            .andExpect(jsonPath("$.name").exists())
            .andExpect(jsonPath("$.price").exists())
            .andExpect(jsonPath("$.stock").exists())
            .andDo(print());

        verify(productService).saveProduct(new ProductDto("pen", 5000, 2000));
    }

}

 

  • Gson은 구글에서 개발한 JSON 파싱 라이브러리로서 자바 객체를 JSON 문자열로 변환하거나 JSON 문자열을 자바 객체로 변환하는 역할을 한다.

perform(), andExpect()

  • 서버로 URL 요청을 보내는 것처럼 통신 테스트 코드를 작성해서 컨트롤러를 테스트할 수 있다.
  • MockMvcRequestBuilders에서 제공하는 HTTP 메서드로 URL을 정의해서 사용한다.
  • 결과값으로 ResultActions 객체가 리턴되는데, andExpect() 메서드를 사용해 결괏값 검증을 할 수 있다.
  • andExpect()는 ResultMatcher를 활용하는데, 이를 위해 MockMvcResultMatchers 클래스에 정의돼 있는 메서드를 활용해 생성할 수 있다.

MockMvcRequestBuilders

  • Http 메서드인 GET, POST, PUT, DELETE에 매핑되는 메서드를 제공한다.
  • 이 메서드는 MockMVcServletRequestBuilder 객체를 리턴하며, HTTP 요청 정보를 설정할 수 있게 해준다.

Service

아래는 Service 레이어에서 테스트 코드 예시 이다.

@ExtendWith(SpringExtension.class) 
@Import({ProductServiceImpl.class})
public class ProductServiceTest {

	// 스프링 Bean에 Mock 객체를 등록해서 주입받는 방식
    // @MockBean
    // ProductRepository productRepository;

	// 스프링 Bean에 ProductRepository를 등록하지 않고, 직접 객체를 생성하는 방식
    private ProductRepository productRepository = Mockito.mock(ProductRepository.class);
    private ProductServiceImpl productService;

    @BeforeEach
    public void setUpTest() {
        productService = new ProductServiceImpl(productRepository);
    }

    @Test
    void getProductTest() {
        // given
        Product givenProduct = new Product();
        givenProduct.setNumber(123L);
        givenProduct.setName("펜");
        givenProduct.setPrice(1000);
        givenProduct.setStock(1234);

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

    // 예제 7.12
    @Test
    void saveProductTest() {
        // given
        Mockito.when(productRepository.save(any(Product.class)))
            .then(returnsFirstArg());

        // when
        ProductResponseDto productResponseDto = productService.saveProduct(
            new ProductDto("펜", 1000, 1234));

        // then
        Assertions.assertEquals(productResponseDto.getName(), "펜");
        Assertions.assertEquals(productResponseDto.getPrice(), 1000);
        Assertions.assertEquals(productResponseDto.getStock(), 1234);

        verify(productRepository).save(any());
    }
}
  • 위 코드에서 any() 는 Mockito의 ArgumentMatchers에서 제공하는 메서드로서 Mock 객체의 동작을 정의하거나 검증하는 단계에서 조건으로 특정 매개변수의 전달을 설정하지 않고, 실행만을 확인하거나 좀 더 큰 범위의 클래스 객체를 매개변수로 전달받는 등의 상황에서 사용한다. 
  • @ExtendWith(SpringExtension.class)를 사용해 JUnit5의 테스트에서 스프링 테스트 컨텍스트를 사용하도록 설정한다.
  • @Import({ProductServiceImpl.class})를 사용해 @Autowired 어노테이션으로 주입받는 ProductSerivce를 주입받는다.

Repository

  • Repository(리포지토리)는 데이터베이스와 가장 가까운 레이어이며, Repository 테스트는 구현하는 목적에 대해 고민하고 작성해야 한다.
  • findById(), save() 같은 기본 메서드에 대한 테스트는 큰 의미가 없다. 왜냐하면 Repository의 기본 메서드는 테스트 검증을 마치고 제공된 것이기 때문이다.
  • 데이터베이스의 연동 여부는 테스트 시 고려해 볼 사항입니다.  데이터베이스는 외부 요인이기 때문에, 단위 테스트를 고려한다면 데이터베이스를 제외할 수 있다.
  • 또는 테스트용 데이터베이스를 사용할 수도 있다. 다만 데이터베이스의 적재를 신경 써야 하는 테스트 환경이라면 잘못된 테스트 코드가 실행되면서 발생할 수 있는 사이드 이펙트를 고려해서 데이터베이스 연동 없이 테스트하는 편이 좋을 수도 있다. 

@DataJpaTest

이 어노테이션이 붙은 테스트 클래스는 JPA와 관련된 설정만 로드해서 테스트를 진행합니다. 기본적으로 @Transactional 어노테이션을 포함하고 있어 테스트 코드가 종료되면 자동으로 데이터베이스의 롤백이 진행된다. 기본값으로 임베디드 데이터베이스를 사용하며, 다른 데이터베이스를 사용하려면 별도의 설정을 거쳐 사용 가능합니다. 

 

@AutoConfigureTestDatabase(replace = 설정값)

@AutoConfigureTestDatabase는 SpringBoot Test에서 데이터베이스 관련 테스트를 진행할 때 사용하는 어노테이션이다.

이 어노테이션이 제공하는 'replace' 속성은 어떤 데이터베이스 설정을 사용할지 정의하는데 사용된다.

'replace' 속성에 설정할 수 있는 값은 다음과 같다.

  1. Replace.NONE: 이 설정은 테스트 환경에서 기본 데이터베이스 연결 설정을 그대로 사용하도록 합니다. 즉, spring.datasource.*에 지정된 데이터베이스 연결을 사용하게 됩니다.
  2. Replace.ANY: 이 설정은 내장형 데이터베이스(H2, HSQL, Derby 등)로 자동 구성을 대체하도록 합니다. 만약 기본 데이터베이스 연결 설정이 내장형 데이터베이스를 가리키고 있지 않다면, 이 설정은 아무런 영향을 주지 않습니다.
  3. Replace.AUTO_CONFIGURED: 이 설정은 테스트 환경에서 자동 구성된 데이터베이스를 사용하도록 합니다. 이는 기본적으로 Replace.ANY와 같지만, 테스트 환경에서 데이터베이스 연결을 재정의하지 않으면 내장형 데이터베이스를 사용하도록 합니다.

따라서, '@AutoConfigureTestDatabase(replace = 설정값)'를 이용하면 테스트 환경의 데이터베이스 설정을 유연하게 변경할 수 있다.

@DataJpaTest
@AutoConfigureTestDatabase(replace = Replace.NONE)
class ProductRepositoryTest {

    @Autowired
    private ProductRepository productRepository;

    @Test
    void save() {
        // given
        Product product = new Product();
        product.setName("펜");
        product.setPrice(1000);
        product.setStock(1000);

        // when
        Product savedProduct = productRepository.save(product);

        // then
        assertEquals(product.getName(), savedProduct.getName());
        assertEquals(product.getPrice(), savedProduct.getPrice());
        assertEquals(product.getStock(), savedProduct.getStock());
    }

}

참고