Spring Boot Test에서 Test Configuration 감지하기
Spring Boot Reference의 Testing - Detecting Test Configuration 파트를 보면 다음과 같은 내용이 나온다.
If you are familiar with the Spring Test Framework, you may be used to using @ContextConfiguration(classes=…) in order to specify which Spring @Configuration to load. Alternatively, you might have often used nested @Configuration classes within your test.
When testing Spring Boot applications, this is often not required. Spring Boot’s @*Test annotations search for your primary configuration automatically whenever you do not explicitly define one.
The search algorithm works up from the package that contains the test until it finds a class annotated with @SpringBootApplication or @SpringBootConfiguration.
Detecting Test Configuration을 위해서 스프링에 친숙하다면 @ContextConfiguration이나 Nested @Configuration이 필요하다고 하고,
Spring Boot를 사용하면 @*Test(@SpringBootTest, @WebMvcTest, @DataJpaTest, etc.)에서 별다른 설정을 하지 않았다면 primary configuration을 찾아나간다고 한다.
N줄 요약
글이 길어지다보니 아무도 안 볼 거 같고, 집중을 하고 소스코드를 따라가면서 읽어야해서 우선 먼저 요약을 적어놓는다.
TestContext를 로딩하기 위한 Test Configuaration은 다음과 같은 우선순위를 가진다.
- @ContextConfiguration 또는 @ContextHierarchy(여러 @ContextConfiguration을 포함)
- Nested @Configuration
- @SpringBootConfiguration (@SpringBootApplication 어노테이션이 @SpringBootConfiguration 어노테이션을 포함하고 있음)
- Nested @TestConfiguration
1, 2, 3 중 하나는 필수이며 셋 중에 하나만 적용된다.
Nested @TestConfiguration은 @ContextConfiguration을 사용했을 때는 적용되지 않고, Nested @Configuration이나 @SpringBootConfiguration에 추가로 적용된다고 보면 된다.
Nested @Configuration은 여러 개 만들어도 전부 적용되고, Nested @TestConfiguration도 여러 개 만들어도 전부 추가로 적용된다.
@ContextConfiguration
Spring 3.1에 추가된 기능으로 해당 블로그를 보면 아래와 같이 나와있다.
At its core, the TestContext framework allows you to annotate test classes with @ContextConfiguration to specify which configuration files to use to load the ApplicationContext for your test.
@ContextConfiguration 어노테이션에 기술한 configuration file들이 ApplicationContext에 로딩되는 걸 TestContext framework에서 해준다는 내용이다.
그럼 @ContextConfiguration에 기술할 수 있는 configuration file에는 무엇이 있을까?
ContextConfiguration Javadoc을 보면 다음과 같이 나와있다.
Component Classes
The term component class can refer to any of the following.
-A class annotated with @Configuration
-A component (i.e., a class annotated with @Component, @Service, @Repository, etc.)
-A JSR-330 compliant class that is annotated with javax.inject annotations
-Any class that contains @Bean-methods
-Any other class that is intended to be registered as a Spring component (i.e., a Spring bean in the ApplicationContext), potentially taking advantage of automatic autowiring of a single constructor without the use of Spring annotations
빈에 관련된 설정(@Configuration) 파일이나 빈에 등록될 수 있는 어노테이션(@Component, @Service, @Repository 등등)은 기본적으로 기술할 수 있다고 보면 된다.
테스트 코드를 통해 간단히 확인해보자
우선 src/main에 인터페이스를 하나 만들자.
1 | interface SomeInterface |
그리고 src/test에 구현체를 하나 만들어주자.
1 | class SomeInterfaceInContextConfiguration : SomeInterface |
이제 테스트 클래스를 작성해서 @ContextConfiguration의 간단한 동작을 검증해보자.
참고로 Spring Boot 2.1.x 미만에서는 @ExtendWith(SpringExtension::class)를 추가해줘야한다.
또한 Spring Boot 2.2.x 미만에서는 @TestConstructor 어노테이션이 없기 때문에 생성자 안의 파라미터 마다 @Autowired 어노테이션을 추가해줘야한다.
그리고 JUnit 4에서는 Field Injection 밖에 지원하지 않기 때문에 Constructor Injection을 사용하려면 JUnit 5를 사용해야한다.
1 |
|
실제 어떻게 동작하는지 하나씩 찾아나가보자.
@SpringBootTest 어노테이션을 보면 그 안에 @ExtendWith(SpringExtension.class) 어노테이션이 포함돼있다.
또한 @BootstrapWith 어노테이션을 통해 어떤 클래스를 통해 Spring TestContext Framework를 부트스트랩할 지 명시하고 있다.
1 |
|
그리고 SpringExtension 클래스의 beforeAll 메서드를 보면 testContextManager를 가져오고 있다.
1 |
|
그리고 그 안에서 TestContextManager를 초기화하고 있다.
1 | private static TestContextManager getTestContextManager(ExtensionContext context) { |
TestContextManager 생성자에서는 TestContextBootstrapper를 resolving하고 있다.
1 | public TestContextManager(Class<?> testClass) { |
1 | static TestContextBootstrapper resolveTestContextBootstrapper(BootstrapContext bootstrapContext) { |
resolveExplicitTestContextBootstrapper 메서드를 보면 testClass에 달려있는 BootstrapWith 어노테이션을 사용하는 걸 볼 수 있다.
1 | private static Class<?> resolveExplicitTestContextBootstrapper(Class<?> testClass) { |
- testClass에 BootstrapWith 어노테이션을 찾는다.
- 없으면 null을 반환한다.
- 하나만 있으면 어노테이션의 value에 기술된 TestContextBootstrapper 클래스를 반환한다.
- 두 개 이상이면 테스트 클래스에 직접적으로 기술된 BootstrapWith 어노테이션을 찾는다.
- 있으면 value에 기술된 TestContextBootstrapper 클래스를 반환한다.
- 없으면 우선순위 충돌로 인해 multiple @BootstrapWith 어노테이션을 발견했다는 에러를 반환한다.
우리는 @SpringBootTest 어노테이션에 있는 @BootstrapWith(SpringBootTestContextBootstrapper.class) 하나만 기술돼있기 때문에 SpringBootTestContextBootstrapper가 반환된다
이제 testContextBootstrapper를 구했으면 인자로 넘겨서 TestContextManager를 초기화 하고 있는데 TestContextManager 생성자 안에서는 testContext를 만들고 있다.
1 | public TestContextManager(TestContextBootstrapper testContextBootstrapper) { |
그리고 메서드를 쭉쭉 타고 들어가다보면 AbstractTestContextBootstrapper 클래스의 buildMergedContextConfiguration 메서드에서 ContextConfiguration 어노테이션 유무를 판단하고 처리하고 있다.
1 | public final MergedContextConfiguration buildMergedContextConfiguration() { |
- testClass에 ContextConfiguration 어노테이션이나 ContextHierarchy 어노테이션이 포함됐는지 확인한다.
- 포함됐으면 ContextHierarchy 어노테이션이 포함됐는지 확인 후에 처리한 걸 반환한다.
- ContextConfiguration 어노테이션이 포함됐는지 확인 후에 처리한 걸 반환한다.
3번에 의해 동작이 되는 거라고 보면 된다.
@ContextHierarchy 어노테이션은 @ContextConfiguration을 배열로 가지는 어노테이션으로 여러 @ContextConfiguration이 필요할 때 사용하면 된다.
Nested @Configuration
우선 동작하는 코드를 간단히 살펴보자.
src/test에 인터페이스의 구현체를 하나 더 추가해보자.
1 | class SomeInterfaceInNestedConfiguration : SomeInterface |
그리고 테스트 코드를 통해 검증해보자
1 |
|
이제 실제로 어떻게 동작하는지 또 알아보자.
기본적으로 위에 설정한 동작방식 그대로를 쫓아가다가 분기문에서 갈라진다고 보면 된다.
AbstractTestContextBootstrapper 클래스의 buildMergedContextConfiguration 메서드에서 ContextConfiguration 어노테이션 유무를 판단하고 있는 걸 위에서 살펴보았다.
1 | public final MergedContextConfiguration buildMergedContextConfiguration() { |
우리 클래스에서는 해당 어노테이션이 없기 때문에 buildDefaultMergedContextConfiguration 메서드를 쭉쭉 타고 보면 buildMergedContextConfiguration 메서드까지 가게 된다.
1 | private MergedContextConfiguration buildMergedContextConfiguration(Class<?> testClass, |
그리고 SpringBootContextLoader의 processContextConfiguration 메서드를 보면 detectDefaultConfigurationClasses를 호출하고 있다.
(우리의 테스트 코드에서는 resource가 비어있는데 그거까지 이 포스트에서 다루기에는 너무 방대해져서 생략했다.)
1 |
|
메서드를 또 쭉쭉 타고 들어가다 보면 AnnotationConfigContextLoaderUtils 클래스의 detectDefaultConfigurationClasses 메서드를 호출하고 있다.
1 | public static Class<?>[] detectDefaultConfigurationClasses(Class<?> declaringClass) { |
그리고 그 안에는 testClass(declaringClass)에 getDeclaredClasses 메서드를 호출하고 있다.
해당 메서드는 클래스에 정의된 클래스 객체를 반환하는 메서드라고 보면 된다.
따라서 Nested class들을 전부 반환하게 되는데 이 class 들을 for-loop 돌면서 isDefaultConfigurationClassCandidate 메서드를 호출해서 DefaultConfigurationClassCandidate라면 추가한 후에 반환하고 있다.
isDefaultConfigurationClassCandidate 메서드를 보면 static이면서 private이 아니고, final이 아닌 클래스인데 @Configuration 어노테이션이 붙어있는지 판단하고 있다.
1 | private static boolean isDefaultConfigurationClassCandidate( { Class<?> clazz) |
이렇게 Nested @Configuration 클래스를 추가했으면 그 다음에 또 메서드를 쭉쭉 타고 들어가다보면 SpringBootTestContextBootstrapper 클래스의 getOrFindConfigurationClasses 메서드를 호출하고 있다.
1 | protected Class<?>[] getOrFindConfigurationClasses(MergedContextConfiguration mergedConfig) { |
그리고 containsNonTestComponent 메서드에서는 Nested @Configuration classes 중에 @TestConfiguration 어노테이션이 붙지 않은 클래스가 하나라도 존재하면 Nested @Configuration classes들을 merge 하여 Test Configuration으로 사용하고 있다.
즉 Nested @Configuration 클래스가 2개여도 두 @Configuration을 하나로 머지하여 사용한다고 보면 된다.
1 | private boolean containsNonTestComponent(Class<?>[] classes) { |
@SpringBootConfiguration
스프링 부트의 primary configuration은 @SpringBootConfiguration이다.
하지만 @SpringBootConfiguration을 직접 사용하는 경우는 아직까지 보지 못했고 @SpringBootApplication을 사용하면 그 안에 포함돼있다.
1 |
|
src/main에 @SpringBootApplication 클래스를 하나 추가해주자.
1 |
|
그리고 SomeInterface의 구현체도 하나 작성해주자
1 | class SomeInterfaceInConfiguration : SomeInterface |
해당 클래스를 빈으로 등록해줄 Config 클래스도 작성하자.
1 |
|
그리고 테스트를 통해 해당 빈이 주입되는지 검증해보자.
1 |
|
이제 실제로 왜 이렇게 동작하는지 알아보자.
위에 살펴봤던 것과 같이 SpringBootTestContextBootstrapper 클래스의 getOrFindConfigurationClasses 메서드를 호출하고 있다.
그리고 Nested @Configuration 클래스가 하나라도 존재하는지 containsNonTestComponent 메서드를 통해 검증했었다.
하지만 이번에는 하나도 설정한 게 없으므로 그 아래에 있는 부분을 탄다.
1 | protected Class<?>[] getOrFindConfigurationClasses(MergedContextConfiguration mergedConfig) { |
- nested @Configuration 클래스를 가져온다.
- nested @TestConfiguration이 아닌 nested @Configuration 클래스가 하나라도 존재한다면 nested @Configuration(nested @TestConfiguration 포함) 클래스들을 반환한다.
- @SpringBootConfiguration 어노테이션이 붙은 클래스를 가져온다.
- 3에서 클래스를 찾지 못했다면 @SpringBootConfiguration이 붙은 클래스를 찾지 못하여 @ContextConfiguration이나 @SpringBootTest에 component classes를 명시하라고 에러를 뱉는다.
- 3에서 찾은 클래스와 nested @Configuration 클래스를 머지한다.
이렇게 nested @Configuration 클래스가 없다면 디폴트로 @SpringBootConfiguration이 붙은 @SpringBootApplication이 붙은 클래스가 Test Configuration으로 사용된다고 보면 된다.
Nested @TestConfiguration
SpringBootTestContextBootstrapper 클래스의 getOrFindConfigurationClasses 메서드를 보면 containsNonTestComponent 메서드를 호출하고 있다.
즉, Nested @TestConfiguration이 아닌 Nested @Configuration 클래스가 하나라도 존재하는지 찾는 것인데…
Nested @TestConfiguration 클래스는 어떤 역할을 하는 걸까??
src/test에 SomeInterface의 구현체를 하나 더 추가해보자
1 | class SomeInterfaceInNestedTestConfiguration : SomeInterface |
그리고 해당 빈이 주입되도록 Nested @TestConfiguration을 사용하여 테스트를 작성해보자.
1 |
|
실제로 src/main에 있는 @Configuration도 주입되고, Nested @TestConfiguration도 주입된 걸 볼 수 있다.
Nested @TestConfiguration의 용도는 원래 Configuration(@SpringBootConfigurtion 또는 Nested @Configuration)에 추가적으로 설정할 Configuration을 위해 사용한다고 보면 된다.
위에 살펴봤던 것과 같이 SpringBootTestContextBootstrapper 클래스의 getOrFindConfigurationClasses 메서드를 호출하고 있다.
그리고 Nested @Configuration 클래스가 하나라도 존재하는지 containsNonTestComponent 메서드를 통해 검증했었다.
이번에는 Nested @TestConfiguration 클래스를 설정했으므로 그 관점에서 바라보자.
1 | protected Class<?>[] getOrFindConfigurationClasses(MergedContextConfiguration mergedConfig) { |
- nested @Configuration 클래스를 가져온다.
- nested @TestConfiguration이 아닌 nested @Configuration 클래스가 하나라도 존재한다면 nested @Configuration(nested @TestConfiguration 포함) 클래스들을 반환한다.
- @SpringBootConfiguration 어노테이션이 붙은 클래스를 가져온다.
- 3에서 클래스를 찾지 못했다면 @SpringBootConfiguration이 붙은 클래스를 찾지 못하여 @ContextConfiguration이나 @SpringBootTest에 component classes를 명시하라고 에러를 뱉는다.
- 3에서 찾은 클래스와 nested @Configuration 클래스를 머지한다.
@TestConfiguration 어노테이션이 @Configuration을 포함하고 있으므로 mergedConfig.getClasses()
에서는 Nested @Configuration과 Nested @TestConfiguration 클래스가 나온다고 보면 된다.
1 |
|
그리고 우리는 Nested @Configuration 클래스는 하나도 없으므로 containsNonTestComponent(classes)
에서 false를 뱉고
그 아래에서 Class<?> found = new AnnotatedClassFinder(SpringBootConfiguration.class).findFromClass(mergedConfig.getTestClass());
로 찾아온 @SpringBootApplication 클래스와 Nested @TestConfiguration 클래스가 머지된다고 보면 된다.
실제로 merge() 메서드에서는 두 Configuration들을 머지하고 있다.
1 | private Class<?>[] merge(Class<?> head, Class<?>[] existing) { |
참고로 @ContextConfiguration을 사용할 때는 Nested @Configuration/@TestConfiuration이 먹히지 않는다. (물론 @SpringBootConfiguration도 씹힌다.)
1 |
|