[Troubleshooting] - @MockBean 과 @SpyBean

청주는사과아님·2024년 12월 22일
1

Troubleshooting

목록 보기
4/7

김영한 님의 스프링 핵심 원리 - 기본편 을 들으며 실습하던 중, @MockBean@SpyBean 의 차이를 볼 수 있는 트러블 슈팅을 경험했습니다.


🔖 배경 설명

제가 실습하던 부분은 List, Map 을 이용한 전략 패턴 이었습니다.
Spring 을 이용하다 보면 종종 해당 타입의 Bean 들이 모두 필요한 경우 가 있을 수 있습니다.

제가 생각한 예시는 민간 인증서 서비스 로, 사용자가 인증할 때 인증 수단 중 어떤 것을 사용할지 동적으로 결정되어야 하기 때문입니다.

사용자 선택에 따라 카카오, 네이버, 토스 등 인증 수단이 달라지기 때문입니다.

그래서 위 인증 서비스 예시를 전략 패턴 을 이용해 간단히 만들어 보면 공부되겠다 싶어 아래처럼 코드를 구성하였습니다.

  • CertificationType
public enum CertificationType {
    KAKAO, NAVER, PASS, TOSS
}
  • Certification
// 전략 패턴을 위한 인터페이스
public interface Certification {
    void doCertification();
    CertificationType getCertificationType();
}

// 인증 수단별 구현체 (네이버, Pass, 토스는 생략)
@Component
public class KakaoCertification implements Certification{

    @Override
    public void doCertification() {
        System.out.println("I'm kakao certification");
    }

	// 각 인증 수단별 올바른 타입을 반환
    @Override
    public CertificationType getCertificationType() {
        return CertificationType.KAKAO;
    }
}
  • CertificationService
@Service
public class CertificationService {

    private final Map<CertificationType, Certification> certifications;

    public CertificationService(List<Certification> certifications) {
    	
        // 나중 설명을 위해 객체 속성 print
        certifications.forEach(System.out::println);
        certifications.forEach(c -> System.out.println(c.getCertificationType()));
        
    	// 인증 타입에 따라 Map 에 잘 넣어주기
        this.certifications = certifications.stream()
                .collect(Collectors.toMap(
                        Certification::getCertificationType, Function.identity()
                ));
    }

	// Map 에 넣어진 걸로 인증 진행
    public void doCertification(CertificationType type) {
        certifications.get(type).doCertification();
    }
}

❗️ 문제 발견

필요한 클래스를 구성한 후 아래처럼 Mock 을 이용해 실제 메서드가 호출되었는지 확인 하는 테스트 코드를 만들었습니다.

@SpringBootTest
public class StrategyPatternTest {

    @MockitoBean KakaoCertification kakao;
    @MockitoBean NaverCertification naver;
    @MockitoBean PassCertification pass;
    @MockitoBean TossCertification toss;

    @Autowired
    CertificationService certificationService;

    @Test
    void doTest() {
        Arrays.stream(CertificationType.values())
                .forEach(certificationService::doCertification);

        for (Certification certification : List.of(kakao, naver, pass, toss)) {
            verify(certification, times(1)).doCertification();
        }
    }
}

여담으로 우리가 기존 사용하던 @MockBean
spring-boot-test 3.4.0 버전부터 deprecated 되어
3.6.0 버전에는 없어질 것이라 하고 @MockitoBean 사용을 권장하고 있습니다.

하지만 실행하니 CertificationService 의 생성자에서 에러가 발생 해 테스트가 실패하였습니다.

Failed to load ApplicationContext for [...]
...
Caused by: org.springframework.beans.factory.BeanCreationException: Error creating bean with name 'certificationService' ... : Constructor threw exception
  at org.springframework.beans.factory.support.ConstructorResolver.instantiate(ConstructorResolver.java:321)
  ...

이를 확인하고자 생성자에 CertificationType 을 확인해보니 아래처럼 Null 임을 확인했습니다.

@Service
public class CertificationService {

    private final Map<CertificationType, Certification> certifications;

