테스트 코드에서 @MockBean 사용의 문제점과 해결법

Glen·2024년 1월 7일
5

배운것

목록 보기
33/37

서론

좋은 단위 테스트는 FIRST 원칙을 지켜야 한다.

FIRST에서 앞의 두 원칙인 Fast는 빨라야 하고, Isolated는 격리되어야 한다는 뜻이다.

즉, 단위 테스트는 빠르게 실행되고, 다른 테스트에 대해 영향을 받으면 안 된다.

하지만, 스프링을 사용하는 코드에서는 순수한 단위 테스트가 힘든 경우가 있다.

예를 들어, Controller 단위 테스트가 대표적일 것이다.

슬라이스 테스트라고도 부른다!

이 경우 어쩔 수 없이 스프링을 통해 테스트를 실행해야 하므로, 느리고 실행되고 외부 의존성이 필요하다.

또한 Controller 클래스가 의존하는 클래스는 스프링 빈으로 등록되어 있어야 하므로, 주로 @MockBean 어노테이션을 사용하여 Mock 객체를 빈으로 등록시킨다.

이때 발생하는 문제점과 해결법을 알아보자.

본론

Mock 의존성 추가

스프링을 사용하여 빈으로 등록할 클래스는 여러 방법을 통해 DI를 수행한다.

그 중 생성자를 통한 DI를 가장 많이 사용한다.

@RestController
public class FooController {

    private final FooService fooService;

    public FooController(FooService fooService) {  
        this.fooService = fooService;  
    }
    ...
}

이유라면, 필드 주입의 경우 스프링 프레임워크의 실행 없이 필드를 초기화할 수 없기 때문이고, 세터 주입의 경우 세터가 열려있다는 것이 객체의 캡슐화를 위반하고, 기본으로 싱글턴으로 관리되는 빈의 특성상 잠재적인 위험이 발생할 수 있기 때문이다.

하지만 테스트 코드에서는 굳이 생성자 주입을 사용할 필요가 없다.

왜냐하면 스프링 프레임워크 위에서 테스트 코드가 실행되기 때문에, 테스트 코드를 테스트 하지 않는 이상, 필드 주입의 문제점이 전혀 문제가 되지 않는다.

따라서 다음과 같이 테스트에서는 필드 주입을 사용한 테스트 코드를 작성한다.

@WebMvcTest(FooController.class)
class FooControllerTest {

    @Autowired
    FooService fooService;
}

하지만 이 테스트 코드를 실행하면 UnsatisfiedDependencyException 예외가 발생하며 테스트가 실패한다.

org.springframework.beans.factory.UnsatisfiedDependencyException: Error creating bean with name 'kr.co.glen.FooControllerTest': Unsatisfied dependency expressed through field 'fooService': No qualifying bean of type ...

이유는 @WebMvcTest를 사용했기 때문이다.

@WebMvcTest의 Javadoc에는 다음 구문이 있다.

Using this annotation will disable full auto-configuration and instead apply only configuration relevant to MVC tests (i.e. @Controller, @ControllerAdvice, @JsonComponent, Converter/GenericConverter, Filter, WebMvcConfigurer and HandlerMethodArgumentResolver beans but not @Component, @Service or @Repository beans).

요약하면, 모든 빈을 등록하는 것이 아니라, MVC 테스트에 관한 빈들만 등록한다.
따라서 @Service 어노테이션이 붙은 서비스 클래스는 빈으로 등록되지 않아 UnsatisfiedDependencyException 예외가 발생한 것이다.

이 문제를 해결할 수 있는 가장 간단한 방법은 의존하고 있는 서비스 클래스를 Mock으로 등록하는 것이다.

왜냐하면 지금 수행하는 테스트는 Controller에 대한 단위 테스트이다.
Controller가 의존하는 Service 클래스가 실제로 동작할 필요는 없다.
따라서 Service를 Mock으로 등록하여, 원하는 응답을 가정하면 된다.

자동으로 빈을 등록하는 방법은 @WebMvcTest를 사용했기에 막혀버렸으니, Configuration을 사용하여 수동으로 빈을 등록하는 방법을 선택하면 된다.

@TestConfiguration
class FooServiceConfig {

    @Bean
    public FooService fooService() {
        return Mockito.mock(FooService.class)
    }
}

@Import(FooServiceConfig.class)
@WebMvcTest(FooController.class)
class FooControllerTest {

    @Autowired
    FooService fooService;
    ...
}

