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은 다음과 같은 우선순위를 가진다.

  1. @ContextConfiguration 또는 @ContextHierarchy(여러 @ContextConfiguration을 포함)
  2. Nested @Configuration
  3. @SpringBootConfiguration (@SpringBootApplication 어노테이션이 @SpringBootConfiguration 어노테이션을 포함하고 있음)
  4. 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
2
3
4
5
6
7
8
9
10
11
@SpringBootTest
@ContextConfiguration(classes = [SomeInterfaceInContextConfiguration::class])
@TestConstructor(autowireMode = TestConstructor.AutowireMode.ALL)
internal class ContextConfigurationTest2(
private val someInterface: SomeInterface
) {
@Test
internal fun `@ContextConfiguration에 기술된 Component Classes들이 Test Configuration으로 사용된다`() {
assertThat(someInterface).isExactlyInstanceOf(SomeInterfaceInContextConfiguration::class.java)
}
}

실제 어떻게 동작하는지 하나씩 찾아나가보자.
@SpringBootTest 어노테이션을 보면 그 안에 @ExtendWith(SpringExtension.class) 어노테이션이 포함돼있다.
또한 @BootstrapWith 어노테이션을 통해 어떤 클래스를 통해 Spring TestContext Framework를 부트스트랩할 지 명시하고 있다.

