JAVA/JAVA | Spring 학습기록

[Spring] @MockBean VS @SpyBean

kth990303 2022. 8. 22. 13:33
반응형

Spring Boot 환경에서 여러 협력 객체들이 존재하는 통합테스트는 빈을 호출하기 위해 @SpringBootTest를 사용하는 경우가 많다. 그리고 협력 객체들이 많을 때에는 테스트를 위한 기대 행위 전처리 작업을 mockito를 이용하여 stub을 통해 test double을 만들어주는 경우들이 존재한다.

 

@SpringBootTest와 함께 사용한다면 @Mock, @InjectMocks 대신 @MockBean을 주로 사용할 것이다. 그런데 테스트 코드를 작성하는 방법에 대해 공부하던 도중 @SpyBean이라는 어노테이션을 알게 됐다. 이 어노테이션은 뭐하는 애일까? 그리고 어떻게 사용하는게 좋을까?


@MockBean

개인적으로 나는 웬만해선 @MockBean 대신 실제 객체를 사용하는 방식을 사용하고 있다.

(그 이유에 대해서는 아래에 서술할 예정이다.)

다만, OAuth 테스트 코드를 작성하는 경우는 실제 계정을 넣을 순 없기 때문에 아래와 같이 @MockBean을 사용하고 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class AuthServiceTest {
 
    @MockBean
    private KakaoPlatformUserProvider kakaoPlatformUserProvider;
    
    @Autowired
    private MemberRepository memberRepository;
 
    @Autowired
    private JwtTokenProvider jwtTokenProvider;
 
    @Autowired
    private AuthService authService;
 
    @Test
    @DisplayName("사용자 정보를 받아 토큰을 만들어 반환한다.")
    void createToken() {
        final PlatformUserDto platformUserDto = new PlatformUserDto("alex""email@email.com""KAKAO""1");
        when(kakaoPlatformUserProvider.getPlatformUser(anyString(), anyString())).thenReturn(platformUserDto);
 
        final TokenRequestDto tokenRequestDto = new TokenRequestDto("authorizationCode""https://...");
        final TokenResponseDto tokenResponseDto = authService.createTokenWithKakaoOauth(tokenRequestDto);
 
        assertThatCode(() -> jwtTokenProvider.validateAbleToken(tokenResponseDto.getAccessToken()))
                .doesNotThrowAnyException();
    }
}
cs

Kakao Oauth를 이용할 때 사용자 정보를 받아 제대로 토큰이 반환되는지 확인하는 테스트 메서드이다.

우리가 이 테스트에서 중요한 것은, 사용자 정보를 받아 토큰이 제대로 반환되는 여부이지, 사용자 정보를 올바르게 생성하는 것은 관심 분야가 아니다. 그렇기 때문에 @MockBean으로 KakaoPlatformUserProvider 가짜 객체를 만들어서 getPlatformUser 메서드가 항상 의도한 사용자 정보 DTO를 반환하게 하였다. 이 사용자 정보를 바탕으로 토큰을 받는 createTokenWithKakaoOauth를 테스트한 것이다.

 

이렇듯, @MockBean을 이용하면 편리하게 test double을 생성할 수 있다.


@SpyBean

그런데 mockito에서 제공해주는 어노테이션은 @MockBean뿐만 아니라 @SpyBean도 존재한다. 이 어노테이션은 뭐하는 친구일까? 

 