이제 테스트가 실패하는 일 없이 잘 돌아간다!

@MockBean

스프링을 사용할 때, 하나의 Controller 클래스만 사용하는 경우는 거의 없다.

그렇기 때문에 각 Controller 클래스마다 단위 테스트 코드가 생기게 된다.

이때도 마찬가지로, 해당 Controller가 의존하는 Service를 Mock으로 등록해야 한다.

@Import(FooServiceConfig.class)
@WebMvcTest(FooController.class)
class FooControllerTest {

    @Autowired
    FooService fooService;
    ...
}

@TestConfiguration
class BarServiceConfig {
    ...
    @Bean
    public BarService barService() {
        return Mockito.mock(BarService.class)
    }
}

하지만 Controller 테스트마다 별도의 Configuration 클래스를 만들어 주는 것은 귀찮고 불편하다.

이때 @MockBean 어노테이션을 사용하면 수동 빈 주입을 하지 않고 동적으로 Mock 객체를 빈으로 등록할 수 있다.

방법은 기존 필드 주입 대상인 변수 위에 @Autowired 대신 @MockBean 어노테이션을 붙이면 된다.

@WebMvcTest(FooController.class)
class FooControllerTest {

    @MockBean
    FooService fooService;
    ...
}

@MockBean 어노테이션이 붙은 변수는 해당 변수의 타입에 대해 자동으로 Mock 객체를 빈으로 등록시켜 준다.

@SpyBean 어노테이션으로 Spy 객체를 빈으로 등록할 수 있다.

이제 매우 편하게 Controller 단위 테스트를 작성할 수 있게 됐다.

Application Context

그리고 만들어진 모든 Controller 테스트를 실행해 보면, 테스트 클래스마다 약간의 딜레이가 생기며 뚝뚝 끊기는 현상을 볼 수 있다.

이유는 스프링의 Application Context가 초기화되기 때문이다.

테스트마다 스프링의 Application Context가 초기화되는 것이 당연하지만, 초기화가 되는 시간이 상당히 길기 때문에 스프링은 테스트 시점에 Application Context 구성이 같다면 Application Context를 재사용한다.

그렇기 때문에 @MockBean을 사용하면, 테스트 클래스마다 Application Context 구성이 달라지기 때문에 초기화 작업이 일어나는 것이다.

@WebMvcTest(FooController.class) 처럼 특정 컨트롤러 클래스만 빈으로 등록하는 경우에도 마찬가지로, @MockBean을 사용한 것처럼 테스트마다 Application Context 초기화가 발생한다!

따라서 좋은 단위 테스트가 지켜야 하는 F를 지킬 수 없다.

그렇다고 Controller 테스트에서 스프링에 대한 의존 없이 테스트를 수행하기엔, 더 귀찮은 작업이 동반된다.

class FooControllerTest {

    ObjectMapper objectMapper = new ObjectMapper();
    
    MemberService memberService = mock();

    MemberController memberController = new MemberController(memberService);

    MockMvc mockMvc = MockMvcBuilders  
        .standaloneSetup(memberController)  
        .build();

    ...
}

따라서 스프링이 제공하는 DI를 통해 편하게 테스트 코드를 작성하며, 빠르게 테스트를 실행할 수 있어야 한다.

테스트가 속도가 느려지는 이유는 Application Context를 초기화하기 때문이니, 테스트마다 같은 구성의 Application Context를 사용하면 된다.

해결법은 간단하다.

@WebMvcTest의 속성으로 특정 Controller를 등록하는 것을 사용하지 않고, 모든 컨트롤러가 의존하는 클래스를 @Import 어노테이션을 사용하여 Mock 빈으로 등록하면 된다.

@Import(FooBarConfig.class)
@WebMvcTest
class FooControllerTest {

    @Autowired
    FooService fooService;
    ...
}

@Import(FooBarConfig.class)
@WebMvcTest
class BarControllerTest {

    @Autowired
    BarService barService;
    ...
}

@TestConfiguration
class FooBarConfig {

    @Bean  
    public FooService fooService() {  
        return Mockito.mock();  
    }  
      
    @Bean  
    public BarService barService() {  
        return Mockito.mock();  
    }
}

이제 두 테스트 클래스 모두 같은 Application Context를 사용하기 때문에 초기화가 되지 않고, 빠르게 테스트가 실행된다!

하지만 Fast는 해결했지만, Isolated를 해결했을까?

문제점

사용하지 않는 서비스

FooController가 필요한 의존은 FooService 밖에 없다.

