스프링 부트의 테스트 설정
- 스프링 부트는 테스트 환경을 쉽게 설정할 수 있도록 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' 속성에 설정할 수 있는 값은 다음과 같다.
- Replace.NONE: 이 설정은 테스트 환경에서 기본 데이터베이스 연결 설정을 그대로 사용하도록 합니다. 즉, spring.datasource.*에 지정된 데이터베이스 연결을 사용하게 됩니다.
- Replace.ANY: 이 설정은 내장형 데이터베이스(H2, HSQL, Derby 등)로 자동 구성을 대체하도록 합니다. 만약 기본 데이터베이스 연결 설정이 내장형 데이터베이스를 가리키고 있지 않다면, 이 설정은 아무런 영향을 주지 않습니다.
- 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());
}
}
참고
- https://github.com/wikibook/springboot/tree/main/chapter7_test/src/test/java/com/springboot/test
- 스프링 부트 핵심 가이드 "스프링 부트를 활용한 애플리케이션 개발 실무" , 장정우, 2022
'Development > Spring' 카테고리의 다른 글
[Spring] JPA의 정렬과 페이징 처리 (0) | 2024.02.29 |
---|---|
[Spring] JPQL (JPA Query Language)과 쿼리 메서드 (1) | 2024.02.28 |
[Spring] Lombok (0) | 2024.02.21 |
[Spring] OpenSessionInViewFilter (0) | 2024.02.21 |
[Spring] Controller와 API 구현하기 (0) | 2024.02.20 |