Mockito는 개발자가 동작을 직접 제어할 수 있는 가짜 객체를 지원하는 테스트 프레임워크이다. 일반적으로 Spring으로 웹 애플리케이션을 개발하면 여러 객체들 간의 의존성이 생긴다. 이러한 의존성은 단위 테스트를 작성을 어렵게 하는데, 이를 해결하기 위해 가짜 객체를 주입시켜주는 Mockito 라이브러리를 활용할 수 있다.
Mock 객체 의존성 주입
Mockito에서 가짜 객체의 의존성을 주입을 위해서는 크게 3가지의 애노테이션이 사용된다.
예를 들어 UserCotroller에 대한 단위 테스트를 작성하고자 할 때, UserService를 사용하고 있다면 @Mock를 통해 가짜 UserService를 만들고, @InjectMocks를 통해 UserCotroller에 이를 주입시킬 수 있다.
Stub로 결과 처리
의존성이 있는 객체는 가짜 객체를 주입하여 어떤 결과를 반환하라고 정해진 답변을 준비시켜야 한다. Mockito에서는 다음과 같은 Stub 메서드를 제공한다.
Mockito와 JUnit의 결합
Mockito도 테스트 프레임워크이기 때문에 JUnit과 결합되기 위해서는 별도의 작업이 필요하다. Mockito를 활용하기 위해 클래스 애노테이션으로 JUnit4에서는 @RunWith(MockitoJUnitRunner.class), 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());
}
}
@ExtendWith(MockitoExtension.class) // JUnit5와 Mockito 연동
class UserControllerTest {
@InjectMocks // 가짜 객체를 주입하는 테스트 대상
private UserController userController;
@Mock // 가짜 객체 생성
private UserService userService;
// HTTP 호출을 위해 스프링에서 제공
private MockMvc mockMvc;
@BeforeEach // MockMvc 생성
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("/api/schedules")
// .contentType(MediaType.MULTIPART_FORM_DATA)
// .content(new Gson().toJson(requestDto))
// );
// multipart/formdata로 받는 경우
ResultActions resultActions = mockMvc.perform(
MockMvcRequestBuilders.multipart("/api/schedules")
.part(new MockPart("title", requestDto.getTitle().getBytes(StandardCharsets.UTF_8)))
.part(new MockPart("content", requestDto.getContent().getBytes(StandardCharsets.UTF_8)))
.part(new MockPart("writer", requestDto.getWriter().getBytes(StandardCharsets.UTF_8)))
.part(new MockPart("password", requestDto.getPassword().getBytes(StandardCharsets.UTF_8)))
);
// 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() { // SignUpRequest 객체 생성
return SignUpRequest.builder()
.email("test@test.test")
.pw("test")
.build();
}
private UserResponse userResponse() { // UserResponse 객체 생성
return UserResponse.builder()
.email("test@test.test")
.pw("test")
.role(UserRole.ROLE_USER)
.build();
}
given
- doReturn() : 반환할 값
- when() : 사용할 가짜 객체
- any() : HTTP 요청을 보내면 스프링은 내부에서 MessageConverter를 사용해 Json String을 객체로 변환한다. 그런데 이것은 스프링 내부에서 진행되므로, API로 전달되는 파라미터인 SignUpRequest를 조작할 수 없다. 그래서 SignUpRequest 타입이라면 어떠한 객체도 처리할 수 있도록 any()가 사용되었다. any()를 사용할 때는 특정 클래스의 타입을 지정해주는 것이 좋다.
when
- mockMvc.perform() : 요청 정보 작성
- MockMvcRequestBuilders : 요청 메서드 종류, 내용, 파라미터 등을 설정할 수 있다.
- Gson().toJson() : 보내는 데이터는 객체가 아닌 문자열이여야 하므로 Gson을 사용해 변환한다. (google JSON 외부 라이브러리)
then
- status().isOk() : API 호출 결과로 200 Response인지 확인한다.
- jsonPath() : 해당 json 값이 존재하는지 검증
@DisplayName("사용자 목록 조회")
@Test
void getUserList() throws Exception {
// given
doReturn(userList()).when(userService)
.findAll();
// when
ResultActions resultActions = mockMvc.perform(
MockMvcRequestBuilders.get("/user/list") // GET 요청
);
// then
MvcResult mvcResult = resultActions.andExpect(status().isOk()).andReturn();
UserListResponseDTO response = new Gson()
.fromJson(mvcResult
.getResponse()
.getContentAsString(),
UserListResponseDTO.class);
assertThat(response.getUserList().size()).isEqualTo(5);
}
private List<UserResponse> userList() {
List<UserResponse> userList = new ArrayList<>();
for (int i = 0; i < 5; i++) {
userList.add(new UserResponse("test@test.test", "test", UserRole.ROLE_USER));
}
return userList;
}
스프링 부트에서는 컨트롤러 테스트를 위한 @WebMvcTest 애노테이션을 제공한다. 이를 이용하면 MockMvc 객체가 자동 생성될 뿐만 아니라 ControllerAdvice나 Filter, Interceptor 등 웹 계층 테스트에 필요한 요소들을 모두 빈으로 등록해 스프링 컨텍스트 환경을 구성한다. @WebMvcTest는 스프링 부트가 제공하는 테스트 환경이므로 @MockBean과 @SpyBean을 사용해 주어야 한다.
@WebMVcTest(UserController.class)
class UserControllerTest {
@MockBean
private UserService userService;
@Autowired
private MockMvc mockMvc;
// 테스트 작성
}
하지만 여기서 주의할 점이 있다. 스프링은 내부적으로 스프링 컨텍스트를 캐싱해두고 동일한 테스트 환경이라면 재사용한다. 그런데 특정 컨트롤러만을 빈으로 만들고 @MockBean과 @SpyBean으로 빈을 모킹하는 @WebMvcTest는 캐싱의 효과를 거의 얻지 못하고 새로운 컨텍스트의 생성을 필요로 한다. 그러므로 빠른 테스트를 원한다면 직접 MockMvc를 생성했던 처음의 방법을 사용하는 것이 좋을 수 있다.
테스트할 서비스
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class UserServiceImpl {
private final UserRepository userRepository;
private final BCryptPasswordEncoder passwordEncoder;
@Transactional
public UserResponse signUp(final SignUpRequest request) { // 회원가입
final User user = User.builder()
.email(request.getEmail())
.pw(passwordEncoder.encode(request.getPw()))
.role(UserRole.ROLE_USER)
.build();
return UserResponse.of(userRepository.save(user));
}
public List<User> findAll() { // 사용자 목록 조회
return userRepository.findAll().stream()
.map(UserResponse::of)
.collect(Collectors.toList()));
}
}
@ExtendWith(MockitoExtension.class)
class UserServiceTest {
@InjectMocks
private UserService userService;
@Mock
private UserRepository userRepository;
@Spy//Stub하지 않은 메서드를 실제 메서드로 동작하게 한다. 사용자 비밀번호를 암호화해야 하므로 사용
private BCryptPasswordEncoder passwordEncoder;
}
@DisplayName("회원 가입")
@Test
void signUp() {
// given
BCryptPasswordEncoder encoder = new BCryptPasswordEncoder();
SignUpRequest request = signUpRequest();
String encryptedPw = encoder.encode(request.getPw());
doReturn(new User(request.getEmail(), encryptedPw, UserRole.ROLE_USER)).when(userRepository)
.save(any(User.class));
// when
UserResponse user = userService.signUp(request);
// then
assertThat(user.getEmail()).isEqualTo(request.getEmail());
assertThat(encoder.matches(request.getPw(), user.getPw())).isTrue();
// verify
verify(userRepository, times(1)).save(any(User.class));
verify(passwordEncoder, times(1)).encode(any(String.class));
}
@DisplayName("사용자 목록 조회")
@Test
void findAll() {
// given
doReturn(userList()).when(userRepository)
.findAll();
// when
final List<UserResponse> userList = userService.findAll();
// then
assertThat(userList.size()).isEqualTo(5);
}
private List<User> userList() {
List<User> userList = new ArrayList<>();
for (int i = 0; i < 5; i++) {
userList.add(new User("test@test.test", "test", UserRole.ROLE_USER));
}
return userList;
}
테스트할 레포지토리
public interface UserRepository extends JpaRepository <User, Long> {}
스프링 부트는 JPA 레포지토리를 손쉽게 테스트할 수 있는 @DataJpaTest 애노테이션을 제공한다. @DataJpaTest를 사용하면 기본적으로 인메모리 데이터베이스인 H2를 기반으로 테스트용 데이터베이스를 구축하며, 테스트가 끝나면 트랜잭션 롤백을 해준다.
@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();
}
}