Home

Spring Testing - Context Management and Caching

2019-03-27

개요

최근 들어 회사에서 테스트 성능이 문제가 된 경우가 몇 번 있었다. 이를 해결하기 위해 Spring의 Testing 레퍼런스를 정독하며 테스트의 동작 방식에 대해서 파헤쳐보았다. 비록 내가 내린 결론은 테스트 툴을 더 잘 활용하여 빌드 속도를 끌어올리기는 어렵다는 것이었지만, 그래도 내가 모르고 있었던 테스트 작동 방식에 대해서 더 깊이 이해할 수 있었던 좋은 기회였다.

이번 글에서는 Spring에서 intergration test를 위해 제공하는 주요 기능 중의 하나인 context management 및 caching에 대해서 정리해보았다.


Context Management

Spring으로 작성된 application의 integration test를 돌리기 위해서는 ApplicationContext가 필요하다. Unit test와는 달리 두 개 이상의 bean이 함께 작동했을 때 의도한 대로 작동하는 지를 확인해야 하기 때문이다. JUnit4를 기반으로 작성된 Spring의 integration test 실행 과정은 다음과 같다 :

  1. 테스트 클래스의 instance를 생성한다. 이 때 instance는 no-args constructor를 통해 생성된다.
  2. 테스트에 필요한 bean으로 ApplicationContext를 구성한다.
  3. 2의 테스트 instance에, 1에서 생성한 ApplicationContext를 활용하여 필요한 bean을 주입한다.

Spring에서는 TestContext라는 프레임워크를 통해 테스트에서 사용할 ApplicationContext를 정의할 수 있다. 대표적으로 @ContextConfiguration이라는 annotation을 활용하는 방법이 있다. 테스트 클래스에 @ContextConfiguration을 붙이고 테스트에서 사용할 @Configuration 클래스나 @Component 클래스을 명시하면 된다. 아래는 Kotlin으로 작성한 예시 코드이다.

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
38
39
40
41
42
43
44
/* Configuration & Component class definition */
class BeanA {}
class BeanB {}

@Configuration
class ConfigA {
@Bean
fun beanA(): BeanA {
return BeanA()
}
}

@Configuration
class ConfigB {
@Bean
fun beanB(): BeanB {
return BeanB()
}
}

@Component
class ComponentC {}

@Component
class ComponentD {}


/* Test code */
@RunWith(SpringRunner::class)
@ContextConfiguration(
classes = [ConfigA::class, ComponentC::class]
)
class SomeIntegrationTest1 {
@Autowired
lateinit var beanA: BeanA

@Autowired
lateinit var componentC: ComponentC

@Test
fun someTest1() {
/* some test code */
}
}

위의 예시에 대해서 조금 더 자세히 설명해보자면, 우선 몇 개의 @Configuration 클래스 및 @Component 클래스를 선언했다.

  • ConfigA -> BeanA를 구성
  • ConfigB -> BeanB를 구성
  • ComponentC
  • ComponentD

그리고 SomeIntegrationTest1에서는 @ContextConfigurationclasses attribute로 ConfigAComponentC를 사용하겠다고 지정했다. 그러면 SomeIntegrationTest1을 돌릴 때 사용될 ApplicationContextConfigAComponentC만으로 구성되고 ConfigBComponentD는 사용되지 않는다. 이런 방식으로 테스트에서 실제로 필요한 bean만 생성하여 사용하고 불필요한 bean을 생성하여 성능이 저하되는 것을 방지할 수 있다.

@ContextConfiguration 클래스의 classes attribute에는 다음과 같은 type의 class가 올 수 있다.

  • @Configuration 클래스
  • @Component, @Service, @Repository 등 stereotype 클래스
  • 그 외 @Bean 메소드를 포함한 아무 클래스


Context Caching

방금 전 상황에서는 테스트 클래스가 하나였다. 하지만 이제 테스트 클래스가 100개가 있다고 해보자. 당연히 CI를 하려면 전체 빌드를 돌려야 하고, 테스트 클래스 100개를 전부 다 돌려야 할 것이다. 이 때 ApplicationContext 생성 전략을 어떻게 취하면 될까?

우선, 가장 쉬운 방법은 각 테스트를 돌릴 때마다 새로운 ApplicationContext를 생성하는 방법이 있다. 하지만 이 방법은 최악의 성능을 보일 것이다. ApplicationContext를 생성하는 데에 테스트 당 20~30초만 걸려도 전체 빌드를 돌리는 데에 1시간이 걸리는 기적을 볼 수 있다.

그렇다고 하나 이상의 테스트에서 사용되는 모든 bean으로 구성된 ApplicationContext 한 번만 만들어서 전체 테스트에 대해 재사용하는 것 역시 좋지 않은 방법이다. 우선, 이는 몇 개의 테스트만 돌려보고 싶은 상황을 고려하지 않은 전략이다. 한 개의 테스트만 돌릴 것인데 다른 테스트에서 필요한 bean을 생성하는 것은 분명한 리소스 낭비이다. 또한, 테스트가 ApplicationContext를 오염시키는 경우 다음 테스트를 돌릴 때에는 어쩔 수 없이 새 ApplicationContext를 만들어야 하는데, 이 때 다시 수많은 bean을 생성해야 하므로 굉장히 오랜 시간이 걸릴 것이다.

Spring은 이러한 문제를 해결하기 위해 context caching 기능을 지원한다. Spring TestContext 프레임워크는 한 번 ApplicationContext가 만들어지면 이를 캐시에 저장한다. 그리고 다른 테스트를 돌릴 때 가능한 경우 재사용한다. 여기서 가능한 경우란,

  1. 같은 bean의 조합을 필요로 하고
  2. 이전 테스트에서 ApplicationContext가 오염되지 않은 경우

