본문 바로가기
Spring

Springboot - 서비스 단위 테스트

by khds 2023. 4. 2.

스프링 프로젝트를 진행하면서 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/

 

Test Double을 알아보자

테스트 더블(Test Double)이란? xUnit Test Patterns의 저자인 제라드 메스자로스(Gerard Meszaros…

tecoble.techcourse.co.kr

 

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

 

[springboot] 데이터 사용 Service를 mockito로 테스트하기

mockito는 단위 테스트를 위한 java mocking framework이다. 어디다 쓰냐면... 단위 테스트를 해야 하는데 데이터베이스에서 데이터를 가져와야 할 경우 테스트 환경에 따라 각각 다른 데이터가 조회될

lemontia.tistory.com

 

https://plz-exception.tistory.com/m/29

 

Spring Boot(2) Mockito를 이용한 단위 테스트

개발 환경 : JAVA 1.8 / Spring Boot 2.4.1 / Gradle 6.7.1 / MySql IDE : IntelliJ 20.3.3 Dependency : spring-boot-starter-test:2.4.1 or mockito-all:1.10.19 저번에 이어 이번엔 Mockito라는 SpringBoot Test에 자주 사용되는 라이브러리를

plz-exception.tistory.com

 

https://tecoble.techcourse.co.kr/post/2020-09-19-what-is-test-double/

 

Test Double을 알아보자

테스트 더블(Test Double)이란? xUnit Test Patterns의 저자인 제라드 메스자로스(Gerard Meszaros…

tecoble.techcourse.co.kr

 

https://cobbybb.tistory.com/16#2.2%20mock%EA%B0%9D%EC%B2%B4%EC%97%90%EC%84%9C%20stub%EB%90%98%EC%A7%80%20%EC%95%8A%EC%9D%80%20%EB%A9%94%EC%86%8C%EB%93%9C-1

 

Mockito @Mock @MockBean @Spy @SpyBean 차이점

예제 코드 https://github.com/cobiyu/MockitoSample Test Double이 왜 필요한 지부터 시작하는 기본적인 테스트 코드부터 한 단계씩 발전시켜나가며 Mockito의 어노테이션들의 정확한 쓰임새에 대해 살펴보겠습

cobbybb.tistory.com

 

https://gom20.tistory.com/126

 

[Spring Boot] Service 단위 테스트 (Mockito)

서비스 코드의 테스트 코드를 일부분 구현해보았다. @ExtendWith(MockitoExtension.class) @ExtendWith 확장 기능 구현 Mockito Mock을 지원하는 오픈 소스 테스트 프레임워크 @Mock, @InjectMocks, @Spy @Mock 가짜 객체,

gom20.tistory.com

 

https://w97ww.tistory.com/73

 

[Spring Boot] 예외처리, 테스트코드에 관한 고민

TIL 39일차 38일차: 우승멤버 등록 controller 테스트 코드 작성하기 1 를 진행하는 중에, 스프링부트에서 JUnit을 이용한 테스트코드를 작성할 때, 어떤 로직으로 구현하는건지에 대한 정리가 스스로

w97ww.tistory.com

 

https://www.baeldung.com/mockito-void-methods

https://ksjm0720.tistory.com/34

 

[Junit5] 메소드 인자 값을 확인하고 싶을 때 - ArgumentCeptor

단위 테스트를 진행하다 보면 단위 테스트는 타깃이 되는 객체만을 철저히 검증하는 테스트라고 볼 수가 있습니다. 그 때문에 최대한 의존하고 있는 객체에 영향을 받지 않아야 합니다. 그래서

ksjm0720.tistory.com

 

https://velog.io/@hellonayeon/spring-boot-service-layer-unit-testcode

 

[Spring Boot] Service 계층의 단위 테스트 코드 작성

 

velog.io