작지만 꾸준한 반복

모킹 관련 어노테이션 정리 - @Mockito @Mock @MockBean @InjectMock 본문

카테고리 없음

모킹 관련 어노테이션 정리 - @Mockito @Mock @MockBean @InjectMock

iamjooon2 2023. 7. 23. 23:27

우아한테크코스 레벨 3 프로젝트를 진행하면서, 테스트코드 작성을 새로 배우고 있다.

 

나는 크루들과 테스트에 관한 이야기를 나눌 때 마다 스스로를 클래시스트라고 말했다.

외부 API 사용 같이 '실제 객체를 테스트 할 수 없는 상황이 아니면, 연관된 모든 객체를 직접 테스트해야 그것이 진짜 테스트다'라고 생각했다.

일일히 메서드들을 모킹할 때마다 가독성이 떨어지고, 테스트 해야하는 메서드 하나를 위해, 연관된 수많은 메서드를 짜맞추는 것이 말이 안맞는다고 생각했다.

그렇게 난 레벨2 까지 Controller단은 RestAssured, Service와 Repository는 @SpringBootTest 어노테이션을 이용하여 테스트를 진행했다.

 

하지만 이런 내 신조는 레벨 3가 되어 아주 보기좋게 깨지고 말았다.

 

이번 프로젝트에서 소셜 로그인을 담당하게 되었고, '실제 객체를 테스트 할 수 없는 상황'이 나에게 벌어진 것이다.

프론트엔드로부터 넘겨받은 카카오의 인가 코드를 받아 accessToken을 받아오는 메서드였는데,

문제는 이 인가코드를 받는 과정이 웹에서 일일히 동의항목을 체크하고 로그인 버튼을 클릭한 후 redirectUri로 리다이렉트되는 것이었고

그리고 인가코드는 한 번밖에 사용이 불가능하여... 임의로 받아둔 인가 코드를 테스트에 사용할 수도 없던 상황이었다.

 

즉, 모킹을 이용할 수 밖에 없는 상황이었다

 

이 과정에서 배우게 된 스프링에서 사용할 수 있는 모킹 관련 어노테이션을 정리하고자 한다

 

@Mock

mock 객체를 만든다

실제 인스턴스 없이 가상의 Mock 인스턴스를 만들어 직접 사용할 수 있으며,

Mockito의 어노테이션이다

 

@MockBean

Spring의 ApplicationContext에 실제 객체의 Bean이 아닌 MockBean을 등록시킬 수 있다

@SpringBootTest을 사용하게 되면 ApplicationContext을 모두 로드하는데

이때 @MockBean 어노테이션을 붙여두면, 실제 Bean이 아닌 Mock 객체를 ApplicationContext에 주입할 수 있다.

스프링 부트의 어노테이션이다

 

@InjectMocks

mock 객체를 주입한다.

Mockito의 어노테이션이다

 

@SpringBootTest 어노테이션을 통해 작성한 코드는 다음과 같다

@Transactional
@SpringBootTest
@SuppressWarnings("NonAsciiCharacters")
@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class)
class AuthServiceTest {

    @MockBean
    private KakaoOAuthClient oAuthClient;

    @MockBean
    private JwtProvider jwtProvider;

    @MockBean
    private MemberRepository memberRepository;

    @Autowired
    private AuthService authService;

    @Test
    void 기존_멤버의_토큰을_발급한다() {
        // given
        카카오_토큰_받기_성공();
        Member 저장된_멤버 = 식별자_있는_멤버();
        when(memberRepository.findByEmail("이메일"))
                .thenReturn(Optional.of(저장된_멤버));
        when(jwtProvider.create(String.valueOf(저장된_멤버.getId())))
                .thenReturn("생성된 토큰");

        // when
        String 토큰 = authService.createToken("코드");

        // then
        assertThat(토큰).isEqualTo("생성된 토큰");
    }

    @Test
    void 새로_가입한_멤버의_토큰을_발급한다() {
        // given
        카카오_토큰_받기_성공();
        when(memberRepository.findByEmail("이메일"))
                .thenReturn(Optional.empty());
        when(memberRepository.save(식별자_없는_멤버()))
                .thenReturn(식별자_있는_멤버());

        when(jwtProvider.create("1"))
                .thenReturn("생성된 토큰");

        // when
        String 토큰 = authService.createToken("코드");

        // then
        assertThat(토큰).isEqualTo("생성된 토큰");
    }

