삽질인가 고찰인가

[JUnit] Mockito.verify() 사용할 때 @Autowired vs @MockBean vs @SpyBean

우당탕 오리의 개발모험 2023. 4. 6. 22:42

verify() 용도

test 코드가 실행되는 동안 특정 메서드가 실행된 횟수등을 확인합니다.

E.g. verify(mock, times(5)).someMethod("was called five times");

사용하다보니 몇가지 주의사항이 있어서 글을 남깁니다.

아래 2가지 내용을 확인하세요.

빨리 확인해야한다면 '정리' 만 바로 확인하세요.

1. MockBean만 검증할 수 있다.

실무에서 이미 작성된 테스트코드에 추가로 verify() 검증을 추가해봅니다.

근데.. 에러가 발생합니다

Argument passed to verify() is of type PointService$$EnhancerBySpringCGLIB$$2aeafde9 and is not a mock!

verify()로 전달된 매개변수가 mock이 아니라네요.

아래는 에러가 발생한 코드입니다

verify() 라인에서 테스트가 실패했다

  1. 이 테스트는 Spring 전체 테스트입니다. @SpringBootTest 어노테이션이 붙어있는 것을 보면 알 수 있습니다.
    테스트 실행 시 마치 실제 실행하는 것 처럼 모든 빈을 등록하고, 주입해줍니다
    그리고 @Autowired 어노테이션을 이용했으므로, 스프링 빈으로 등록되어있을 PointService 클래스를 주입받았습니다.
  2. 그리고 PointService 클래스의 findAll() 메서드가 1번만 제대로 실행됐는지 verify()로 확인하는 로직을 작성했습니다.
  3. 테스트는 실패했고, 앞서 설명했던 에러메시지를 확인 할 수 있습니다.

에러 메시지를 찬찬히 읽어보면 알 수 있습니다. verify()에서 체크할 수 있는 클래스는 Mock 빈이어야 합니다!
이제서야 알았네요.

해결방법? - @MockBean

그렇담 Mock으로 만들어주면 되죠.

verify()로 검증 할 클래스를 MockBean으로 만들고 테스트를 실행했더니 성공했습니다!

하지만, 이 테스트는 반쪽짜리 해결이었습니다.

2. @MockBean 으로 등록한 클래스 내부의 로직은 검증 할 수 없다

그렇게 코드를 푸시했고, 완료한 줄 알았습니다.

근데, QA가 진행되던 와중에 위에서 테스트코드로 검증했던 로직에서 에러가 발생했습니다.

 

이유는 MockBean인 PointService 클래스의 내부 메서드가 모두 실행되지 않고 넘어가서 잠재된 에러를 발견하지 못했기 때문입니다.

즉, PointService.withdrawalMembershipProccess() 메서드 내부 로직이 test-code에서는 아예 실행되지 않았던 겁니다.

그래서 실제로 메서드가 실행됐더라면 발견했을 Exception 지점을 놓쳤습니다.

그럼 어떻게 해야할까요?

기존의 verify() 검증하는 test-code 외에 추가로 실제 메서드가 호출되어 로직이 실행되는 test-code를 별도로 짜야할까요?

….

해결방법! - @SpyBean

간단했습니다. @SpyBean 어노테이션으로 바꾸기만하면 됩니다.

⚠️ 참고
PointService.withdrawalMembershipProccess()가 실행되면 "탈퇴회원 {}의 포인트 회수 프로세스 진행" 메시지가 로그에 찍혀야 한다.
public class PointService { 
    public void withdrawalMembershipProccess(Member member) { 
    	log.info("탈퇴회원 {}의 포인트 회수 프로세스 진행", member.getName()); 
    } 
}​

  1. @SpyBean 어노테이션으로 바꿔서 테스트 실행했다.
  2. verify() 검증대상의 메서드 내 로직이 제대로 실행됐다 : 로직 내 작성된 텍스트가 출력됐다.
  3. verify() 테스트도 통과했다.

이로써 테스트하려는 메서드 내 로직도 검증하고, verify()로 실행횟수도 검증을 할 수 있습니다.

정리

JUnit 테스트코드를 짤 때 verify()를 사용하려면, 검증할 객체는 ‘Mock Bean’이어야한다.

하지만, 단순히 @MockBean으로 선언해버리면 그 내부의 로직이 테스트 검증이 되지 않으므로, @SpyBean을 이용해서 로직도 검증하고, verify()도 검증한다.

 

 

아래부터는 뭐가 다른지 궁금해서 좀 더 파본 내용이다.