    public CertificationService(List<Certification> certifications) {
    	
        // 직접 제공된 Bean 들 확인
        certifications.forEach(System.out::println);
        
        // CertificationType 도 직접 확인
        certifications.forEach(c -> System.out.println(c.getCertificationType()));
        
		/* ... 생략 ... */
        
    }
}
kakaoCertification	// ? 객체면 hashCode 뒤에 붙을텐데 뭐지? 
naverCertification	// 난 toString 건든 적 없는데?
passCertification	// passCertification@7d90644f 이렇게?
tossCertification
null				// ?? Null?? 왜 비어있음?
null
null
null

📌 문제 원인

문제의 원인은 @MockBean 그 자체에 있었습니다.
Mock 의 사전적 정의는 모조품 으로, Mockito 는 특정 Bean 들의 행동을 감시하고 재정의 할 수 있는 모조품 Bean 을 제공합니다.

때문에 KakaoCertification, NaverCertification 등을 테스트에서 @MockBean 으로 구성할 경우, 각 실제 Bean 의 메서드가 실행되지 않고 모조품의 메서드만 실행됩니다.

즉, @MockBean 들의 getCertificationType() 메서드 행동을 정의하지 않았기 때문에 Null 이 반환되었고 그래서 예외가 발생했던 것 이었습니다.

(Map 의 key 에 Null 인 값을 중복으로 넣으려 했기 때문에 예외 발생)


✅ 문제 해결

이를 해결하는 방법은 다양하나, 가장 1 차원적인 생각으로 @MockBean 들의 행동을 모두 정의 하는 방법이 있습니다.

@Test
void doTest() {

	// MockBean 행동 모두 정의
    when(kakao.getCertificationType()).thenReturn(CertificationType.KAKAO);
    when(naver.getCertificationType()).thenReturn(CertificationType.NAVER);
    
    /* ... */
   
}

하지만 이는 동일한 코드를 너무 많이 작성하고 무엇보다 Bean 들이 내가 구현한 대로 작동하는지 확인 하는 저의 테스트 의도와 맞지 않았습니다.

그래서 @MockBean 대신 @SpyBean 을 이용하였습니다.

@SpringBootTest
public class StrategyPatternTest {

    @MockitoSpyBean KakaoCertification kakao;
    @MockitoSpyBean NaverCertification naver;
    @MockitoSpyBean PassCertification pass;
    @MockitoSpyBean TossCertification toss;

    @Autowired
    CertificationService certificationService;

    @Test
    void doTest() {
        Arrays.stream(CertificationType.values())
                .forEach(certificationService::doCertification);

        List.of(kakao, naver, pass, toss).forEach(
                c -> verify(c, times(1)).doCertification()
        );
    }
}

@SpyBean@MockBean 처럼 행동을 감시하고 재정의 할 수 있는 것을 동일하지만, Spring 에서 제공한 Bean 에 기반한 모조 Bean 입니다.

즉, @MockBean행동을 정의 하지 않으면 앞선 상황처럼 Null 을 뱉어내지만, @SpyBean 은 우리가 만든 행동 그대로 작동하는 모조품인 것입니다.

이 둘의 차이점을 간단하게 정리하면 아래 표처럼 나타낼 수 있습니다.

@MockBean@SpyBean
내부 동작완전한 Mock (모조품) 임. 실제 Spring Bean 과 관련 없음.우리가 구성한 Spring Bean 을 감싸서 동작
기본 method call실제 메서드가 호출되지 않음. 정의하지 않으면 Null 을 반환함.실제 메서드가 호출됨. 특별히 Mock 의 행동을 지정하면 그대로 행동함.

원래 @MockBean@SpyBean 의 다른점은 알고 있었지만 이를 실제로 경험한 것은 처음이었습니다.

깊고 좁게 공부하는 것도 중요하지만, 다양한 트러블 슈팅에 대처하기 위해 넓게 아는 것도 중요하다 느꼈습니다.


profile
나 같은게... 취준?!

0개의 댓글