Mockito란?
Mockito는 Java에서 단위 테스트를 작성할 때 널리 사용되는 Mocking 프레임워크이다. Mocking은 테스트 중에 실제 객체 대신 가짜 객체(Mock)를 사용하여, 테스트 환경에서 특정 동작을 시뮬레이션하는 것을 의미한다.
Mock 객체가 특정 메서드를 호출했을 때, 미리 정의된 값을 반환하도록 설정하는 것을 stub이라 한다.
Mockito에서는 다음과 같은 stub 메소드를 제공한다.
- doReturn(): 가짜 객체가 특정한 값을 반환해야 하는 경우 사용
- doNothing(): 가짜 객체가 아무 것도 반환하지 않는 경우에 사용 (void 메소드)
- doThrow(): 가짜 객체가 예외를 발생시키는 경우에 사용
이 외에도 BDDMockito라는 것도 있는데, BDDMockito는 BDD(Behavior Driven Development) 스타일의 테스트 작성을 지원하는 Mockito의 확장이다. 사실상 Mockito를 기반으로 하고 있어 사용 방법이 거의 동일하다. BDD 스타일의 테스트는 비즈니스 로직에 더 가까운 표현을 가능하게 한다. (BDDMockito란?)
Mockito는 단독 테스트 프레임워크가 아니기 때문에, JUnit과 결합해 사용하려면 추가적인 설정이 필요하다. JUnit5에서는 @ExtendWith(SpringExtension.class) 어노테이션을 사용하여 Mockito와 JUnit을 결합할 수 있다.
@ExtendWith(SpringExtension.class)
public class ProductServiceImplTest {
}
given/when/then 패턴
given-when-then 패턴은 최근 단위 테스트에서 널리 사용되는 패턴으로, 테스트 코드의 가독성과 구조를 향상시키기 위해 1개의 단위 테스트를 세 단계로 나누어 작성한다. 이를 BDD Style 패턴이라 한다.
- given (준비):
- 테스트에서 사용할 데이터 또는 상황을 미리 준비하는 단계
- Mock 객체나 테스트할 메서드의 입력값 등을 설정
- when (실행):
- 실제로 테스트하고자 하는 동작(함수나 메서드)을 실행하는 단계
- 준비된 데이터를 바탕으로 특정 메서드를 호출하거나 이벤트를 발생
- then (검증):
- 실행된 결과를 검증하는 단계
- 실행 결과가 예상한 대로 나오는지 확인하며, 이를 통해 테스트가 성공 또는 실패하는지를 판별
다음과 같은 상품목록을 조회하는 ProductController가 있다.
이에 대한 단위 테스트를 진행한다고 하면, Junit5와 Mockito 결합 → 의존성 주입 → 테스트 코드 작성 순으로 진행할 수 있다.
@RestController
@RequestMapping("api/v1/product-api")
public class ProductController {
private final Logger LOGGER = LoggerFactory.getLogger(ProductController.class);
private ProductService productService;
private HttpHeaders headers;
@Autowired
public ProductController(ProductService productService) {
this.productService = productService;
}
// <http://localhost:8080/api/v1/product-api/product/{productId}>
@GetMapping(value = "/product/{productId}")
public ResponseEntity<ProductDto> getProduct(@PathVariable Long productId) {
long startTime = System.currentTimeMillis();
LOGGER.info("[getProduct] perform {} of Around Hub API.", "getProduct");
ProductDto productDto = productService.getProduct(productId);
LOGGER.info(
"[getProduct] Response :: productId = {}, productName = {}, productPrice = {}, productStock = {}, Response Time = {}ms",
productDto.getProductId(),
productDto.getProductName(),
productDto.getProductPrice(),
productDto.getProductStock(),
(System.currentTimeMillis() - startTime));
HttpHeaders headers = new HttpHeaders();
headers.add("TestHeader", "TestValue");
// return productDto;
return ResponseEntity.status(200).headers(headers).body(productDto);
}
상품목록 조회 테스트
1. Junit5와 Mockito 결합
앞서 말했듯이, Mockito는 단독 테스트 프레임워크가 아니기 때문에, JUnit과 결합을 해주어야 한다.
@ExtendWith(SpringExtension.class)
public class ProductControllerTest {
}
2. 의존성 주입
ProductController는 ProductService를 의존성 주입받고 있기 때문에, 테스트 코드에서도 이 의존성을 주입받아야 정상적인 테스트를 수행할 수 있다.
Mockito에서는 가짜 객체의 의존성 주입을 위해 세 가지 주요 어노테이션을 사용한다.
- @Mock: 이 어노테이션은 가짜 객체를 생성하여 반환하는 역할
- @Spy: Stub하지 않은 메소드들은 원본 메소드를 그대로 사용
- @InjectMocks: @Mock 또는 @Spy로 생성된 가짜 객체를 자동으로 주입
따라서, 테스트 대상인 ProductController 에는 가짜 객체 주입을 위한 @InjectMocks을 붙이고, 가짜 객체인 ProductService 에는 @Mock 어노테이션을 붙여주면 된다.
@ExtendWith(SpringExtension.class)
public class ProductControllerTest {
@InjectMocks
private ProductController productController;
@Mock
ProductService productService;
}
3. MockMVC 객체 생성
컨트롤러를 테스트하기 위해서는 HTTP 호출이 필요한데, 이에 스프링에서는 MockMVC를 제공한다.
@ExtendWith(SpringExtension.class)
public class ProductControllerTest {
@InjectMocks
private ProductController productController;
@Mock
ProductService productService;
private MockMvc mockMvc;
@BeforeEach
public void init() {
mockMvc = MockMvcBuilders.standaloneSetup(productController).build();
}
}
위와 같이 MockMVC를 생성할 수도 있지만, SpringBoot가 제공하는 @WebMvcTest를 이용하여 더욱 간편하게 사용할 수도 있다. 이를 이용하면 MockMvc 객체가 자동으로 생성될 뿐만 아니라, 웹 계층 테스트에 필요한 요소들을 모두 빈으로 등록해 스프링 컨텍스트 환경을 구성한다.
이때, @Mock과 @Spy 대신 @MockBean과 @SpyBean을 사용해야 한다.
@WebMvcTest(ProductController.class)
public class ProductControllerTest {
@Autowired
private MockMvc mockMvc;
@MockBean
ProductService productService;
}
하지만 간편하게 사용할 수 있다 하더라도 무분별하게 사용하면 안 된다. 스프링은 내부적으로 컨텍스트를 캐싱하여 동일한 테스트 환경에서는 이를 재사용하지만, @WebMvcTest는 특정 컨트롤러만 빈으로 생성하기 때문에 캐싱의 이점을 제대로 활용하지 못한다. 이로 인해 새로운 컨텍스트를 생성해야 하므로 테스트 속도가 느려질 수 있다.
4. 테스트 코드 작성(given)
productService.getProduct(12315L)가 호출되면 미리 정의된 결과인 new ProductDto(12315L, "pen", 5000, 2000)를 반환하도록 지정한다.
@ExtendWith(SpringExtension.class)
public class ProductControllerTest {
@Test
@DisplayName("Product 데이터 가져오기 테스트")
void getProductTest() throws Exception {
// given: Mock 객체가 특정 상황에서 해야 하는 행위를 정의하는 메소드
doReturn(new ProductDto(12315L, "pen", 5000, 2000))
.when(productService).getProduct(12315L);
String productId = "12315";
}
5. 테스트 코드 작성(when)
HTTP GET 요청을 /api/v1/product-api/product/{productId} 경로로 보낸다. 이 부분이 실제로 Controller의 메서드를 호출하는 역할을 한다.
@ExtendWith(SpringExtension.class)
public class ProductControllerTest {
@Test
@DisplayName("Product 데이터 가져오기 테스트")
void getProductTest() throws Exception {
// given: Mock 객체가 특정 상황에서 해야 하는 행위를 정의하는 메소드
doReturn(new ProductDto(12315L, "pen", 5000, 2000))
.when(productService).getProduct(12315L);
String productId = "12315";
// when: 실제 테스트할 행위를 정의
ResultActions resultActions = mockMvc.perform(get("/api/v1/product-api/product/" + productId));
}
6. 테스트 코드 작성(then)
응답 상태 코드, JSON 응답 데이터의 특정 필드 존재 여부 등을 검증한다. exists()는 해당 필드가 존재하는지 여부만을 검증한다.
@ExtendWith(SpringExtension.class)
public class ProductControllerTest {
@Test
@DisplayName("Product 데이터 가져오기 테스트")
void getProductTest() throws Exception {
// given: Mock 객체가 특정 상황에서 해야 하는 행위를 정의하는 메소드
doReturn(new ProductDto(12315L, "pen", 5000, 2000))
.when(productService).getProduct(12315L);
String productId = "12315";
// when: 실제 테스트할 행위를 정의
ResultActions resultActions = mockMvc.perform(get("/api/v1/product-api/product/" + productId));
// then: 기대하는 결과를 검증
MvcResult mvcRsult = resultActions
.andExpect(status().isOk()) // 응답 상태가 200 OK인지 확인
.andExpect(jsonPath("$.productId").exists()) // JSON 응답에 productId 필드가 존재하는지 확인
.andExpect(jsonPath("$.productName").exists()) // productName 필드가 존재하는지 확인
.andExpect(jsonPath("$.productPrice").exists()) // productPrice 필드가 존재하는지 확인
.andExpect(jsonPath("$.productStock").exists()) // productStock 필드가 존재하는지 확인
.andDo(print()) // 요청 및 응답 정보를 출력 (테스트 결과 확인용)
.andReturn(); // MvcResult 객체로 결과를 반환받음
// verify: 해당 객체의 메소드가 실행되었는지 체크
verify(productService).getProduct(12315L);
}
위의 테스트에서는 then 단계에서 필드의 존재만 검증을 하고 있다. 그렇다면 ProductDto객체에 (12315L, "pen", 5000, 2000)가 정확하게 들어가 있는지 검증할 필요가 있는데, 그 부분은productService 에서 수행한다.
productService
@Service
public class ProductServiceImpl implements ProductService {
ProductDataHandler productDataHandler;
@Autowired
public ProductServiceImpl(ProductDataHandler productDataHandler) {
this.productDataHandler = productDataHandler;
}
@Override
public ProductDto getProduct(Long productId) {
Product productEntity = productDataHandler.getProductEntity(productId);
ProductDto productDto = new ProductDto(productEntity.getProductId(), productEntity.getProductName(), productEntity.getProductPrice(), productEntity.getProductStock());
return productDto;
}
}
테스트 코드는 아래와 같이 작성할 수 있는데, 우리는 given 단계에서 생성한 Product 객체가 getProductEntity 메소드를 통해 올바르게 반환되는지를 확인해야 한다. 이때, 사용할 수 있는 것이 Assertions이다. 간단하게 설명하면 JUnit 테스트에서 결과를 검증하는 데 사용되는 메소드 모음인데, 테스트의 기대 결과와 실제 결과를 비교하여 일치 여부를 확인할 수 있다.
Assertions 메소드
assertEquals(expected, actual) | 두 값이 같은지 비교, 같으면 테스트 통과. |
assertNotEquals(expected, actual) | 두 값이 다르면 테스트 통과. |
assertTrue(condition) | 조건이 true인지 확인 |
assertFalse(condition) | 조건이 false인지 확인 |
assertNull(object) | 객체가 null인지 확인 |
assertNotNull(object) | 객체가 null이 아닌지 확인 |
assertSame(expected, actual) | 두 객체가 동일한 인스턴스인지 확인 |
assertNotSame(expected, actual) | 두 객체가 다른 인스턴스인지 확인 |
assertArrayEquals(expectedArray, actualArray) | 두 배열이 같은 요소를 가지고 있는지 확인 |
아래 코드에서는 Assertions.assertEquals를 사용하여 productDto의필드 값들이 productDto를 생성할 때 넣었던 값과 일치하는지 확인하고 있다.
@ExtendWith(SpringExtension.class)
public class ProductServiceImplTest {
@InjectMocks
ProductServiceImpl productService;
@Mock
ProductDataHandlerImpl productDataHandler;
@Test
public void getProductTest() {
// given
doReturn(new Product(12315L, "pen", 5000, 2000))
.when(productDataHandler).getProductEntity(12315L);
// when
ProductDto productDto = productService.getProduct(12315L);
// then
Assertions.assertEquals(productDto.getProductId(), 12315L);
Assertions.assertEquals(productDto.getProductName(), "pen");
Assertions.assertEquals(productDto.getProductPrice(), 5000);
Assertions.assertEquals(productDto.getProductStock(), 2000);
verify(productDataHandler).getProductEntity(12315L);
}
만약 값이 일치하지 않는다면, 아래와 같은 에러가 발생하는 것을 확인할 수 있다.
JUnit를 이용한 테스트에 대해서 간단하게 작성해 보았다. JUnit을 이용한 테스트는 책으로도 나올만큼 방대한 내용을 다루고 있으며, TDD(테스트 주도 개발)는 자바 개발자라면 반드시 숙지해야 할 중요한 기법이다. 때문에 기본적으로 사용할 수 있어야 하며, 이를 간결하고 명확하게 작성하는 것이 중요하다.
참고
[Spring] JUnit과 Mockito 기반의 Spring 단위 테스트 코드 작성법 (3/3) - MangKyu's Diary (tistory.com)
Mockito와 BDDMockito는 뭐가 다를까? (velog.io)
(10) 테스트 코드 적용하기 (JUnit, TDD) [ 스프링 부트 (Spring Boot) ] - YouTube
'Spring' 카테고리의 다른 글
쿼리 메소드란? (0) | 2024.09.29 |
---|---|
BDDMockito (0) | 2024.09.23 |
Junit이란? (1) | 2024.09.21 |
Spring Custum exception (커스텀 예외 처리) (0) | 2024.09.16 |
ResponseEntity란? (1) | 2024.09.15 |