를 의미한다. 물론 context caching은 한 test suite 내에서만, 즉 한 JVM에서 실행되는 테스트 클래스에 대해서만 동작한다.

그렇다면 Spring TestContext 프레임워크는 두 테스트 클래스가 같은 bean의 조합을 필요로 하는지 어떻게 판별할까? 이 질문은 곧 context caching에서의 cache key가 무엇으로 구성되는 지와 동일하다. Spring TestContext 프레임워크는 테스트 클래스의 여러 configuration으로 이 key를 구성한다.

  • locations (from @ContextConfiguration)
  • classes (from @ContextConfiguration)
  • contextInitializerClasses (from @ContextConfiguration)
  • contextCustomizers (from ContextCustomizerFactory)
  • contextLoader (from @ContextConfiguration)
  • parent (from @ContextHierarchy)
  • activeProfiles (from @ActiveProfiles)
  • propertySourceLocations (from @TestPropertySource)
  • propertySourceProperties (from @TestPropertySource)
  • resourceBasePath (from @WebAppConfiguration)

여기서 놓치기 쉬운 점은 어떤 bean을 mock으로 처리했느냐(Mockito의 @MockBean을 사용했느냐)가 ApplicationContext 재사용 여부에 영향을 미친다는 것이다. Mockito의 @MockBean을 사용할 경우 contextCustomizersMockitoContextCustomizer가 추가되는데, 이 때문에 테스트 클래스에서 @MockBean 처리한 bean의 조합이 달라질 경우 cache key가 달라지게 된다. 따라서 비록 같은 @ContextConfiguration classes attributes를 가졌다고 하더라도 @MockBean의 조합이 달라지면 Spring TestContextApplicationContext를 재사용하지 않는다.

(생각해보면 이는 당연한 일인데, 어떤 bean을 mocking 하는 일은 해당 bean을 변형시키는 일이라서 ApplicationContext가 오염되는 것과 마찬가지이기 때문이다.)

아래는 위의 context caching 규칙을 예시를 통해 정리한 것이다. 아까 Context Management 부분에서 정의한 ConfigA, ConfigB, ComponentC, ComponentD가 정의되어 있다고 가정하자.

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
@RunWith(SpringRunner::class)
@ContextConfiguration(
classes = [ConfigA::class]
)
class TestClass1 {
@Test
fun test() {
/* some test code */
}
}

@RunWith(SpringRunner::class)
@ContextConfiguration(
classes = [ConfigA::class]
)
class TestClass2 {
@Test
fun test() {
/* some test code */
}
}

@RunWith(SpringRunner::class)
@ContextConfiguration(
classes = [ConfigA::class]
)
class TestClass3 {
@Autowired
lateinit var beanA: BeanA

@Test
fun test() {
/* some test code */
}
}

  • 위 3개 테스트를 함께 돌리면 ApplicationContext는 한 번만 생성되고 3개 테스트에 대해서 모두 재사용된다. @ContextConfiguration 구성이 모두 같기 때문이다.
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
38
39
40
41
42
43
@RunWith(SpringRunner::class)
@ContextConfiguration(
classes = [ConfigA::class]
)
class TestClass4 {
@Test
fun test() {
/* some test code */
}
}

@RunWith(SpringRunner::class)
@ContextConfiguration(
classes = [ConfigB::class]
)
class TestClass5 {
@Test
fun test() {
/* some test code */
}
}

@RunWith(SpringRunner::class)
@ContextConfiguration(
classes = [ComponentC::class]
)
class TestClass6 {
@Test
fun test() {
/* some test code */
}
}

@RunWith(SpringRunner::class)
@ContextConfiguration(
classes = [ComponentD::class]
)
class TestClass7 {
@Test
fun test() {
/* some test code */
}
}
  • 위 4개의 테스트를 함께 돌리면 매번 새 ApplicationContext를 생성한다. @ContextConfiguration 구성이 모두 다르기 때문이다.
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
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
@RunWith(SpringRunner::class)
@ContextConfiguration(
classes = [ConfigA::class, ConfigB::class]
)
class TestClass8 {
@Test
fun test() {
/* some test code */
}
}

@RunWith(SpringRunner::class)
@ContextConfiguration(
classes = [ConfigA::class, ConfigB::class]
)
class TestClass9 {
@MockBean
lateinit var beanA: BeanA

@Test
fun test() {
/* some test code */
}
}

@RunWith(SpringRunner::class)
@ContextConfiguration(
classes = [ConfigA::class, ConfigB::class]
)
class TestClass10 {
@MockBean
lateinit var beanB: BeanB

@Test
fun test() {
/* some test code */
}
}

@RunWith(SpringRunner::class)
@ContextConfiguration(
classes = [ConfigA::class, ConfigB::class]
)
class TestClass11 {
@MockBean
lateinit var beanA: BeanA

@MockBean
lateinit var beanB: BeanB

@Test
fun test() {
/* some test code */
}
}
  • 위 4개 테스트를 함께 돌리면 매번 새 ApplicationContext를 생성한다. @ContextConfiguration 구성은 같지만 @MockBean 처리된 bean의 구성이 모두 다르기 때문이다.


결론

Spring TestContext 프레임워크의 ApplicationContext 생성 전략을 잘 파악하고 성능이 최대한 잘 나오도록 유의하여 사용하자.


레퍼런스