외부 시스템을 사용하거나 의존하는 클래스에 대해 단위 테스트를 완벽해서 통합 테스트를 하지 않아도 되는 경우 가짜 객체를 사용해서 테스트 환경을 편리하게 만들 수 있다.
Mock은 흉내 내며 놀리다, 흉내내다 or 가짜의 의미로 spring 에서는 가짜 객체라고 보면 된다.
테스트하면서 의존성 주입을 피하기위해 또는 얽혀있는 의존관계에서 나오는 에러를 방지한 단위 테스트를 위해 사용한다.
(테스트를 하기위해서 준비해야하는 것들이 많을 때 사용한다.)
의존성이나 외부 시스템으로 인해 테스트를 진행하기 어려운 경우 이를 대신해 테스트를 진행할 수 있도록 만들어주는 가짜 객체이다.
영화 촬영 시 위험한 역할을 대신하는 스턴트 더블에서 비롯되었다.
마틴 파울러가 작성한 Mock관련 정보: https://martinfowler.com/articles/mocksArentStubs.html
Mock은 Stub이 아니다!
Stub은 상태 검증(State Verification)이고, Mock은 행위 검증(Behavior Verification)이다.
Test Double에서 Mock을 가장 많이 사용하기 때문에 Mock을 어떻게 사용하는지에 대해서만 정리한다.
Mock(가짜객체은 @MockBean 어노테이션을 사용해서 만들어야하는데 @MockBean 어노테이션에 필요한 설정을 2가지 방법으로 할 수 있다.
@SpringBootTest 어노테이션을 사용하는 방법
@SpringBootTest
class productControllerTest {
@MockBean
private ProductService productService;
...
}
두 번째는 @WebMvcTest 어노테이션을 사용하는 방법이다.
@WebMvcTest(controllers = productController.class)
class productControllerTest {
@MockBean
private ProductService productService;
...
}
@SpringBootTest 어노테이션은 모든 빈을 로드하고 @WebMvcTest는 웹 계층의 컴포넌트만 로드하기때문에 빠르지만 의존성이 있는 경우 개발자가 세팅해야한다.
설정 방법을 선택했으면 @MockBean 어노테이션을 사용하여 가짜 객체를 만들 수 있다.
@MockBean
private ProductService productService;
가짜 객체를 사용해서 테스트할 프로덕션 코드를 보자.
@GetMapping("/api/v1/products/selling")
public ApiResponse<List<ProductResponse>> getSellingProducts() {
return ApiResponse.ok(productService.getSellingProducts());
}
GET요청으로 판매상품을 조회하는 컨트롤러 계층 코드이다.
만약 given에 아무조건을 주지 않고 테스트를 돌려보면 통과된다.
@DisplayName("판매상품을 조회한다.")
@Test
void getSellingProducts() throws Exception {
// given
// when // then
mockMvc.perform(
MockMvcRequestBuilders.get("/api/v1/products/selling")
)
.andDo(MockMvcResultHandlers.print())
.andExpect(MockMvcResultMatchers.status().isOk())
.andExpect(MockMvcResultMatchers.jsonPath("$.code").value("200"))
.andExpect(MockMvcResultMatchers.jsonPath("$.status").value("OK"))
.andExpect(MockMvcResultMatchers.jsonPath("$.message").value("OK"))
.andExpect(MockMvcResultMatchers.jsonPath("$.data").isEmpty());
}
Mocking된 Bean에 아무런 행위를 주지 않으면 해당 메서드의 기본값을 반환한다.
해당 테스트에는 모킹된 서비스 계층의 getSellingProducts() 메서드를 사용하는데 이 메서드에 행위를 지정해주지 않아 Mockito의 기본값인 null값이 출력된다.
응답 결과
MockHttpServletResponse:
Status = 200
Error message = null
Headers = [Content-Type:"application/json"]
Content type = application/json
Body = {"code":200,"status":"OK","message":"OK","data":[]}
Forwarded URL = null
Redirected URL = null
Cookies = []
응답 결과를 보면 data에 null들어가 있는 것을 볼 수 있다.
만약 메서드가 primitive type을 반환하면 0 이나 false를 반환하고 String을 포함한 객체면 null을 반환한다.
Mockito의 기본 값을 알아봤으니 이제 모킹된 메서드의 행위를 지정해보자.
나는 모킹된 productService 클래스의 getSellingProducts() 메서드가 판매상품 리스트를 반환하고 싶다.
모킹된 메서드가 판매상품 리스트를 반환하기 원하기 때문에 먼저 상품을 두개를 만들어 리스트로 만들고 result라 선언했다.
이제 행위를 지정해야 한다.
when() 안에는 행위를 지정하고 싶은 메서드를 입력하고, thenReturn()에는 반환하고 싶은 결과값을 넣는다.
나는 productService.getSellingProducts() 메서드가 설정한 result 를 반환하고 싶기 때문에
Mockito.when(productService.getSellingProducts()).thenReturn(result);
이렇게 코드를 작성했다.
@DisplayName("판매상품을 조회한다.")
@Test
void getSellingProducts() throws Exception {
// given
ProductResponse productResponse1 = ProductResponse
.builder()
.id(1L)
.productNumber("001")
.type(HANDMADE)
.sellingStatus(SELLING)
.name("아이스 아메리카노")
.price(4000)
.build();
ProductResponse productResponse2 = ProductResponse
.builder()
.id(2L)
.productNumber("002")
.type(HANDMADE)
.sellingStatus(SELLING)
.name("따뜻한 아이스 아메리카노")
.price(5000)
.build();
List<ProductResponse> result = List.of(productResponse1, productResponse2);
// Stubbing
Mockito.when(productService.getSellingProducts()).thenReturn(result);
// when then
...
}
이렇게 Mock 객체에 행위를 지정하는 것을 stubbing 이라고 한다.
// when // then
mockMvc.perform(
MockMvcRequestBuilders.get("/api/v1/products/selling")
)
.andDo(MockMvcResultHandlers.print())
.andExpect(MockMvcResultMatchers.status().isOk())
.andExpect(MockMvcResultMatchers.jsonPath("$.code").value("200"))
.andExpect(MockMvcResultMatchers.jsonPath("$.status").value("OK"))
.andExpect(MockMvcResultMatchers.jsonPath("$.message").value("OK"))
.andExpect(MockMvcResultMatchers.jsonPath("$.data").isArray())
.andExpect(MockMvcResultMatchers.jsonPath("$.data.length()").value(2))
.andExpect(MockMvcResultMatchers.jsonPath("$.data[0].productNumber").value("001"))
.andExpect(MockMvcResultMatchers.jsonPath("$.data[0].name").value("아이스 아메리카노"))
.andExpect(MockMvcResultMatchers.jsonPath("$.data[1].productNumber").value("002"))
.andExpect(MockMvcResultMatchers.jsonPath("$.data[1].name").value("따뜻한 아이스 아메리카노"));
then 절은 MockMvc를 사용하여 테스트 한다.
응답이 리스트이기때문에 “$.data[index]”로 응답값에 접근해서 예상 값을 작성한다.
@DisplayName("판매상품을 조회한다.")
@Test
void getSellingProducts() throws Exception {
// given
...
List<ProductResponse> result = List.of(productResponse1, productResponse2);
// Stubbing
Mockito.when(productService.getSellingProducts()).thenReturn(result);
// when // then
...
}
위에 stubbing 한 코드를 보면 조금 이상한 부분이 있다.
바로 given 절에서 Mockito.when() 을 사용하고 있는 것이다!
Mocktio를 사용해도 BDD(행위주도개발)의 기본 스타일을 지키고 싶은 개발자들이 만든게 BDDMockito이다.
BDDMockito의 구현 코드를 보면 Mocktio를 상속받고 있는데 실제 기능상 별 차이는 없다.
다만 BDD 스타일인 given, when, then에 맞게 메서드 이름만 달라졌다.
public class BDDMockito extends Mockito {
...
}
위에서 stubbing한 코드를 BDDMockito로 바꿔 보면
@DisplayName("판매상품을 조회한다.")
@Test
void getSellingProducts() throws Exception {
// given
...
List<ProductResponse> result = List.of(productResponse1, productResponse2);
// Stubbing
// Mockito.when(productService.getSellingProducts()).thenReturn(result);
BDDMockito.given(productService.getSellingProducts()).willReturn(result);
// when // then
...
}
given(), willReturn()처럼 메서드 이름만 달라졌을 뿐 구조는 같은걸 볼 수 있다.
기존 테스트 코드를 BDD 스타일로 개발하고 있다면 Mockito도 BDDMockito를 사용해서 코드 가독성을 높여보자!
이 포스트는 Practical Testing: 실용적인 테스트 가이드 강의를 참고하여 작성했습니다.