가짜 객체를 만들어서 stub을 해주는 @MockBean과 달리, @SpyBean은 given에서 stub한 메서드 외에는 그 객체의 실제 메서드를 사용할 수 있다. 마치 실제 객체의 메서드들 중에서 우리가 지정한 특정 메서드만 의도대로 작동되게 하는 것이 그 객체에 스파이를 하나 심어둔 것 같다고 생각하면 이해하기 쉽다. (이 예시는 jojoldu님 블로그 https://jojoldu.tistory.com/226 에서 봤는데 정말 기똥찼다.)

 

SpringBoot @MockBean, @SpyBean 소개

안녕하세요? 이번 시간엔 SpringBoot의 @MockBean , @SpyBean 예제를 진행해보려고 합니다. 모든 코드는 Github에 있기 때문에 함께 보시면 더 이해하기 쉬우실 것 같습니다. (공부한 내용을 정리하는 Github

jojoldu.tistory.com

특정 객체의 일부 메서드만 stub하고, 나머지는 실제 메서드를 사용하고 싶을 때 @SpyBean은 좋은 선택지가 될 수 있다.

 

이처럼 @SpyBean은 실제 객체의 메서드를 사용하기 때문에 아래와 같이 @SpyBean으로 kakaoPlatformUserProvider를 등록해주고, 해당 객체의 getPlatformUser(anyString(), anyString())를 호출하여 작성하면 테스트가 실패한다.

 

에러나는 코드

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class AuthServiceTest {
 
    @SpyBean
    private KakaoPlatformUserProvider kakaoPlatformUserProvider;
    
    @Autowired
    private MemberRepository memberRepository;
 
    @Autowired
    private JwtTokenProvider jwtTokenProvider;
 
    @Autowired
    private AuthService authService;
 
    @Test
    @DisplayName("사용자 정보를 받아 토큰을 만들어 반환한다.")
    void createToken() {
        final PlatformUserDto platformUserDto = new PlatformUserDto("alex""email@email.com""KAKAO""1");
        when(kakaoPlatformUserProvider.getPlatformUser(anyString(), anyString())).thenReturn(platformUserDto);
 
        final TokenRequestDto tokenRequestDto = new TokenRequestDto("authorizationCode""https://...");
        final TokenResponseDto tokenResponseDto = authService.createTokenWithKakaoOauth(tokenRequestDto);
 
        assertThatCode(() -> jwtTokenProvider.validateAbleToken(tokenResponseDto.getAccessToken()))
                .doesNotThrowAnyException();
    }
}
cs

KakaoAuthorizationException 에러 발생

실제 객체의 메서드에 argument로 이상한 값을 넘겨주니까 당연히 에러가 발생한다. 이는 메서드를 직접 호출해서 그렇다. 따라서 아래와 같이 바꿔주어야 한다.

// ERROR
when(kakaoPlatformUserProvider.getPlatformUser(anyString(), anyString()))
    .thenReturn(platformUserDto);

// 이 메서드를 작동할 때는 doReturn을 반환하라
doReturn(platformUserDto).when(kakaoPlatformUserProvider)
    .getPlatformUser(anyString(), anyString());

직접 getPlatformUser()를 호출하지 말고 when()으로 지정해준 SpyBean 객체의 특정 메서드가 doReturn을 반환한다고 지정해주기만 하면 된다.

 

변경된 코드

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class AuthServiceTest {
 
    @SpyBean
    private KakaoPlatformUserProvider kakaoPlatformUserProvider;
 
    @Autowired
    private MemberRepository memberRepository;
 
    @Autowired
    private JwtTokenProvider jwtTokenProvider;
 
    @Autowired
    private AuthService authService;
 
    @Test
    @DisplayName("사용자 정보를 받아 토큰을 만들어 반환한다.")
    void createToken() {
        final PlatformUserDto platformUserDto = new PlatformUserDto("alex""email@email.com""KAKAO""1");
        doReturn(platformUserDto).when(kakaoPlatformUserProvider).getPlatformUser(anyString(), anyString());
 
        final TokenRequestDto tokenRequestDto = new TokenRequestDto("authorizationCode""https://...");
        final TokenResponseDto tokenResponseDto = authService.createTokenWithKakaoOauth(tokenRequestDto);
 
        assertThatCode(() -> jwtTokenProvider.validateAbleToken(tokenResponseDto.getAccessToken()))
                .doesNotThrowAnyException();
    }
}
cs

테스트가 통과하는 것을 확인할 수 있다.

 

참고로 @SpyBean을 인터페이스에 사용할 때엔 해당 인터페이스의 구현체가 반드시 스프링 컨텍스트에 등록돼있어야 한다.


개인적으로 @MockBean, @SpyBean을 지양하고 있는 이유

@MockBean, @SpyBean은 given으로 필요한 test double을 편리하게 만들 수 있다는 장점이 존재한다.

하지만 @MockBean, @SpyBean을 사용하게 되면 Spring Context를 실행해야 하므로 테스트 성능이 저하된다. 이는 피드백이 느려질 수밖에 없으므로 문제가 된다. 특히 CI/CD까지 겸하는 프로젝트라면 그 답답함은 배가 될 것이다. 매번 PR 및 merge가 될 때마다 CI/CD 툴이 build할테니 말이다.

 

그렇지만 이 문제 외에도 또 다른 문제가 존재한다.

바로 의존성이 많은 상태임에도 불구하고 테스트 코드 작성이 편리해져 code smell을 확인하기 어려워진다는 점이다. 이 점 역시 jojoldu님의 블로그를 보고 깨닫게 된 부분이다.

https://jojoldu.tistory.com/320

 

@SpyBean @MockBean 의도적으로 사용하지 않기

보통 스프링 부트 관련 테스트 코드를 작성할때 @MockBean 과 @SpyBean 를 사용했습니다. (참고: SpringBoot @MockBean, @SpyBean 소개) 복잡한 스프링 프로젝트에서도 원하는 코드만 아주 간단하게 Mock 처리를.

jojoldu.tistory.com

테스트 코드를 하나의 문서처럼 생각한다면 @MockBean, @SpyBean으로 감춰진 code smell은 더 큰 문제가 될 것이다. 개인적으론 테스트 코드를 통해 의존 객체가 무엇이 있는지, 그 테스트는 어떤 역할을 하는지 파악하기 쉬워야 한다고 생각한다. 

 

따라서 나는 테스트에서 아래와 같이 @BeforeEach를 사용한 stub을 선호하는 편이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class RollingpaperServiceTest {
 
    private static final String ROLLINGPAPER_TITLE = "AlexAndKei";
    private static final String MEMBER_NICKNAME = "마스터";
    private static final String TEAM_NAME = "nae-pyeon";
 
    private final TeamRequestDto teamRequestDto =
            new TeamRequestDto("nae-pyeon""테스트 모임입니다.""testEmoji""#123456", MEMBER_NICKNAME, true);
    private Long teamId;
    private Long memberId;
    private Long member2Id;
    private Long member3Id;
 
    @Autowired
    private MemberService memberService;
 
    @Autowired
    private TeamService teamService;
 
    @Autowired
    private RollingpaperService rollingpaperService;
 
    @Autowired
    private RollingpaperRepository rollingpaperRepository;
 
    @BeforeEach
    void setUp() {
        memberId = memberService.save("member""m@hello.com""KAKAO""1");
        member2Id = memberService.save("writer""w@hello.com""KAKAO""2");
        member3Id = memberService.save("anonymous""a@hello.com""KAKAO""3");
        teamId = teamService.save(teamRequestDto, memberId);
        teamService.joinMember(teamId, member2Id, "안뇽안뇽");
    }
 
    @Test
    @DisplayName("멤버 대상 롤링페이퍼를 저장하고 id값으로 찾는다.")
    void saveMemberRollingpaperAndFind() {
        final Long rollingpaperId =
                rollingpaperService.createMemberRollingpaper(ROLLINGPAPER_TITLE, teamId, member2Id, memberId);
 
        final RollingpaperResponseDto rollingpaperResponse =
                rollingpaperService.findById(rollingpaperId, teamId, memberId);
 
        assertThat(rollingpaperResponse)
                .extracting("title""to""messages")
                .containsExactly(ROLLINGPAPER_TITLE, MEMBER_NICKNAME, List.of());
    }
}
 
cs

 

물론 이 부분은 정답이 없는 문제라 생각한다.

 

프로덕션 코드에서 충분히 의존성에 대해 고민하고 설계를 했고, 의존 객체가 많아 테스트 코드를 작성하기 어렵다면 @MockBean, @SpyBean을 사용하는 것은 좋은 방법이라 생각된다.

상황에 맞게 적절하게 사용하는 것이 가장 좋아보인다.


참고

 

반응형