BarController 또한 BarService만 필요하다.

하지만 Application Context 재활용을 위해, 모든 Controller를 빈으로 등록하기 때문에 모든 Controller가 의존하는 Service를 빈으로 등록해야 한다.

만약, 새로운 BazControllerBazService가 생겼을 때, FooBarConfig를 수정하지 않으면 기존의 테스트 코드가 실패하게 된다.

또한, 기존 FooController, BarController에 의존하는 객체가 추가되거나, 삭제되면 다른 테스트 코드 또한 영향을 받는다.

Mock Bean

Application Context에 등록되는 빈의 기본 Scope는 Singleton이다.

따라서 다른 테스트 클래스에서 같은 Service를 사용하는 경우가 있다면 문제가 발생할 수 있다.

@Import(FooBarConfig.class)
@WebMvcTest
class FooControllerTest {

    @Autowired
    BazService bazService;
    ...

    @Test
    void fooTest() {
        ...
        given(bazService.someMethod())  
            .willReturn(expect);
        ...
    }
}

@Import(FooBarConfig.class)
@WebMvcTest
class BarControllerTest {

    @Autowired
    BazService bazService;
    ...

    @Test
    void barTest() {
        ...
        // 격리가 되지 않았다.
        var expect = bazService.someMethod();
        ...
    }
}

FooControllerTest에서 정의한 Stub이 BarControllerTest에 영향을 미친다.

이렇게 Fast를 지키려면, Isolated를 지킬 수 없는 문제점이 발생한다.

그렇다면 두 가지 원칙을 지킬 방법은 없을까?

BeanFactoryPostProcessor

@Config 어노테이션을 사용하여, 수동으로 빈을 등록한 이유는 @WebMvcTest 어노테이션을 사용하면 MVC 테스트에 관한 빈들만 등록하기 때문이었다.

과거 스프링을 사용할 때는 XML 파일을 사용하여 빈을 주입했지만, 최근에는 어노테이션 기반의 컴포넌트 스캔 방식으로 빈을 주입한다.

그렇다면 우리도 @Service 어노테이션이 붙은 클래스를 찾아, 해당 클래스를 Mock으로 등록하면 되지 않을까?

@TestConfiguration
class FooBarConfig {

    @Bean  
    public List<?> allMockService() {  
        ClassFilter classFilter = ClassFilter.of(clazz -> clazz.isAnnotationPresent(Service.class));  
        List<Class<?>> clazzs = ReflectionUtils.findAllClassesInPackage("kr.co.glen", classFilter)
        return clazzs.stream()
            .map(Mockito::mock)
            .toList();
    }  
}

당연하지만, 위의 코드는 올바르지 않다.

위의 코드로 등록되는 빈은 allMockService 이름을 가진 List 타입의 빈이다.

System.out.println(applicationContext.getBean("allMockService").getClass());
// class java.util.ImmutableCollections$ListN

따라서 Controller가 의존하는 Service 타입의 객체를 주입받을 수 없기에 @Configuration을 사용한 수동 빈 등록으로 빈을 등록하기엔 한계가 있다.

스프링은 빈이 ApplicationContext에 등록되기 전에 정의하고 수정할 수 있는 기능을 제공한다.

바로 BeanFactoryPostProcessor이다.

BeanPostProcessor와 다르다!
BeanPostProcessor는 의존 관계가 설정된 빈을 재정의하기 때문에, 우리가 원하는 기능을 사용할 수 없다.

BeanFactoryPostProcessor는 함수형 인터페이스이며, 따라서 다음과 같은 하나의 추상 메서드만 존재한다.

@FunctionalInterface  
public interface BeanFactoryPostProcessor {
    void postProcessBeanFactory(ConfigurableListableBeanFactory beanFactory) throws BeansException;
}

postProcessBeanFactory의 Javadoc에는 다음과 같이 설명 되어있다.

Modify the application context's internal bean factory after its standard initialization.
All bean definitions will have been loaded, but no beans will have been instantiated yet.
This allows for overriding or adding properties even to eager-initializing beans.

즉, 해당 메서드를 사용하면 빈 팩토리에 직접 접근하여 원하는 빈을 수동으로 추가할 수 있다.

다음과 같이, BeanFactoryPostProcessor를 구현한 클래스를 정의하고 beanFactory.registerSingleton() 메서드를 호출하여 Mock 객체를 빈으로 등록할 수 있다.

public class MockAllServiceBeanFactoryPostProcessor implements BeanFactoryPostProcessor {  
  