굳이 안봐도 되는 내용이니, @SpyBean, @MockBean, @Autowired에 대해 삽질을 해보고 싶지 않다면 여기서 글 읽기를 종료해도 된다.



🔥 좀 더 파보기

1. 왜 @SpyBean, @MockBean 로 생성된 Mock 객체만 verify() 검증이 가능할까?

첫번째 알아볼 내용은 1번으로 작성한 1. MockBean만 검증할 수 있다. 과 관련된다.

@SpyBean, @MockBean, @Autowired 로 Bean을 생성하면 어떻게 다른가 확인해보자

일단 3개 어노테이션이 어떻게 다른 객체를 만드는지 확인해보자

 

1. @Autowired

  - 객체가 SpringCGLIB로 한 번 감싸있다.

2. @MockBean 일 때

  - 객체가 MockitoMock으로 한 번 감싸있다.

3. @SpyBean 일 때

  - 객체가 MockitoMock, SpringCGLIB로 두 번 감싸있다.

정리

@Autowired @MockBean @SpyBean
객체가 SpringCGLIB로만 한 번 감쌌다. 객체가 MockitoMock으로만 한 번 감쌌다. 객체가 MockitoMock, SpringCGLIB로 두 번 감쌌다.

🧐 아마 SpyBean을 사용했을 때 내부로직이 실행되는 이유는 SpringCGLIB 와 관련있는 것 같다(?왜지?)


verify() 메서드 열어보기

MockingDetails.isMock() 가 있다.
이 부분에서 Mock 객체가 아닐시 위에서 봤던 이런 에러가 발생하는 것이다.

Argument passed to verify() is of type PointService$$EnhancerBySpringCGLIB$$2aeafde9 and is not a mock!

즉, 이 부분이 3개 어노테이션으로 생성된 객체가 Mock 객체인지 확인하는 부분이니, 다음 단계에서 살펴보자.

145 라인의 isMock() 조건을 @MockBean, @SpyBean 객체는 통과했고, @Autowired는 통과하지 못했다.

isMock()을 열어보자

그럼 isMock()을 열어봅시다.

여기서는 크게 2개 로직이 있습니다.

 

1. resolve(mock)

SpringProxy인 SpringCGLIB로 감싸져있던 mock의 Proxy를 벗기고, 실제 객체를 리턴합니다.

(resolve() 메서드 내부도 열어봤지만 그 것까지 쓰면 글이 장황해져서 생략한다)

 

어노테이션 별로 resolve() 전/후를 정리해보면 아래와 같습니다.

  @Autowired @MockBean @SpyBean
  객체가 SpringCGLIB로만 한 번 감싸있다. 객체가 MockitoMock으로만 한 번 감싸있다. 객체가 MockitoMock, SpringCGLIB로 두 번 감싸있다.
해체전 [[PointService] SpringCGLIB] [[PointService] MockitoMock] [[[PointService] MockitoMock] SpringCGLIB]
해체후 [PointService] [[PointService] MockitoMock] [[PointService] MockitoMock]

해체후를 보면 @MockBean과 @SpyBean 객체의 결과가 동일합니다. 즉, MockitoMock 객체가 반환됩니다

 

2. return mockMaker.getHandler(mock)

mockReposity에서 mock(키)로 handler(값)을 찾습니다.

즉, @MockBean과 @SpyBean 객체만 true가 반환됩니다.

정리: Mock 객체여야하는 이유

일단 verify() 내부에서 isMock()으로 Mock객체여부를 체크하기 때문이었다.

Mock객체여부를 체크하는 방법은, verify() 검증 대상 인스턴스가 MockitoMock로 감싸진 객체인지 체크한다

실제 객체가 주입되는 @Autowired는 MockitoMock로 감싸지지 않기 때문에 Exception이 발생한 것이다.

그렇다면 왜? 굳이 걸러낼까?

거기까지는 열어보진 않았지만, 당연히 MockitoMock 객체여야 verify()에서 필요한 검증을 진행할 수 있기 때문일거다.

내부 상세 로직은 굳이 열어보지 않아도 될 것 같다.


2. @SpyMock은 어떻게 실제 로직이 실행되나? SpringCGLIB로 감싼 것과 무슨 연관이 있나?

음.. 이건 앞서 의문이었던 jdkDynamicAopProxy와 SpringCGLIB의 차이를 먼저 확인하다보면 알게될 것 같다.

또한 SpringCGLIB의 기능을 알아보면 좋을 것 같다.

확실한건 @SpyMock는 SpringCGLIB의 기능을 이용한 어노테이션이라는 것이다!