    private OAuthMemberResponse 카카오_토큰_받기_성공() {
        when(oAuthClient.getAccessToken("코드"))
                .thenReturn("토큰");

        OAuthMemberResponse oAuthMemberResponse = 카카오_응답();
        when(oAuthClient.getMember("토큰"))
                .thenReturn(카카오_응답());

        return oAuthMemberResponse;
    }

    private KakaoMemberResponse 카카오_응답() {
        return KakaoMemberResponse.builder().kakaoAccount(KakaoMemberResponse.KakaoAccount.builder()
                .email("이메일")
                .profile(KakaoMemberResponse.Profile.builder()
                        .nickname("이름")
                        .picture("사진")
                        .build())
                .build()).build();
    }

}

 

다시보니 코드가 정말 못났다...

 

먼저 @MockBean 어노테이션을 붙여준 jwtProvider, memberRepository, oAuthClient

실제 테스트시 authService와 협력할 객체들이다.

@MockBean을 이용해 가짜 빈을 등록해두지 않으면, 실제 스프링 빈에 등록된 객체가 호출되기 때문에

모킹을 이용해 가짜 객체를 주입해준 후,

테스트 메서드 내부에서 해당 객체들의 행동을 미리 가정을 해두는 방식으로 테스트를 진행할 수 있다

 

// 메서드 호출과 반환값을 가정
when(targetClass.doSomething()).thenReturn("반환할 값");

// 메서드 호출시 예외를 가정
when(targetClass.doSomething()).thenThrow(new SomeException());

위와같이 행동을 가정할 수 있다

 

위 when 메서드는 모두 Mockito를 static import한 메서드이고

호출할 메서드와 반환할 값을 임의로 지정해둘 수 있다

 

10월 5일 추가...!

 

@SpringBootTest를 사용하지 않고는 다음과 같이 사용할 수 있다

@ExtendWith(MockitoExtension.class)
@SuppressWarnings("NonAsciiCharacters")
@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class)
class AuthServiceTest {

    @Mock
    private OAuthClient oAuthClient;

    @Mock
    private JwtProvider jwtProvider;

    @InjectMocks
    private AuthService authService;

    @Test
    void 기존_멤버의_토큰을_발급한다() {
        // given
        카카오_토큰_받기_성공();
        Member 저장된_멤버 = 식별자_있는_멤버();
        when(memberRepository.findByEmail("이메일"))
                .thenReturn(Optional.of(저장된_멤버));
        when(jwtProvider.create(String.valueOf(저장된_멤버.getId())))
                .thenReturn("생성된 토큰");

        // when
        String 토큰 = authService.createToken("코드");

        // then
        assertThat(토큰).isEqualTo("생성된 토큰");
    }

    @Test
    void 새로_가입한_멤버의_토큰을_발급한다() {
        // given
        카카오_토큰_받기_성공();
        when(memberRepository.findByEmail("이메일"))
                .thenReturn(Optional.empty());
        when(memberRepository.save(식별자_없는_멤버()))
                .thenReturn(식별자_있는_멤버());
                
        when(jwtProvider.create("1"))
                .thenReturn("생성된 토큰");

        // when
        String 토큰 = authService.createToken("코드");

        // then
        assertThat(토큰).isEqualTo("생성된 토큰");
    }

    private OAuthMemberResponse 카카오_토큰_받기_성공() {
        when(oAuthClient.getAccessToken("코드"))
                .thenReturn("토큰");

        OAuthMemberResponse oAuthMemberResponse = 카카오_응답();
        when(oAuthClient.getMember("토큰"))
                .thenReturn(카카오_응답());

        return oAuthMemberResponse;
    }

    private KakaoMemberResponse 카카오_응답() {
        return KakaoMemberResponse.builder().kakaoAccount(KakaoMemberResponse.KakaoAccount.builder()
                .email("이메일")
                .profile(KakaoMemberResponse.Profile.builder()
                        .nickname("이름")
                        .picture("사진")
                        .build())
                .build()).build();
    }
 
 }

 

@ExtendWith(MockitoExtension.class) 어노테이션을 클래스 상단에 붙여준다

 

@MockBean은 스프링 어노테이션이지만, @Mock과 @InjectMock은 Mockito의 어노테이션이다

영어단어 그대로, MockitoExtension을 통해 확장시켜 사용하겠다는 뜻이다.

ApplicationContext를 띄울 이유가 없어서.. 현재는 위와 같은 방식으로 리팩터링 했다..!