    @Override  
    public void postProcessBeanFactory(ConfigurableListableBeanFactory beanFactory) throws BeansException {  
        ClassFilter classFilter = ClassFilter.of(clazz -> clazz.isAnnotationPresent(Service.class));  
        ReflectionUtils.findAllClassesInPackage("com.festago", classFilter)  
            .forEach(clazz -> {  
                beanFactory.registerSingleton(clazz.getSimpleName(), mock(clazz));  
            });  
    }  
}

그리고 기존 @ImportMockAllServiceBeanFactoryPostProcessor를 등록하면 된다.

@Import(MockAllServiceBeanFactoryPostProcessor.class)
@WebMvcTest
class FooControllerTest {

    @Autowired
    BazService bazService;
    ...
}

@Autowired는 타입으로 주입 받기 때문에, @Qualifier 어노테이션을 사용하는 게 아니라면, 빈의 이름을 지정할 필요가 없다.

이 경우, 테스트 클래스마다 어노테이션을 붙여야 하는 문제가 있다.

이때는 커스텀 어노테이션을 만들거나, 추상 클래스를 정의하여서 해결하면 된다.

@MockBean 어노테이션도 BeanFactoryPostProcessor 인터페이스를 구현한 MockitoPostProcessor를 사용한다!

커스텀 어노테이션

@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)  
@WebMvcTest
@Import(MockAllServiceBeanFactoryPostProcessor.class)  
public @interface CustomWebMvcTest {}

@CustomWebMvcTest
class FooControllerTest {
    ...
}

추상 클래스

@Import(MockAllServiceBeanFactoryPostProcessor.class)  
@WebMvcTest
public abstract class ControllerTest {  

    @Autowired  
    protected MockMvc mockMvc;
}

class FooControllerTest extends ControllerTest {  
  
}

둘 중 하나의 방법을 선택하여 테스트를 구현하면 된다!

TestExecutionListener

그래도 하나의 문제가 남았다.

Application Context에 등록된 Mock 빈이 싱글턴이기 때문에, 정의한 Stub이 다른 테스트 코드에도 영향을 미친다는 것이다.

이때도 간단하게 해결할 수 있는데, JUnit5에서 제공하는 Lifecycle hook을 사용하면 된다.

@CustomWebMvcTest
class FooControllerTest {

    @Autowired
    BazService bazService;

    @AfterEach  
    void tearDown() {  
        Mockito.reset(bazService);
    }
}

이제 다른 테스트에서 Stub으로 인한 영향을 받지는 않지만, 매번 훅 메서드를 정의해야 한다.

이 경우 Application Context에 등록된 빈들을 모두 조회해서, 초기화하면 매번 훅 메서드를 정의할 필요가 없을 것 같다.

추상 클래스

추상 클래스를 사용한 방법을 사용하면 ApplicationContext 객체를 주입 받아서 구현하면 된다.

@Import(MockAllServiceBeanFactoryPostProcessor.class)  
@WebMvcTest
public abstract class ControllerTest {  

    @Autowired  
    ApplicationContext applicationContext;  
      
    @AfterEach  
    void resetAllMock() {  
        String[] beanDefinitionNames = applicationContext.getBeanDefinitionNames();  
        for (String beanDefinitionName : beanDefinitionNames) {  
            Object bean = applicationContext.getBean(beanDefinitionName);  
            if (MockUtil.isMock(bean)) {  
                MockUtil.resetMock(bean);  
            }  
        }  
    }
}

하지만 이 방법을 사용해도 Mock이 초기화되지 않는다.

이유는 Mock으로 등록한 빈의 BeanDefinition을 추가하지 않았기 때문이다.

그렇기 때문에 applicationContext.getBeanDefinitionNames() 메소드를 호출해도, Mock으로 등록한 빈을 찾을 수 없다.

해결법은 이전 모든 Service를 Mock 빈으로 등록했던 MockAllServiceBeanFactoryPostProcessor 코드에 BeanDefinition을 추가하는 코드를 작성하면 된다.

public class MockAllServiceBeanFactoryPostProcessor implements BeanFactoryPostProcessor {  
  
