좋은 단위 테스트는 FIRST 원칙을 지켜야 한다.
FIRST에서 앞의 두 원칙인 Fast
는 빨라야 하고, Isolated
는 격리되어야 한다는 뜻이다.
즉, 단위 테스트는 빠르게 실행되고, 다른 테스트에 대해 영향을 받으면 안 된다.
하지만, 스프링을 사용하는 코드에서는 순수한 단위 테스트가 힘든 경우가 있다.
예를 들어, Controller 단위 테스트가 대표적일 것이다.
슬라이스 테스트라고도 부른다!
이 경우 어쩔 수 없이 스프링을 통해 테스트를 실행해야 하므로, 느리고 실행되고 외부 의존성이 필요하다.
또한 Controller 클래스가 의존하는 클래스는 스프링 빈으로 등록되어 있어야 하므로, 주로 @MockBean
어노테이션을 사용하여 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;
...
}
이제 테스트가 실패하는 일 없이 잘 돌아간다!
스프링을 사용할 때, 하나의 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 단위 테스트를 작성할 수 있게 됐다.
그리고 만들어진 모든 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를 빈으로 등록해야 한다.
만약, 새로운 BazController
와 BazService
가 생겼을 때, FooBarConfig
를 수정하지 않으면 기존의 테스트 코드가 실패하게 된다.
또한, 기존 FooController
, BarController
에 의존하는 객체가 추가되거나, 삭제되면 다른 테스트 코드 또한 영향을 받는다.
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
를 지킬 수 없는 문제점이 발생한다.
그렇다면 두 가지 원칙을 지킬 방법은 없을까?
@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));
});
}
}
그리고 기존 @Import
에 MockAllServiceBeanFactoryPostProcessor
를 등록하면 된다.
@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 {
}
둘 중 하나의 방법을 선택하여 테스트를 구현하면 된다!
그래도 하나의 문제가 남았다.
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
을 생성하고 BeanDefinitionRegistry
에 BeanDefinition
을 등록하면 된다.
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한 기능들은 비즈니스 로직에서 사용할 일이 없지만, 테스트 코드와 같은 특수한 상황에서는 사용할 일이 있었다.
스프링을 학습할 때 이러한 기능이 있는 것은 알았지만, 평생 사용할 일이 없을 줄 알았는데, 결국 언젠가 쓸 날이 오긴 온다. 😂