제목: [Spring] JUnit과 Mockito 기반의 Spring 단위 테스트 코드 작성법 (3/3)
작성자: tistory"망나니개발자"
작성자 수정일: 2021년04월20일
링크: https://mangkyu.tistory.com/145
작성일: 2022년2월11일
Mockito는 개발자가 동작을 직접 제어할 수 있는 가짜(Mock) 객체를 지원하는 테스트 프레임워크이다.
일반적으로 Spring과 같은 웹 어플리케이션을 개발한다고 하면, 여러 객체들 간의 의존성이 존재한다.
이러한 의존성은 단위 테스트를 작성하는 것을 어렵게 하는데, 이를 해결하기 위해 가짜 객체를 주입시켜주는 Mockito 라이브러리를 활용할 수 있다.
Mockito를 활용함으로써 가짜 객체에 원하는 결과를 Stub하여 단위 테스트를 진행할 수 있다.
Stub: "A stub is a short article in need of expansion" 직역하면 "스텁은 확장이 필요한 짧은 기사"이다. 아직 다 작성되지 않은 기사나 제목만 있는 기사처럼 함수 이름만 있거나 내용이 완전치 않은 코드를 뜻한다. 테스팅에서 사용되는 테스트 스텁(Test stub)은 테스트를 위하여 작성된 코드로 특정한 목적을 위해 사용된다.
Mockito에서 Mock(가짜) 객체의 의존성을 주입을 위해서는 크게 3가지 어노테이션이 사용된다.
@Mock
: Mock 객체를 만들어 반환해주는 어노테이션
@Spy
: Stub하지 않은 메서드들은 원본 메서드 그대로 사용하는 어노테이션
@InjectMocks
: @Mock
또는 @Spy
로 생성된 가짜 객체를 자동으로 주입시켜주는 어노테이션
예를 들어
UserController
에 대한 단위 테스트를 작성하고자 할때,UserService
를 주입받고 있다면@Mock
어노테이션을 통해 가짜UserService
를 만들고,@InjectMocks
를 통해UserController
에 주입시킬수있다.
의존성이 있는 객체는 가짜 객체(Mock Object)를 주입하여 어떤 결과를 반환하라고 정해진 답변을 준비시켜야 한다. Mockito에서는 다음과 같은 stub 메서드를 제공한다.
doReturn()
: Mock 객체가 특정한 값을 반환해야 하는 경우
doNothing()
: Mock 객체가 아무것도 반환하지 않는 경우
doThrow()
: Mock 객체가 예외를 발생시키는 경우
예를 들어
UserService
의findAllUser()
호출 시에 빈ArrayList
를 반환해야 한다면 다음과 같이doReturn()
을 사용할수있다.
doReturn(new ArrayList()).when(userService).findAllUser()
Mockito도 테스트 프레임워크이기 때문에 JUnit과 결합되기 위해서는 별도의 작업이 필요하다
기존의 JUnit4에서 Mockito를 활용하기 위해서는 클래스 어노테이션으로 @RunWith(MockitoJUnitRunner.class)
를 붙여 주어야 연동이 가능하다.
@ExpendWith(MockitoExtension.class)
를 사용해야 결합이 가능하다사용자 회원가입/목록 조회 API Java 코드
예를 들어 다음과 같은 사용자 회원가입 API와 목록 조회 API가 있다고 하자.
위와 같은 UserController
에 대한 단위 테스트 코드를 작성해주어야 한다.
JUnit5
와 Mockito
를 연동하기 위해서는 @ExtendWith(MockitoExtension.class)
를 사용해야 한다. @ExtendWith(MockitoExtension.class)
class UserControllerTest {
@RequiredArgsConstructor
@RestController
@RequestMapping(value = "/user")
@Log4j2
public class UserController {
private final UserService userService;
@PostMapping(value = "/signUp")
public ResponseEntity<String> signUp(@RequestBody SignUpDto signUpDto) {
return (userService.findByEmail(signUpDto.getEmail())!=null)
? ResponseEntity.badRequest().build()
: ResponseEntity.ok(TokenUtils.generateJwtToken(userService.signUp(signUpDto)));
}
@GetMapping(value = "/list")
public ResponseEntity<UserListResponseDto> findAll() {
UserListResponseDto userListResponseDTO = UserListResponseDto.builder()
.userList(userService.findAll()).build();
return ResponseEntity.ok(userListResponseDTO);
}
}
}
UserController
에 UserService
@Mock
어노테이션을 통해 UserService
에 가짜 Mock
객체를 주입해주어야 한다.UserController
에 UserService
를 주입시켜야 하는데, 이를 위해서 @InjectMocks
를 붙여주어야 한다.@ExtendWith(MockitoExtension.class)
class UserControllerTest {
@InjectMocks
private UserController userController;
@Mock
private UserService userService;
}
@ExtendWith(MockitoExtension.class)
class UserControllerTest {
@InjectMocks
private UserController userController;
@Mock
private UserService userService;
private MockMvc mockMvc;
@BeforeEach
public void init() {
mockMvc = MockMvcBuilders.standaloneSetup(userController).build();
}
UserController
에 대한 API를 받아 넘겨줄 수 있는 MockMvc
까지 준비가 되었으므로, 다음의 케이스들에 대해 테스크 코드를 작성해보자SingUpDto
객체 1개와 userService
의 isEmailDuplicated
와 signUp
에 대한 stub
이 필요하다given
단계에 다음과 같은 테스트 코드가 작성된다.@DisplayName("회원 가입 성공")
@Test
void signUpSuccess() throws Exception {
//given
SignUpDto signUpDto = signUpDto();
Mockito.doReturn(null).when(userService).findByEmail(signUpDto.getEmail());
Mockito.doReturn(new User("a")).when(userService).signUp(ArgumentMatchers.any(SignUpDto.class));
}
private SignUpDto signUpDto() {
SignUpDto signUpDto = new SignUpDto();
signUpDto.setEmail("test@test.test");
signUpDto.setPw("test");
return signUpDto;
}
여기서 userService
의 signUp
함수에 대한 매개변수로 우리가 만든 signUpDto가 아닌 어떠한 변수도 처리함을 뜻하는 any()
가 사용됨에 주의해야 한다.
Spring에서 HTTP Body로 전달된 데이터는 HttpMessageCoverter
에 의해 새로운 객체로 변환된다.
any()
가 사용되었다.signUpDto
는 다른 코드에서도 사용되므로 함수로 뽑아내었다.그 다음 when 단계를 작성해주어야 하는데, 이 때 mockMvc에 데이터를 함께 POST 요청을 보내야 한다.
@DisplayName("회원 가입 성공")
@Test
void signUpSuccess() throws Exception {
//given
SignUpDto signUpDto = signUpDto();
Mockito.doReturn(null).when(userService).findByEmail(signUpDto.getEmail());
Mockito.doReturn(new User("a")).when(userService).signUp(ArgumentMatchers.any(SignUpDto.class));
//when
ResultActions actions = mockMvc.perform(
MockMvcRequestBuilders.post("/user/signUp")
.contentType(MediaType.APPLICATION_JSON)
.content(new Gson().toJson(signUpDto))
);
}
MockMvcRequestBuilders
를 사용해야 하며 요청 메서드 종류,내용,파라미터 등을 설정할 수 있다.@DisplayName("회원 가입 성공")
@Test
void signUpSuccess() throws Exception {
//given
SignUpDto signUpDto = signUpDto();
Mockito.doReturn(null).when(userService).findByEmail(signUpDto.getEmail());
Mockito.doReturn(new User("a")).when(userService).signUp(ArgumentMatchers.any(SignUpDto.class));
//when
ResultActions actions = mockMvc.perform(
MockMvcRequestBuilders.post("/user/signUp")
.contentType(MediaType.APPLICATION_JSON)
.content(new Gson().toJson(signUpDto))
);
//then
MvcResult mvcResult = actions.andExpect(MockMvcResultMatchers.status().isOk()).andReturn();
String token = mvcResult.getResponse().getContentAsString();
Assertions.assertThat(token).isNotNull();
}
HttpMessageCoverter
에 의해 새로운 객체로 변환된다. any()
가 사용되었다.signUpDto
는 다른 코드에서도 사용되므로 함수로 뽑아내었다.SignUpDto
가 필요하며, isEmailDupplicated
의 결과로 true가 반환되도록 given 단계를 변경해주어야 한다.@DisplayName("이메일이 중복되어 회원 가입 실패")
@Test
void signUpFailByDuplicatedEmail() throws Exception {
//given
SignUpDto signUpDto = signUpDto();
Mockito.doReturn(new User("b")).when(userService).findByEmail(signUpDto.getEmail());
//when
ResultActions actions = mockMvc.perform(
MockMvcRequestBuilders.post("/user/signUp")
.contentType(MediaType.APPLICATION_JSON)
.content(new Gson().toJson(signUpDto))
);
//then
actions.andExpect(MockMvcResultMatchers.status().isBadRequest());
}
추가로 이메일이 중복된 경우에는 UserService
의 signUp
메서드가 호출되지 않는다.
그렇기 때문에 SignUp
에 대한 Stub
은 불필요해졌으므로 제거해주어야 한다.
Stub
이 있으면 테스트가 실패한다.given 단계에서는 UserService
의 findAll
에 대한 Stub
이 필요하다
when 단계에서 호출하는 HTTP 메서드를 Get으로, URL을 /use/list
로 작성해주어야 한다.
then 단계에서는 HTTP Status가 OK이며, 주어진 Json 데이터를 객체로 변환하여 확인해보아야 한다.
@DisplayName("사용자 목록 조회")
@Test
void getUserList() throws Exception {
//given
Mockito.doReturn(userList()).when(userService).findAll();
//when
ResultActions actions = mockMvc.perform(
MockMvcRequestBuilders.get("/user/list")
);
//then
MvcResult result = actions.andExpect(MockMvcResultMatchers.status().isOk()).andReturn();
UserListResponseDto response = new Gson().fromJson(result.getResponse().getContentAsString(), UserListResponseDto.class);
Assertions.assertThat(response.getUserList().size()).isEqualTo(5);
}
private List<User> userList() {
final List<User> userList = new ArrayList<>();
for (int i = 0; i < 5; i++) {
userList.add(new User("test@test.test"));
}
return userList;
}
@RequiredArgsConstructor
@Service
public class UserServiceImpl implements UserService {
private final UserRepository userRepository;
private final
BCryptPasswordEncoder passwordEncoder;
@Override
public User signUp(final SignUpDTO signUpDTO) {
final User user = User.builder()
.email(signUpDTO.getEmail())
.pw(passwordEncoder.encode(signUpDTO.getPw())) .role(UserRole.ROLE_USER)
.build();
return userRepository.save(user);
}
@Override
public boolean isEmailDuplicated(final String email) {
return userRepository.existsByEmail(email);
}
@Override
public List<User> findAll() {
return userRepository.findAll();
}
}
이번에는 다음과 같은 테스트 코드를 작성해보도록 하자.
@ExtendWith(MockitoExtension.class)
와 가짜 객체 주입을 사용해 다음과 같은 테스트 클래스를 작성할 수 있다.@ExtendWith(MockitoExtension.class)
class UserServiceTest {
@InjectMocks
private UserService userService;
@Mock
private UserRepository userRepository;
@Spy private BCryptPasswordEncoder passwordEncoder;
}
이번에는 BCryptPasswordEncoder
에 @Spy
가 사용되었다.
@Spy
는 Mock되지 않는 메서드는 실제 메서드로 동작하는 어노테이션이다.
위의 예제에서 실제로 사용자 비밀번호를 암호화해야 하므로 @Spy
를 사용하였다.
@DisplayName("회원 가입")
@Test void signUp() {
// given
final BCryptPasswordEncoder encoder = new BCryptPasswordEncoder();
final SignUpDTO signUpDTO = signUpDTO();
final String encryptedPw = encoder.encode(signUpDTO.getPw());
// when
doReturn(new User(signUpDTO.getEmail(), encryptedPw, UserRole.ROLE_USER)).when(userRepository).save(any(User.class));
final User user = userService.signUp(signUpDTO);
// then
assertThat(user.getEmail()).isEqualTo(signUpDTO.getEmail());
assertThat(encoder.matches(signUpDTO.getPw(), user.getPw())).isTrue();
// verify
verify(userRepository, times(1)).save(any(User.class));
verify(passwordEncoder, times(1)).encode(any(String.class));
}
이번 테스테 코드에서는 추가적으로 mockito
의 verify()
를 사용했다.
verify
는 Mock
된 객체의 해당 메서드가 몇 번 호출되었는지를 검증하는데 도와준다.
위의 예제에서는 passwordEncoder
의 encode
메소드와 userRepository
의 save
메소드가 각각 1번씩만 호출되었는지를 검증하기 위해 사용되었다.
@DisplayName("이메일 중복 여부")
@Test
void isEmailDuplicated() {
// given
final SignUpDTO signUpDTO = signUpDTO();
doReturn(true).when(userRepository).existsByEmail(signUpDTO.getEmail());
// when
final boolean isDuplicated = userService.isEmailDuplicated(signUpDTO.getEmail());
// then
assertThat(isDuplicated).isTrue();
}
@DisplayName("사용자 목록 조회")
@Test
void findAll() {
// given
doReturn(userList()).when(userRepository).findAll();
// when
final List<User> userList = userService.findAll();
// then
assertThat(userList.size()).isEqualTo(5);
}
private List<User> userList() {
final 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;
}
지금까지 Spring 기반의 애플리케이션 코드에 대해 단위 테스트를 작성하는 방법을 알아보았다.
하지만 요즘 널리 알려진 개발 방법론 중 하나인 TDD(Test-Driven Development, 테스트 주도 개발)은 테스트 코드를 먼저 작성하고, 실제 코드가 나오는 것이다.
다음 포스팅에서는 TDD로 개발하는 방법을 자세히 알아보도록 하자.