    @Override  
    public void postProcessBeanFactory(ConfigurableListableBeanFactory beanFactory) throws BeansException {  
        BeanDefinitionRegistry registry = (BeanDefinitionRegistry) beanFactory;  
        ClassFilter classFilter = ClassFilter.of(clazz -> clazz.isAnnotationPresent(Service.class));  
        ReflectionUtils.findAllClassesInPackage("com.festago", classFilter)  
            .forEach(clazz -> {  
                AbstractBeanDefinition bean = BeanDefinitionBuilder  
                    .genericBeanDefinition(clazz)  
                    .getBeanDefinition();  
                registry.registerBeanDefinition(clazz.getSimpleName(), bean);  
                beanFactory.registerSingleton(clazz.getSimpleName(), mock(clazz));  
            });  
    }  
}

BeanDefinitionBuilder 클래스를 사용하여 GenericBeanDefinition을 생성하고 BeanDefinitionRegistryBeanDefinition을 등록하면 된다.

RootBeanDefinition의 Javadoc에서 선언적 빈 정의 방식에는 GenericBeanDefinition을 권장하기 때문에 GenericBeanDefinition을 선택했다.

커스텀 어노테이션

커스텀 어노테이션의 경우 Lifecycle hook 메서드를 선언할 수 없으므로 다른 방법이 필요하다.

스프링은 Lifecycle hook 메서드를 선언하지 않고, 어노테이션으로 훅 메서드를 사용할 수 있게 하는 기능을 제공한다.

바로 TestExecutionListener이다.

public interface TestExecutionListener {  
    default void beforeTestClass(TestContext testContext) throws Exception {};  
    default void prepareTestInstance(TestContext testContext) throws Exception {};  
    default void beforeTestMethod(TestContext testContext) throws Exception {};  
    default void afterTestMethod(TestContext testContext) throws Exception {};  
    default void afterTestClass(TestContext testContext) throws Exception {};  
}

TestExecutionListener는 다음과 같이 Lifecycle hook 메서드를 제공한다.

따라서 TestExecutionListener 인터페이스를 구현하면 된다.

public class ResetMockTestExecutionListener implements TestExecutionListener {
    @Override  
    public void afterTestMethod(TestContext testContext) throws Exception {
        ApplicationContext applicationContext = testContext.getApplicationContext();
        String[] beanDefinitionNames = applicationContext.getBeanDefinitionNames();  
        for (String beanDefinitionName : beanDefinitionNames) {  
            Object bean = applicationContext.getBean(beanDefinitionName);  
            if (MockUtil.isMock(bean)) {  
                MockUtil.resetMock(bean);  
            }  
        }
    }
}

구현한 TestExecutionListener@TestExecutionListeners 어노테이션을 사용하여 커스텀 어노테이션에 추가하면 끝이다.

@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME) 
@WebMvcTest
@Import(MockAllServiceBeanFactoryPostProcessor.class)  
@TestExecutionListeners(value = ResetMockTestExecutionListener.class, mergeMode = MergeMode.MERGE_WITH_DEFAULTS)
public @interface CustomWebMvcTest {}

mergeMode = MergeMode.MERGE_WITH_DEFAULTS를 추가해야 기존의 TestExecutionListener가 유지된다!

이제 Controller 단위 테스트에서 @MockBean으로 인한 Application Context 초기화가 발생하지 않으면서, 외부 테스트에 영향받지 않는 좋은 테스트 코드를 작성할 수 있게 됐다!

결론

Controller 단위 테스트에서 발생하는 Application Context 초기화 때문에 테스트 속도가 느려지는 문제가 발생했다.

따라서 FIRST 원칙의 F를 지킬 수 없었고, 의존하는 Service를 Mock 빈으로 등록하니 I를 지킬 수 없는 딜레마가 발생했다.

이때, 빈이 Application Context에 등록되기 이전에, 수동으로 조작할 수 있게 해주는 BeanFactoryPostProcessor을 사용하여 외부 코드의 변경 없이 의존하는 Service를 Mock 빈으로 등록할 수 있었다.

또한, 싱글턴으로 등록되는 빈의 문제점 때문에, 테스트의 Lifecycle hook을 정의하여 테스트 간에 영향을 받지 않도록 할 수 있었다.

직접 BeanFactoryPostProcessor, BeanDefinition과 같은 스프링의 low한 기능들은 비즈니스 로직에서 사용할 일이 없지만, 테스트 코드와 같은 특수한 상황에서는 사용할 일이 있었다.

스프링을 학습할 때 이러한 기능이 있는 것은 알았지만, 평생 사용할 일이 없을 줄 알았는데, 결국 언젠가 쓸 날이 오긴 온다. 😂

profile
꾸준히 성장하고 싶은 사람

0개의 댓글