스프링 프로젝트를 진행하면서 service 클래스의 테스트를 진행해야 한다.
전에는 인 메모리 db를 활용하여 service 내의 repository를 실제 호출하면서 테스트를 진행하였다.
하지만 여기에는 문제가 있는데 repository를 사용하고 DB로 부터 영향을 받는 것이다. service 테스트를 진행하는 것은 순전히 service 내의 기능만을 잘 돌아가는 지를 확인하는 것이다. 하지만 예전 방식은 service 내의 repository 기능 또한 테스트를 진행하는 것이다. 통합테스트를 하는 것처럼 보일 것이다.
이러한 문제를 해결하는 것이 Mockito를 사용하는 것이다.
Mockito는 단위 테스트를 위한 java mocking framework이다.
Mockito에 대해 알기 전에 먼저 'Test Double'에 대해 알아야 한다. Test Double는 xUnit Test Patterns의 저자인 제라드 메스자로스(Gerard Meszaros)가 만든 용어로 테스트를 진행하기 어려운 경우 이를 대신해 테스트를 진행할 수 있도록 만들어주는 객체를 말한다.
service 테스트는 repository 와 연관돼있고 DB에 영향을 받게 된다. 그렇기에 DB 상태에 따라 다른 결과가 나올 수 있다. DB사용 없이 테스트하는 것은 어렵고 모호한데 이를 대신해 줄 수 있는 객체를 테스트 더블이라 한다.
Mockito는 Mock객체를 활용하는 데 Mock 객체는 가짜 객체로 함수가 실행되면 어떤 결과가 리턴될지를 미리 지정하는 방식이다. 이러한 Mock 객체는 Test Double 중 stub에 해당되는데 자세한 내용은 아래를 참고하길 바란다.
https://tecoble.techcourse.co.kr/post/2020-09-19-what-is-test-double/
SpringBoot를 사용하면 gradle에 spring-boot-starter-test가 추가되는데, 이 안에 mockito-junit-jupiter 패키지가 포함되어 있기 때문에 springboot를 사용한다면 따로 dependency를 추가할 필요가 없다.
두 개의 예시코드를 살펴보자. 아래는 service 코드이다.
@Service
@RequiredArgsConstructor
public class FindArticleService {
private final FindArticleRepository findArticleRepository;
@Transactional(readOnly = true)
public List<ArticleMapResponse> findPublicMapArticles(
LocationRange locationRange) {
Long userId = null;
return ArticleMapResponse.toResponses(findArticles(userId, locationRange));
}
@Transactional(readOnly = true)
public List<ArticleMapResponse> findPrivateMapArticles(
Long userId, LocationRange locationRange) {
return ArticleMapResponse.toResponses(findArticles(userId, locationRange));
}
private List<Article> findArticles(Long userId, LocationRange locationRange) {
return findArticleRepository.findArticles(userId,
locationRange.getUpperLatitude(), locationRange.getLowerLatitude(),
locationRange.getUpperLongitude(), locationRange.getLowerLongitude());
}
}
@Service
@RequiredArgsConstructor
public class AuthService {
private final UserRepository userRepository;
private final PasswordEncoder passwordEncoder;
private final JwtTokenProvider tokenProvider;
@Transactional(readOnly = true)
public String login(LoginRequest loginRequest) {
User user = findUser(loginRequest);
verifyPassword(loginRequest, user);
return tokenProvider.createAccessToken(String.valueOf(user.getId()), Role.USER);
}
@Transactional
public void signUp(SignUpRequest signUpRequest) {
verifyEmail(signUpRequest);
User user = User.builder()
.email(signUpRequest.getEmail())
.provider(AuthProvider.local)
.nick_name(signUpRequest.getNickName())
.role(Role.USER)
.join_date(new Date())
.password(passwordEncoder.encode(signUpRequest.getPassword())).build();
userRepository.saveUser(user);
}
private User findUser(LoginRequest loginRequest){
return userRepository.findByEmail(loginRequest.getEmail())
.orElseThrow(() -> new NotExistsEmailException("존재하지 않는 이메일입니다."));
}
private void verifyPassword(LoginRequest loginRequest, User user){
if (!passwordEncoder.matches(loginRequest.getPassword(), user.getPassword())) {
throw new NotMatchPasswordException("비밀번호가 틀렸습니다.");
}
}
private void verifyEmail(SignUpRequest signUpRequest){
if (userRepository.existsByEmail(signUpRequest.getEmail())) {
throw new AlreadyExistedEmailException("이미 사용중인 이메일입니다.");
}
}
}
아래는 service test코드이다.
@ExtendWith(MockitoExtension.class)
public class FindArticleServiceTest {
@Mock
private FindArticleRepository findArticleRepository;
@Spy
@InjectMocks
private FindArticleService findArticleService;
@Test
@DisplayName("전체지도 내 게시글 찾기 테스트")
public void findPublicMapArticlesTest(
) {
//given
List<Article> articles = createArticleList(createArticle());
Long userId = null;
LocationRange locationRange = new LocationRange(new ArticleRangeRequest(10.0, 10.0, 10.0, 10.0));
given(findArticleRepository.findArticles(userId, 20.0, 0.0, 20.0, 0.0))
.willReturn(articles);
//when
List<ArticleMapResponse> responses = findArticleService.findPublicMapArticles(locationRange);
//then
verify(findArticleRepository,times(1)).findArticles(userId, 20.0, 0.0, 20.0, 0.0);
verify(findArticleService, times(1)).findPublicMapArticles(locationRange);
assertThat(responses).hasSize(1);
}
private Article createArticle() {
return Article.builder()
.id(1L)
.content("ddddd")
.latitude(10.1)
.longitude(10.1)
.private_map(true)
.public_map(true)
.title("히히히히")
.user_id(1L).build();
}
private List<Article> createArticleList(Article article) {
List<Article> articles = new ArrayList<>();
articles.add(article);
return articles;
}
}
@SpringBootTest
public class AuthServiceTest {
@MockBean
private UserRepository userRepository;
@MockBean
private PasswordEncoder passwordEncoder;
@MockBean
private JwtTokenProvider tokenProvider;
@SpyBean
@Autowired
private AuthService authService;
private User user;
@Test
@DisplayName("로그인시")
public void Login() {
//given
String testToken = "testtset";
setUser();
LoginRequest loginRequest = new LoginRequest("email", "password");
given(userRepository.findByEmail("email")).willReturn(Optional.ofNullable(user));
given(passwordEncoder.matches(loginRequest.getPassword(), user.getPassword())).willReturn(true);
given(tokenProvider.createAccessToken(any(), any())).willReturn(testToken);
//when
String token = authService.login(loginRequest);
//then
assertThat(token).isEqualTo(testToken);
}
@Test
@DisplayName("로그인 실패할 경우")
public void Login_IfNotExistsEmail() {
//given
LoginRequest loginRequest = new LoginRequest("email", "password");
//when & then
assertThatThrownBy(
() -> authService.login(loginRequest))
.isInstanceOf(NotExistsEmailException.class);
//given
setUser();
given(userRepository.findByEmail("email")).willReturn(Optional.ofNullable(user));
given(passwordEncoder.matches(any(), any())).willReturn(false);
//when & then
assertThatThrownBy(
() -> authService.login(loginRequest))
.isInstanceOf(NotMatchPasswordException.class);
}
@Test
@DisplayName("회원가입 진행시")
public void SignUp() {
//given
ArgumentCaptor<User> captor = ArgumentCaptor.forClass(User.class);
SignUpRequest signUpRequest = new SignUpRequest("nickName", "email", "password");
given(userRepository.saveUser(any())).willReturn(1);
given(userRepository.existsByEmail("email")).willReturn(false);
given(passwordEncoder.encode("password")).willReturn("password");
//when
authService.signUp(signUpRequest);
//then
verify(userRepository, times(1)).saveUser(captor.capture());
User user = captor.getValue();
assertThat(signUpRequest.getNickName()).isEqualTo(user.getNick_name());
}
@Test
@DisplayName("회원가입 진행시 이미 이메일이 가입되어 있는경우")
public void SignUp_IfExistsEmail() {
//given
SignUpRequest signUpRequest = new SignUpRequest("nickName", "email", "password");
given(userRepository.existsByEmail("email")).willReturn(true);
//when & then
assertThatThrownBy(
() -> authService.signUp(signUpRequest))
.isInstanceOf(AlreadyExistedEmailException.class);
}
private void setUser(){
user = User.builder()
.id(20L)
.email("test")
.image_url(null)
.provider_id("test")
.provider(AuthProvider.google)
.nick_name("tset")
.role(Role.USER)
.join_date(new Date())
.password("password").build();
}
}
1, @Mock
가짜객체로 만들 것을 정하는 어노테이션이다.
2. @InjectMocks
@InjectMocks 라는 어노테이션을 사용하면 해당 클래스가 필요한 의존성과 맞는 Mock 객체들을 감지하여 해당 클래스의 객체가 만들어질때 객체를 만들고 해당 변수에 객체를 주입하게 된다.
실제 테스트하려는 객체에 어노테이션을 달면 된다.
3. @MockBean, @Autowired, @SpyBean
@SpringBootTest는 SpringBoot 컨텍스트를 이용하여 테스트를 가능하도록 해주는 어노테이션이다.
즉, @Autowired라는 강력한 어노테이션으로 컨텍스트에서 알아서 생성된 객체를 주입받아 테스트를 진행할 수 있도록 한다.
@SpringBootTest가 선언된 테스트에서는 @Mock 대신 @MockBean을, @InjeckMocks 대신 @Autowired를, @Spy 대신 @Spybean을 사용한다.
4. @Spy
위의 코드에서 특정 메서드가 제대로 호출되었는지 여부를 확인할 수 있다. 위의 findArticleServiceTest 중 아래와 같은 코드가 있다.
verify(findArticleService, times(1)).findPublicMapArticles(locationRange);
이는 findArticleService 내의 findPublicMapArticles가 1번 실행했는지 검증하는 것이고 이러한 verify를 쓰기 위해서는 @Spy를 추가해야 한다.
또 하나는 @Mock을 통해 Mock 객체로 등록하면 given().willReturn() 과 같이 어떤 리턴을 할지 미리 선언을 해야 한다. 하지만 해당 객체의 어떤 메서드는 가짜객체로 진행하고 어떤 객체는 진짜 로직을 실행시키고 싶을 때가 있다.
그럴 때 사용하는 것이 @Spy 어노테이션이다. @Mock을 달은 객체는 모든 메서드가 무조건 given~ 을 추가해야 했지만 @Spy를 달은 객체는 일부만 given~ 을 추가할 수 있고 추가한 메서드만 가짜 객체의 방식으로 진행된다.
하지만 given~ 을 선언하지 않아도 에러가 발생되는 것은 아니다. 그 이유는 Mockito는 메서드의 타입별로 정의된 default 메서드가 있다. stub 되지 않는 메서드들은 default 메서드로 실행되게 된다. 그렇기에 리턴이 불필요한 repository 내 메서드 같은 경우 굳이 given~ 을 설정하여 stub 하지 않아도 된다.
5. Stub
메서드를 실행하면 가짜 객체를 리턴하도록 하는 방식이다.
위 코드 예시중 아래와 같은 방식이 많이 적혀있다.
given(findArticleRepository.findArticles(userId, 20.0, 0.0, 20.0, 0.0))
.willReturn(articles);
findArticleRepository.findArticles가 호출되면 articles를 리턴하도록 하는 하는 것이다.
매개변수는 실제값 대신 any()로 설정할 수 있다.
위와 같이 repository메서드들을 설정하고 service 메서드를 실행하여 얻는 리턴값을 비교하여 테스트를 진행하면 된다.
assertThat를 통해 테스트를 하면 된다.
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
위와 같이 import 하고 사용하면 된다.
아래와 같이 몇 번 실행되는 가도 테스트 해도 된다.
verify(findArticleService, times(1)).findPublicMapArticles(locationRange);
그렇다면 service 메서드가 리턴타입이 void이거나 중간에 예외처리를 테스트하고 싶을 때는 어떻게 하나?
아래의 AuthServiceTest 내의 코드처럼 특정 상황에 대해 given~ 으로 예외가 나도록 설정을 하고 service 메서드를 실행하면 미리 지정된 예외가 나오는지를 확인하면 된다.
assertThatThrownBy(
() -> authService.signUp(signUpRequest))
.isInstanceOf(AlreadyExistedEmailException.class);
리턴 타입이 void 인 것은 AuthServiceTest 내의 ArgumentCaptor 객체를 활용하면 된다. 이는 특정 타입의 객체를 특정 순간에 저장하는 것으로 아래의 코드를 봐보자.
//given
ArgumentCaptor<User> captor = ArgumentCaptor.forClass(User.class);
SignUpRequest signUpRequest = new SignUpRequest("nickName", "email", "password");
given(userRepository.saveUser(any())).willReturn(1);
given(userRepository.existsByEmail("email")).willReturn(false);
given(passwordEncoder.encode("password")).willReturn("password");
//when
authService.signUp(signUpRequest);
//then
verify(userRepository, times(1)).saveUser(captor.capture());
User user = captor.getValue();
assertThat(signUpRequest.getNickName()).isEqualTo(user.getNick_name());
위의 코드에서 User 타입을 지정한 captor을 선언한다. verify부분에서 captor.capture()를 통해 해당 메서드가 실행되고 리턴되는 정보를 captor에 저장한다. captor.getValue()를 통해 리턴되는 값을 얻고 이 값을 통해 테스트를 진행하면 된다.
참고
https://lemontia.tistory.com/915
https://plz-exception.tistory.com/m/29
https://tecoble.techcourse.co.kr/post/2020-09-19-what-is-test-double/
https://www.baeldung.com/mockito-void-methods
https://ksjm0720.tistory.com/34
https://velog.io/@hellonayeon/spring-boot-service-layer-unit-testcode
'Spring' 카테고리의 다른 글
springboot service 계층: 인터페이스와 여러 구현체로 구현(공통 메서드 분리) (0) | 2023.07.15 |
---|---|
Java Springboot AOP를 통한 로그 출력(메서드 이름을 활용) (0) | 2023.07.08 |
Spring boot - 서버의 존재하지 않는 URL 접근 시 예외 처리 (0) | 2022.04.09 |
스프링부트 - 인터셉터(Interceptor) (0) | 2022.03.06 |
스프링부트 AOP에 대한 기본 정리 (0) | 2022.02.28 |