Mockito는 개발자가 동작을 직접 제어할수 있는 가짜 ( Mock ) 객체를 지원하는 테스트 프레임워크
입니다. 일반적으로 Spring으로 웹 애플리케이션을 개발하면, 여러 객체들 간에 의존성이 생깁니다. 이러한 의존성 때문에 단위 테스트를 작성하기가 어려워지는데, 이를 해결하기 위해 가짜 객체를 주입시켜주는 Mockito 라이브러리를 활용할 수 있습니다. Mockito를 활용하면 가짜 객체에 원하는 결과를 Stub하여 단위 테스트를 진행할 수 있습니다.
예를 들어 컨트롤러 단위 테스트를 진행하기 위해서 서비스 빈을 불러오지 않고, 해당 서비스를 호출했을 때 어떤 값을 반환하게 설계할 수 있습니다.
단위 테스트 및 Stub 에 대한 자세한 설명은 해당 링크를 참고해주세요.
Mockito에서 Mock 객체의 의존성 주입을 위해 크게 3가지 어노테이션이 사용됩니다.
예시로 Controller에 대한 단위 테스트를 할 때 Service 에 의존성이 있다면 @Mock 어노테이션을 통해 가짜 Service를 만들고 , @InjectMocks 를 이용해 Controller 에게 주입합니다.
@Spy 같은 경우는 가짜 객체가 아니라 실제로 동작해야 하는 메서드에 사용합니다. 예를 들면 JWT 토큰 생성하는 메서드는 실제로 동작해야 하기 때문에 @Spy 로 설정합니다.
의존성이 있는 객체는 가짜 객체를 주입하여 어떤 결과를 반환하라고 정해진 답변을 준비
해야 하는데 Mockito 에서는 다음과 같은 Stub 메서드를 제공합니다.
만약 Mock 객체를 생성 후 Stub를 설정하지 않았다면 null값이나 0 이 반환됩니다.
Mockito 와 JUnit를 같이 사용하기 위해 별도의 작업이 필요한데 JUnit4 에서 Mockito를 활용하기 위해서 클래스 어노테이션으로 @RunWith(MockitoJUnitRunner.class) 를 선언해주어야 하는데, SpringBoot 2.2.0 버전 부터는 공식적으로 JUnit5를 지원하기 때문에 @ExtendWith(MockitoExtension.class) 를 선언해 주셔야합니다.
@RestController
@RequiredArgsConstructor
public class UserController {
private final UserService userService;
@PostMapping("/users/signUp")
public ResponseEntity<UserResponse> signUp(@RequestBody SignUpRequest request) {
return ResponseEntity.status(HttpStatus.CREATED)
.body(userService.signUp(request));
}
@GetMapping("/users")
public ResponseEntity<List<UserResponse>> findAll() {
return ResponseEntity.ok(userService.findAll());
}
}
// Mockito를 사용하기 위한 필수 인터페이스
@ExtendWith(MockitoExtension.class)
class UserControllerTest {
// UserService의 가짜 객체를 주입
@InjectMocks
private UserController userController;
// 가짜 객체 생성
@Mock
private UserService userService;
// Spy 사용 예시
@Spy
private BCryptPasswordEncoder passwordEncoder;
// 컨트롤러 테스트를 하기위해 HTTP 호출을 위한 객체입니다.
private MockMvc mockMvc;
@BeforeEach
public void init() {
mockMvc = MockMvcBuilders.standaloneSetup(userController).build();
}
@DisplayName("회원 가입 성공")
@Test
void signUpSuccess() throws Exception {
// given
SignUpRequest request = signUpRequest();
UserResponse response = userResponse();
doReturn(response).when(userService)
.signUp(any(SignUpRequest.class));
// when
ResultActions resultActions = mockMvc.perform(
MockMvcRequestBuilders.post("/users/signUp")
.contentType(MediaType.APPLICATION_JSON)
.content(new Gson().toJson(request))
);
// then
MvcResult mvcResult = resultActions.andExpect(status().isOk())
.andExpect(jsonPath("email", response.getEmail()).exists())
.andExpect(jsonPath("pw", response.getPw()).exists())
.andExpect(jsonPath("role", response.getRole()).exists())
}
}
private SignUpRequest signUpRequest() {
return SignUpRequest.builder()
.email("test@test.test")
.pw("test")
.build();
}
private UserResponse userResponse() {
return UserResponse.builder()
.email("test@test.test")
.pw("test")
.role(UserRole.ROLE_USER)
.build();
}
}
MockMVC에 대한 설명은 해당 링크를 참조해 주세요.
@Spy
private BCryptPasswordEncoder passwordEncoder;
Spy는 Mock하지 않은 메서드는 실제 메서드로 동작해야 하는데, 위의 코드는 실제로 사용자 비밀번호를 암호화해야 하기 때문에 @Spy로 사용합니다.
// 예제 1번
doReturn(response).when(userService)
.signUp(any(SignUpRequest.class));
userService의 signUp 메서드를 호출했을 경우 , 미리 생성해 놓은 response를 반환합니다.
HTTP 요청을 보내면 Spring은 내부에서 MessageConverter를 이용해 객체를 Json String으로 변환합니다. 하지만 우리는 API로 전달되는 파라미터인 request 객체 ( SignUpRequest ) 를 조작할 수 없습니다. 그래서 전달되는 인자가 SignUpRequest 클래스 타입이라면 어떤 객체라도 처리할 수 있게 any() 를 사용합니다. any()를 사용할 때 인자로 특정 클래스의 타입을 지정해 주는 것
이 좋습니다. 자세한 내용은 하단의 Argument matchers
에서 다룹니다.
doReturn(response).when(userService)
.signUp(1L);
만약 userService의 signUp 메서드에 인자 1 을 주었을 때 테스트를 하고 싶다면 다음과 같이 작성할 수 있습니다.
// when
ResultActions resultActions = mockMvc.perform(
MockMvcRequestBuilders.post("/users/signUp")
.contentType(MediaType.APPLICATION_JSON)
.content(new Gson().toJson(request))
);
when 단계에서 mockMVC에 데이터와 함께 POST 요청을 전송하고 , 그 결과값인 ResultActions 타입의 변수로 받습니다. 다만 위의 설명처럼 객체를 직접 Json String으로 변환해야 하기에 Gson 을 이용해 변환해줍니다.
// then
MvcResult mvcResult = resultActions.andExpect(status().isOk())
.andExpect(jsonPath("email", response.getEmail()).exists())
.andExpect(jsonPath("pw", response.getPw()).exists())
.andExpect(jsonPath("role", response.getRole()).exists())
then 단계에서 API 호출 결과가 200 Response 인지 검증을 하며 또한 jsonPath 을 이용해 해당 json 값이 존재하는지 확인할 수 있습니다.
위와 같이 MockMvc를 생성하는 작업은 번거롭습니다. 때문에 Spring Boot는 컨트롤러 테스트를 위해 @WebMvcTest 어노테이션을 제공하고 있으며 , 이를 사용하면 MockMvc 객체가 자동으로 생성되며 ControllerAdvice나 Filter , Intercaptor등 웹 계층 테스트에 필요한 모든 빈을 등록해 스프링 컨텍스트 환경을 구성합니다. 또한 @WebMvcTest를 사용하기 위해선 @Mock , @Spy 대신에 @MockBean 과 @SpyBean을 사용해 주셔야합니다.
@WebMVcTest(UserController.class)
class UserControllerTest {
@MockBean
private UserService userService;
@Autowired
private MockMvc mockMvc;
// 테스트 작성
}
다만 스프링은 내부적으로 스프링 컨텍스트를 캐싱하고 동일한 테스트 환경이라면 재사용합니다. 하지만 특정 컨트롤러만을 빈으로 만들고 @MockBean 과 @SpyBean으로 빈을 모킹하는 @WebMvcTest는 캐싱 효과를 거의 얻지 못하고 새로운 컨텍스트를 생성합니다. 따라서 빠른 테스트를 원한다면 처음 사용했던 MockMvc를 사용하는 방법이 좋습니다.
Mocking할 메서드가 내부에서 어떤 인수로 실행될 지 모를 때 예시로 DB의 AutoIncrement 값이 몇인지 모르지만 Stub를 해야할 때에 any를 사용할 수 있습니다.
when(studyRepository.findById(any())).thenReturn(Optional.of(study));
doReturn(response).when(studyRepository)
.findById(any(Integer.class));
any()를 사용하면 어떤 인수로 메서드에 전달되어도 동일한 결과를 반환하며 이 외 다양한 인터페이스가 있습니다.
@Mock
private UserRepository userRepository;
@Spy
private BCryptPasswordEncoder passwordEncoder;
//verify
verify(userRepository, times(1)).save(any(User.class));
verify(passwordEncoder, times(1)).encode(any(String.class));
테스트 코드 내에서 given - when - then 단계 외에 verify 단계를 활용할 수 있는데, verify는 Mock된 객체의 특정 메서드가 호출된 횟수를 검증
할 수 있습니다. 위의 코드에서 userRepository 객체의 save 메서드와 passwordEncoder 객체의 encode 메서드가 각각 1번만 호출되었는지를 검증하기 위해 사용합니다.
그 외에 사용할 수 있는 코드는 다음이 있습니다.
InOrder 는 메서드 호출 순서를 검증하기 위해 사용됩니다. 처음 InOrder 객체를 생성할 때 inOrder("Mock객체명") 으로 생성한 후에 검증하고 싶은 순서에 맞게 verify를 작성하면 됩니다. 또한 위의 표에 calls()
를 사용하여 테스트할 수 있습니다.
verifyNoMoreInteractions(T mock) -> 선언한 verify 후 해당 mock를 실행하면 실패
verifyNoInteractions(T mock) -> 테스트 내에 mock를 실행하면 실패
@Test
void testInOrderWithCalls() {
// 선언한 순서대로 실행되면 성공합니다.
userService.getUser();
userService.getUser();
userService.getLoginErrNum();
InOrder inOrder = inOrder(userService); // 이 코드 이후로 순서를 정합니다.
inOrder.verify(userService, calls(2)).getUser(); // 먼저 getUser() 가 두번 실행되고
inOrder.verify(userService).getLoginErrNum(); // getLoginErrNum() 이 실행되면 성공합니다.
}
@Test
void testInOrderWithVerifyNoMoreInteractions() {
userService.getUser();
// userService.getLoginErrNum(); - 실행하면 fail
InOrder inOrder = inOrder(userService);
inOrder.verify(userService).getUser(); // getUser()가 먼저 실행되고
verifyNoMoreInteractions(userService); //위에 verify 이후 userService를 호출하면 fail
}
@Test
void testInOrderWithVerifyNoInteractions() {
userService.getUser();
userService.getLoginErrNum();
// productService.getProduct(); - 해당 코드를 주석해제하면 해당 테스트는 실패합니다.
InOrder inOrder = inOrder(userService);
inOrder.verify(userService).getUser(); // getUser()가 먼저 실행되고
inOrder.verify(userService).getLoginErrNum(); // getLoginErrNum() 이 실행되면 성공!
verifyNoInteractions(productService); //만약 productService를 호출하면 실패
}
스프링 부트는 JPA 레파지토리를 손쉽게 테스트할 수 있는 @DataJpaTest 를 제공합니다. @DataJpaTest를 사용하면 기본적으로 인메모리 데이터베이스인 H2를 기반으로 테스트용 데이터베이스를 구축하며, 테스트가 끝나면 트랜잭션 롤백을 합니다. 레파지토리 계층은 실제 DB와 통신없이 단순 모킹하는 것은 의미가 없으므로 데이터베이스와 통신하는 @DataJpaTest를 사용합니다.
@DataJpaTest
class UserRepositoryTest {
@Autowired
private UserRepository userRepository;
@DisplayName("사용자 추가")
@Test
void addUser() {
// given
User user = user();
// when
User savedUser = userRepository.save(user);
// then
assertThat(savedUser.getEmail()).isEqualTo(user.getEmail());
assertThat(savedUser.getPw()).isEqualTo(user.getPw());
assertThat(savedUser.getRole()).isEqualTo(user.getRole());
}
@DisplayName("사용자 목록 조회")
@Test
void addUser() {
// given
userRepository.save(user());
// when
List<User> userList = userRepository.findAll();
// then
assertThat(userList.size()).isEqualTo(1);
}
private User user() {
return User.builder()
.email("email")
.pw("pw")
.role(UserRole.ROLE_USER).build();
}
}
TDD는 테스트를 기준으로 하는 개발 방법론
이라면 , BDD는 행동을 기준으로 하는 개발 방법론
입니다. 크게 Given과 When , Then 3가지로 나눠서 테스트를 진행하면 되는데 BDD에 대한 자세한 설명은 해당 링크 를 참조해주세요.
Mockito는 BDD 스타일로 테스트 코드를 짤 수 있게 BDDMockito 클래스를 제공합니다. 간단하게 기존 메서드를 다음과 같이 변경해주면 됩니다.
@Test
void testVerifyTimes() {
//given
//when 메서드를 given으로 변경해주시면 됩니다!
// 기존 코드 : when(userService.getUser()).thenReturn(null);
given(userService.getUser()).thenReturn(null);
//when
userService.getUser();
userService.getUser();
//then
//verify 를 then으로 바꿔주세요.
// 기존 코드 : verify(userService, times(2)).getUser();
then(userService, times(2)).getUser();
}
참고 블로그 1 : https://mangkyu.tistory.com/145
참고 블로그 2 : https://effortguy.tistory.com/144
참고 블로그 3 : 링크