1
2
3
4
5
6
7
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Inherited
@BootstrapWith(SpringBootTestContextBootstrapper.class)
@ExtendWith(SpringExtension.class)
public @interface SpringBootTest {

그리고 SpringExtension 클래스의 beforeAll 메서드를 보면 testContextManager를 가져오고 있다.

1
2
3
4
@Override
public void beforeAll(ExtensionContext context) throws Exception {
getTestContextManager(context).beforeTestClass();
}

그리고 그 안에서 TestContextManager를 초기화하고 있다.

1
2
3
4
5
6
private static TestContextManager getTestContextManager(ExtensionContext context) {
Assert.notNull(context, "ExtensionContext must not be null");
Class<?> testClass = context.getRequiredTestClass();
Store store = getStore(context);
return store.getOrComputeIfAbsent(testClass, TestContextManager::new, TestContextManager.class);
}

TestContextManager 생성자에서는 TestContextBootstrapper를 resolving하고 있다.

1
2
3
public TestContextManager(Class<?> testClass) {
this(BootstrapUtils.resolveTestContextBootstrapper(BootstrapUtils.createBootstrapContext(testClass)));
}

BootstrapUtils.resolveTestContextBootstrapper 메서드 안에서는 resolveExplicitTestContextBootstrapper 메서드를 호출하고 있다.

1
2
3
4
5
6
static TestContextBootstrapper resolveTestContextBootstrapper(BootstrapContext bootstrapContext) {
Class<?> testClass = bootstrapContext.getTestClass();

Class<?> clazz = null;
try {
clazz = resolveExplicitTestContextBootstrapper(testClass);

resolveExplicitTestContextBootstrapper 메서드를 보면 testClass에 달려있는 BootstrapWith 어노테이션을 사용하는 걸 볼 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
private static Class<?> resolveExplicitTestContextBootstrapper(Class<?> testClass) {
Set<BootstrapWith> annotations = new LinkedHashSet<>();
AnnotationDescriptor<BootstrapWith> descriptor =
TestContextAnnotationUtils.findAnnotationDescriptor(testClass, BootstrapWith.class);
while (descriptor != null) {
annotations.addAll(descriptor.findAllLocalMergedAnnotations());
descriptor = descriptor.next();
}

if (annotations.isEmpty()) {
return null;
}
if (annotations.size() == 1) {
return annotations.iterator().next().value();
}

// Allow directly-present annotation to override annotations that are meta-present.
BootstrapWith bootstrapWith = testClass.getDeclaredAnnotation(BootstrapWith.class);
if (bootstrapWith != null) {
return bootstrapWith.value();
}

throw new IllegalStateException(String.format(
"Configuration error: found multiple declarations of @BootstrapWith for test class [%s]: %s",
testClass.getName(), annotations));
}
  1. testClass에 BootstrapWith 어노테이션을 찾는다.
  2. 없으면 null을 반환한다.
  3. 하나만 있으면 어노테이션의 value에 기술된 TestContextBootstrapper 클래스를 반환한다.
  4. 두 개 이상이면 테스트 클래스에 직접적으로 기술된 BootstrapWith 어노테이션을 찾는다.
  5. 있으면 value에 기술된 TestContextBootstrapper 클래스를 반환한다.
  6. 없으면 우선순위 충돌로 인해 multiple @BootstrapWith 어노테이션을 발견했다는 에러를 반환한다.

우리는 @SpringBootTest 어노테이션에 있는 @BootstrapWith(SpringBootTestContextBootstrapper.class) 하나만 기술돼있기 때문에 SpringBootTestContextBootstrapper가 반환된다

이제 testContextBootstrapper를 구했으면 인자로 넘겨서 TestContextManager를 초기화 하고 있는데 TestContextManager 생성자 안에서는 testContext를 만들고 있다.

1
2
3
4
public TestContextManager(TestContextBootstrapper testContextBootstrapper) {
this.testContext = testContextBootstrapper.buildTestContext();
registerTestExecutionListeners(testContextBootstrapper.getTestExecutionListeners());
}

그리고 메서드를 쭉쭉 타고 들어가다보면 AbstractTestContextBootstrapper 클래스의 buildMergedContextConfiguration 메서드에서 ContextConfiguration 어노테이션 유무를 판단하고 처리하고 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public final MergedContextConfiguration buildMergedContextConfiguration() {
Class<?> testClass = getBootstrapContext().getTestClass();
CacheAwareContextLoaderDelegate cacheAwareContextLoaderDelegate = getCacheAwareContextLoaderDelegate();

if (TestContextAnnotationUtils.findAnnotationDescriptorForTypes(
testClass, ContextConfiguration.class, ContextHierarchy.class) == null) {
return buildDefaultMergedContextConfiguration(testClass, cacheAwareContextLoaderDelegate);
}

if (TestContextAnnotationUtils.findAnnotationDescriptor(testClass, ContextHierarchy.class) != null) {
// ...
}
else {
return buildMergedContextConfiguration(testClass,
ContextLoaderUtils.resolveContextConfigurationAttributes(testClass),
null, cacheAwareContextLoaderDelegate, true);
}
}
  1. testClass에 ContextConfiguration 어노테이션이나 ContextHierarchy 어노테이션이 포함됐는지 확인한다.
  2. 포함됐으면 ContextHierarchy 어노테이션이 포함됐는지 확인 후에 처리한 걸 반환한다.
  3. ContextConfiguration 어노테이션이 포함됐는지 확인 후에 처리한 걸 반환한다.

3번에 의해 동작이 되는 거라고 보면 된다.
@ContextHierarchy 어노테이션은 @ContextConfiguration을 배열로 가지는 어노테이션으로 여러 @ContextConfiguration이 필요할 때 사용하면 된다.

Nested @Configuration

우선 동작하는 코드를 간단히 살펴보자.
src/test에 인터페이스의 구현체를 하나 더 추가해보자.

1
class SomeInterfaceInNestedConfiguration : SomeInterface

그리고 테스트 코드를 통해 검증해보자

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@SpringBootTest
@TestConstructor(autowireMode = TestConstructor.AutowireMode.ALL)
internal class NestedConfigurationTest(
private val someInterface: SomeInterface
) {
@Configuration
internal class Config {
@Bean
fun someInterface() = SomeInterfaceInNestedConfiguration()
}

@Test
internal fun `@ContextConfiguration 어노테이션 다음으로는 Nested @Configuration 클래스가 Test Configuration으로 사용된다`() {
assertThat(someInterface).isExactlyInstanceOf(SomeInterfaceInNestedConfiguration::class.java)
}
}

이제 실제로 어떻게 동작하는지 또 알아보자.
기본적으로 위에 설정한 동작방식 그대로를 쫓아가다가 분기문에서 갈라진다고 보면 된다.
AbstractTestContextBootstrapper 클래스의 buildMergedContextConfiguration 메서드에서 ContextConfiguration 어노테이션 유무를 판단하고 있는 걸 위에서 살펴보았다.

1
2
3
4
5
6
7
8
9
10
public final MergedContextConfiguration buildMergedContextConfiguration() {
Class<?> testClass = getBootstrapContext().getTestClass();
CacheAwareContextLoaderDelegate cacheAwareContextLoaderDelegate = getCacheAwareContextLoaderDelegate();

if (TestContextAnnotationUtils.findAnnotationDescriptorForTypes(
testClass, ContextConfiguration.class, ContextHierarchy.class) == null) {
return buildDefaultMergedContextConfiguration(testClass, cacheAwareContextLoaderDelegate);
}
// ...
}

우리 클래스에서는 해당 어노테이션이 없기 때문에 buildDefaultMergedContextConfiguration 메서드를 쭉쭉 타고 보면 buildMergedContextConfiguration 메서드까지 가게 된다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
private MergedContextConfiguration buildMergedContextConfiguration(Class<?> testClass,
List<ContextConfigurationAttributes> configAttributesList, @Nullable MergedContextConfiguration parentConfig,
CacheAwareContextLoaderDelegate cacheAwareContextLoaderDelegate,
boolean requireLocationsClassesOrInitializers) {

Assert.notEmpty(configAttributesList, "ContextConfigurationAttributes list must not be null or empty");

// @BootstrapWith(SpringBootTestContextBootstrapper.class)에 의해 SpringBootTestContextBootstrapper의 getDefaultContextLoaderClass 메서드를 호출하여
// SpringBootContextLoader가 resolving 됨
ContextLoader contextLoader = resolveContextLoader(testClass, configAttributesList);
List<String> locations = new ArrayList<>();
List<Class<?>> classes = new ArrayList<>();
List<Class<?>> initializers = new ArrayList<>();

for (ContextConfigurationAttributes configAttributes : configAttributesList) {
if (logger.isTraceEnabled()) {
logger.trace(String.format("Processing locations and classes for context configuration attributes %s",
configAttributes));
}

if (contextLoader instanceof SmartContextLoader) { // SpringBootContextLoader는 SmartContextLoader의 구현체이다
SmartContextLoader smartContextLoader = (SmartContextLoader) contextLoader;
smartContextLoader.processContextConfiguration(configAttributes);

그리고 SpringBootContextLoader의 processContextConfiguration 메서드를 보면 detectDefaultConfigurationClasses를 호출하고 있다.
(우리의 테스트 코드에서는 resource가 비어있는데 그거까지 이 포스트에서 다루기에는 너무 방대해져서 생략했다.)

1
2
3
4
5
6
7
8
@Override
public void processContextConfiguration(ContextConfigurationAttributes configAttributes) {
super.processContextConfiguration(configAttributes);
if (!configAttributes.hasResources()) {
Class<?>[] defaultConfigClasses = detectDefaultConfigurationClasses(configAttributes.getDeclaringClass());
configAttributes.setClasses(defaultConfigClasses);
}
}

메서드를 또 쭉쭉 타고 들어가다 보면 AnnotationConfigContextLoaderUtils 클래스의 detectDefaultConfigurationClasses 메서드를 호출하고 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public static Class<?>[] detectDefaultConfigurationClasses(Class<?> declaringClass) {
Assert.notNull(declaringClass, "Declaring class must not be null");

List<Class<?>> configClasses = new ArrayList<>();

for (Class<?> candidate : declaringClass.getDeclaredClasses()) {
if (isDefaultConfigurationClassCandidate(candidate)) {
configClasses.add(candidate);
}
// ..
}
// ..
return ClassUtils.toClassArray(configClasses);
}

그리고 그 안에는 testClass(declaringClass)에 getDeclaredClasses 메서드를 호출하고 있다.
해당 메서드는 클래스에 정의된 클래스 객체를 반환하는 메서드라고 보면 된다.
따라서 Nested class들을 전부 반환하게 되는데 이 class 들을 for-loop 돌면서 isDefaultConfigurationClassCandidate 메서드를 호출해서 DefaultConfigurationClassCandidate라면 추가한 후에 반환하고 있다.

isDefaultConfigurationClassCandidate 메서드를 보면 static이면서 private이 아니고, final이 아닌 클래스인데 @Configuration 어노테이션이 붙어있는지 판단하고 있다.

1
2
3
4
private static boolean isDefaultConfigurationClassCandidate(@Nullable Class<?> clazz) {
return (clazz != null && isStaticNonPrivateAndNonFinal(clazz) &&
AnnotatedElementUtils.hasAnnotation(clazz, Configuration.class));
}

이렇게 Nested @Configuration 클래스를 추가했으면 그 다음에 또 메서드를 쭉쭉 타고 들어가다보면 SpringBootTestContextBootstrapper 클래스의 getOrFindConfigurationClasses 메서드를 호출하고 있다.

1
2
3
4
5
6
7
protected Class<?>[] getOrFindConfigurationClasses(MergedContextConfiguration mergedConfig) {
Class<?>[] classes = mergedConfig.getClasses();
if (containsNonTestComponent(classes) || mergedConfig.hasLocations()) {
return classes;
}
// ...
}

그리고 containsNonTestComponent 메서드에서는 Nested @Configuration classes 중에 @TestConfiguration 어노테이션이 붙지 않은 클래스가 하나라도 존재하면 Nested @Configuration classes들을 merge 하여 Test Configuration으로 사용하고 있다.
즉 Nested @Configuration 클래스가 2개여도 두 @Configuration을 하나로 머지하여 사용한다고 보면 된다.

1
2
3
4
5
6
7
8
9
private boolean containsNonTestComponent(Class<?>[] classes) {
for (Class<?> candidate : classes) {
if (!MergedAnnotations.from(candidate, SearchStrategy.INHERITED_ANNOTATIONS)
.isPresent(TestConfiguration.class)) {
return true;
}
}
return false;
}

@SpringBootConfiguration

스프링 부트의 primary configuration은 @SpringBootConfiguration이다.
하지만 @SpringBootConfiguration을 직접 사용하는 경우는 아직까지 보지 못했고 @SpringBootApplication을 사용하면 그 안에 포함돼있다.

1
2
3
4
5
6
7
8
9
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Inherited
@SpringBootConfiguration
@EnableAutoConfiguration
@ComponentScan(excludeFilters = { @Filter(type = FilterType.CUSTOM, classes = TypeExcludeFilter.class),
@Filter(type = FilterType.CUSTOM, classes = AutoConfigurationExcludeFilter.class) })
public @interface SpringBootApplication {

src/main에 @SpringBootApplication 클래스를 하나 추가해주자.

1
2
@SpringBootApplication
class DemoApplication

그리고 SomeInterface의 구현체도 하나 작성해주자

1
class SomeInterfaceInConfiguration : SomeInterface

해당 클래스를 빈으로 등록해줄 Config 클래스도 작성하자.

1
2
3
4
5
@Configuration
class SomeInterfaceConfig {
@Bean
fun someInterface() = SomeInterfaceInConfiguration()
}

그리고 테스트를 통해 해당 빈이 주입되는지 검증해보자.

1
2
3
4
5
6
7
8
9
10
@SpringBootTest
@TestConstructor(autowireMode = TestConstructor.AutowireMode.ALL)
internal class SpringBootConfigurationTest(
private val someInterface: SomeInterface
) {
@Test
internal fun `테스트용 설정이 없으면 기본적으로 @SpringBootApplication 클래스가 Test Configuration으로 사용된다`() {
assertThat(someInterface).isExactlyInstanceOf(SomeInterfaceInConfiguration::class.java)
}
}

이제 실제로 왜 이렇게 동작하는지 알아보자.
위에 살펴봤던 것과 같이 SpringBootTestContextBootstrapper 클래스의 getOrFindConfigurationClasses 메서드를 호출하고 있다.
그리고 Nested @Configuration 클래스가 하나라도 존재하는지 containsNonTestComponent 메서드를 통해 검증했었다.
하지만 이번에는 하나도 설정한 게 없으므로 그 아래에 있는 부분을 탄다.

1
2
3
4
5
6
7
8
9
10
11
12
protected Class<?>[] getOrFindConfigurationClasses(MergedContextConfiguration mergedConfig) {
Class<?>[] classes = mergedConfig.getClasses();
if (containsNonTestComponent(classes) || mergedConfig.hasLocations()) {
return classes;
}
Class<?> found = new AnnotatedClassFinder(SpringBootConfiguration.class)
.findFromClass(mergedConfig.getTestClass());
Assert.state(found != null, "Unable to find a @SpringBootConfiguration, you need to use "
+ "@ContextConfiguration or @SpringBootTest(classes=...) with your test");
logger.info("Found @SpringBootConfiguration " + found.getName() + " for test " + mergedConfig.getTestClass());
return merge(found, classes);
}
  1. nested @Configuration 클래스를 가져온다.
  2. nested @TestConfiguration이 아닌 nested @Configuration 클래스가 하나라도 존재한다면 nested @Configuration(nested @TestConfiguration 포함) 클래스들을 반환한다.
  3. @SpringBootConfiguration 어노테이션이 붙은 클래스를 가져온다.
  4. 3에서 클래스를 찾지 못했다면 @SpringBootConfiguration이 붙은 클래스를 찾지 못하여 @ContextConfiguration이나 @SpringBootTest에 component classes를 명시하라고 에러를 뱉는다.
  5. 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
@SpringBootTest
@TestConstructor(autowireMode = TestConstructor.AutowireMode.ALL)
class NestedTestConfigurationTest(
private val someInterface: SomeInterface,
private val someInterface2: SomeInterface,
) {
@TestConfiguration
internal class Config {
@Bean
fun someInterface2() = SomeInterfaceInNestedTestConfiguration()
}

@Test
internal fun `@SpringBootConfiguration에 의해 src에 있는 @Configuration 클래스에 있는 빈이 주입된다`() {
assertThat(someInterface).isExactlyInstanceOf(SomeInterfaceInConfiguration::class.java)
}

@Test
internal fun `@SpringBootConfiguration에 없는 건 @TestConfiguration 클래스에 있는 빈이 주입된다`() {
assertThat(someInterface2).isExactlyInstanceOf(SomeInterfaceInNestedTestConfiguration::class.java)
}
}

실제로 src/main에 있는 @Configuration도 주입되고, Nested @TestConfiguration도 주입된 걸 볼 수 있다.
Nested @TestConfiguration의 용도는 원래 Configuration(@SpringBootConfigurtion 또는 Nested @Configuration)에 추가적으로 설정할 Configuration을 위해 사용한다고 보면 된다.

위에 살펴봤던 것과 같이 SpringBootTestContextBootstrapper 클래스의 getOrFindConfigurationClasses 메서드를 호출하고 있다.
그리고 Nested @Configuration 클래스가 하나라도 존재하는지 containsNonTestComponent 메서드를 통해 검증했었다.
이번에는 Nested @TestConfiguration 클래스를 설정했으므로 그 관점에서 바라보자.

1
2
3
4
5
6
7
8
9
10
11
12
protected Class<?>[] getOrFindConfigurationClasses(MergedContextConfiguration mergedConfig) {
Class<?>[] classes = mergedConfig.getClasses();
if (containsNonTestComponent(classes) || mergedConfig.hasLocations()) {
return classes;
}
Class<?> found = new AnnotatedClassFinder(SpringBootConfiguration.class)
.findFromClass(mergedConfig.getTestClass());
Assert.state(found != null, "Unable to find a @SpringBootConfiguration, you need to use "
+ "@ContextConfiguration or @SpringBootTest(classes=...) with your test");
logger.info("Found @SpringBootConfiguration " + found.getName() + " for test " + mergedConfig.getTestClass());
return merge(found, classes);
}
  1. nested @Configuration 클래스를 가져온다.
  2. nested @TestConfiguration이 아닌 nested @Configuration 클래스가 하나라도 존재한다면 nested @Configuration(nested @TestConfiguration 포함) 클래스들을 반환한다.
  3. @SpringBootConfiguration 어노테이션이 붙은 클래스를 가져온다.
  4. 3에서 클래스를 찾지 못했다면 @SpringBootConfiguration이 붙은 클래스를 찾지 못하여 @ContextConfiguration이나 @SpringBootTest에 component classes를 명시하라고 에러를 뱉는다.
  5. 3에서 찾은 클래스와 nested @Configuration 클래스를 머지한다.

@TestConfiguration 어노테이션이 @Configuration을 포함하고 있으므로 mergedConfig.getClasses()에서는 Nested @Configuration과 Nested @TestConfiguration 클래스가 나온다고 보면 된다.

1
2
3
4
5
6
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Configuration
@TestComponent
public @interface TestConfiguration {

그리고 우리는 Nested @Configuration 클래스는 하나도 없으므로 containsNonTestComponent(classes)에서 false를 뱉고
그 아래에서 Class<?> found = new AnnotatedClassFinder(SpringBootConfiguration.class).findFromClass(mergedConfig.getTestClass());로 찾아온 @SpringBootApplication 클래스와 Nested @TestConfiguration 클래스가 머지된다고 보면 된다.
실제로 merge() 메서드에서는 두 Configuration들을 머지하고 있다.

1
2
3
4
5
6
private Class<?>[] merge(Class<?> head, Class<?>[] existing) {
Class<?>[] result = new Class<?>[existing.length + 1];
result[0] = head;
System.arraycopy(existing, 0, result, 1, existing.length);
return result;
}

참고로 @ContextConfiguration을 사용할 때는 Nested @Configuration/@TestConfiuration이 먹히지 않는다. (물론 @SpringBootConfiguration도 씹힌다.)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
@SpringBootTest
@ContextConfiguration(classes = [SomeInterfaceInContextConfiguration::class])
@TestConstructor(autowireMode = TestConstructor.AutowireMode.ALL)
internal class ContextConfigurationTest(
private val someInterface: SomeInterface,
private val someInterface2: SomeInterface,
private val someInterface3: SomeInterface,
) {
@Configuration
internal class NestConfiguration {
@Bean
fun someInterface2() = SomeInterfaceInNestedConfiguration()
}

@TestConfiguration
internal class NestedTestConfiguration {
@Bean
fun someInterface3() = SomeInterfaceInNestedTestConfiguration()
}

@Test
internal fun `@ContextConfiguration에 기술된 Component Classes들이 Test Configuration으로 사용된다`() {
assertThat(someInterface).isExactlyInstanceOf(SomeInterfaceInContextConfiguration::class.java)
}

@Test
internal fun `@ContextConfiguratio을 적용했으면 Nested @Configuration은 무시된다`() {
assertThat(someInterface2).isNotExactlyInstanceOf(SomeInterfaceInNestedConfiguration::class.java)
assertThat(someInterface2).isExactlyInstanceOf(SomeInterfaceInContextConfiguration::class.java)
}

@Test
internal fun `@ContextConfiguratio을 적용했으면 Nested @TestConfiguration은 무시된다`() {
assertThat(someInterface3).isNotExactlyInstanceOf(SomeInterfaceInNestedTestConfiguration::class.java)
assertThat(someInterface3).isExactlyInstanceOf(SomeInterfaceInContextConfiguration::class.java)
}
}