이 글에서 다루는 것
"Spring 소스 파헤치기" 시리즈의 스물여섯 번째 글입니다. 25편까지는 Spring이 실제로 일하는 코드를 읽었습니다. 이번에는 그 코드를 시험해 보는 쪽, spring-test 모듈을 읽습니다. 테스트 클래스에 @SpringJUnitConfig 한 줄을 붙이면 컨테이너가 뜨고, @Autowired 필드가 채워지고, @Transactional을 붙인 테스트는 끝나면 데이터가 되돌아갑니다. 그 일을 누가 하는지 따라갑니다.
답하려는 질문은 네 가지입니다.
- JUnit은 Spring을 모르는데, 테스트 클래스에 빈을 주입하는 코드는 언제 어떻게 끼어드는가
- 테스트 클래스가 수백 개여도 컨테이너를 수백 번 띄우지 않는 이유는 무엇이고, 무엇이 달라지면 새로 띄우는가
@Transactional이 붙은 테스트는 왜 끝나면 롤백되는가MockMvc는 서버도 포트도 없이 어떻게 컨트롤러를 부르는가
| 항목 | 내용 |
|---|---|
| 기준 소스 | Spring Framework 7.1.0-SNAPSHOT |
| 커밋 | f447f3c310 (2026-10-01, main 브랜치) |
| 주로 읽는 모듈 | spring-test |
| 주로 읽는 클래스 | SpringExtension, TestContextManager, AbstractTestContextBootstrapper, MergedContextConfiguration, DefaultCacheAwareContextLoaderDelegate, DefaultContextCache, TransactionalTestExecutionListener, MockMvc |
글에 나오는 줄 번호와 세부 동작은 모두 이 커밋 기준입니다. 다른 버전에서는 줄 번호가 다르고 세부 내용도 달라질 수 있습니다. 같은 커밋의 소스를 받는 방법은 1편에 있습니다.
이 글은 Spring을 처음 보는 분이 따라올 수 있는 큰 줄기만 골랐습니다. 빼놓은 것은 글 끝의 "이 글에서 뺀 것"에 이름과 위치만 적어 두었습니다.
먼저 알아 둘 말
JUnit Jupiter는 Java에서 가장 널리 쓰는 테스트 도구인 JUnit의 테스트 작성·실행 부분입니다. @Test가 붙은 메서드를 찾아 하나씩 실행합니다. Jupiter는 테스트를 실행하는 길목마다 바깥 코드가 끼어들 수 있는 자리를 열어 두었는데, 그 자리에 끼우는 것을 확장(extension)이라고 하고 @ExtendWith(확장클래스.class)로 등록합니다.
애플리케이션 컨텍스트(이 글에서는 줄여서 컨텍스트)는 빈을 만들고 보관하는 컨테이너입니다. 컨텍스트를 쓸 수 있는 상태로 만드는 메서드가 refresh()이고, 설정을 읽고 빈을 전부 만드는 일이라 시간이 걸립니다. 자세한 것은 7편에 있습니다.
TestContext 프레임워크는 spring-test 안에서 "테스트 하나를 돌리는 데 필요한 컨텍스트를 마련하고, 테스트의 앞뒤에 할 일을 하는" 부분을 부르는 이름입니다. 이 글의 1~5절이 이것입니다.
리스너(listener)는 "이런 일이 생기면 알려 달라"고 등록해 두는 객체입니다. 이 글의 TestExecutionListener는 테스트 클래스 시작, 테스트 메서드 시작과 끝 같은 순간에 불립니다.
캐시는 한 번 만든 것을 보관해 두었다가 같은 요청이 오면 다시 만들지 않고 꺼내 주는 저장소입니다. 무엇이 "같은 요청"인지 가리는 값을 키(열쇠)라고 합니다.
트랜잭션과 롤백·커밋은 17편에서, 요청을 컨트롤러로 보내는 DispatcherServlet은 21편에서 설명했습니다. 이 글의 5절과 6절이 그 두 글에 기댑니다.
1. JUnit과 Spring이 만나는 자리 — SpringExtension
테스트 클래스는 보통 이렇게 생겼습니다.
// 이 글을 위해 쓴 예시입니다 (Spring의 코드가 아닙니다)
@SpringJUnitConfig(AppConfig.class) // 이 설정 클래스로 컨텍스트를 띄워 달라
class GreeterTests {
@Autowired Greeter greeter; // 그 컨텍스트의 빈을 여기에 넣어 달라
@Test
void greets() { /* ... */ }
}
@SpringJUnitConfig는 어노테이션 두 개를 묶어 놓은 것입니다. @ExtendWith(SpringExtension.class)와 @ContextConfiguration입니다(SpringJUnitConfig 51–57행). 앞의 것은 JUnit에게 하는 말이고("이 클래스를 돌릴 때 SpringExtension을 끼워 달라"), 뒤의 것은 Spring에게 하는 말입니다("컨텍스트는 이 설정으로 만들어 달라"). 어노테이션 위에 붙은 어노테이션을 읽어 속성까지 이어 주는 방법은 13편의 MergedAnnotations가 맡습니다.
SpringExtension은 Jupiter의 확장 인터페이스 여덟 개를 구현합니다.
public class SpringExtension implements BeforeAllCallback, AfterAllCallback, TestInstancePostProcessor,
BeforeEachCallback, AfterEachCallback, BeforeTestExecutionCallback, AfterTestExecutionCallback,
ParameterResolver {
그런데 메서드들의 본문은 하나같이 짧습니다. beforeEach를 보면 이렇습니다.
@Override
public void beforeEach(ExtensionContext context) throws Exception {
Object testInstance = context.getRequiredTestInstance();
Method testMethod = context.getRequiredTestMethod();
TestContextManager testContextManager = getTestContextManager(context);
registerMethodInvoker(testContextManager, context);
testContextManager.beforeTestMethod(testInstance, testMethod);
}
JUnit이 건네준 ExtensionContext에서 테스트 객체와 메서드를 꺼내, TestContextManager의 같은 뜻을 가진 메서드에 넘기는 것이 전부입니다. 나머지 콜백도 같은 모양입니다.
| Jupiter가 부르는 메서드 | 넘겨받는 TestContextManager 메서드 |
위치 |
|---|---|---|
beforeAll |
beforeTestClass() |
211–215행 |
postProcessTestInstance |
prepareTestInstance(테스트 객체) |
238–246행 |
beforeEach |
beforeTestMethod(…) |
317–323행 |
beforeTestExecution |
beforeTestExecution(…) |
329–335행 |
afterTestExecution |
afterTestExecution(…) |
341–348행 |
afterEach |
afterTestMethod(…) |
354–361행 |
afterAll |
afterTestClass() |
221–230행 |
resolveParameter |
(넘기지 않고 컨텍스트에서 빈을 찾아 돌려준다) | 427–449행 |
왜 이렇게 얇게 만들었을까요. TestContextManager의 static 블록에는 JUnit Jupiter뿐 아니라 JUnit 4와 TestNG의 예외 타입 이름도 함께 적혀 있습니다(97–104행). 테스트 도구가 무엇이든 "테스트 클래스 시작, 객체 준비, 메서드 앞, 메서드 뒤, 클래스 끝"이라는 순간은 같습니다. Spring 쪽 일은 전부 TestContextManager 아래에 두고, 도구마다 그 순간을 전달해 주는 얇은 접착제만 따로 만든 것입니다. SpringExtension은 Jupiter용 접착제입니다.
TestContextManager는 테스트 클래스마다 하나씩 만들어져 Jupiter가 제공하는 저장소(Store)에 보관됩니다.
static TestContextManager getTestContextManager(ExtensionContext context) {
Assert.notNull(context, "ExtensionContext must not be null");
Class<?> testClass = context.getRequiredTestClass();
Store store = getStore(context);
return store.computeIfAbsent(testClass, TestContextManager::new, TestContextManager.class);
}
private static Store getStore(ExtensionContext context) {
return context.getRoot().getStore(TEST_CONTEXT_MANAGER_NAMESPACE);
}
computeIfAbsent는 "이 열쇠로 넣어 둔 것이 없으면 만들어 넣고, 있으면 그것을 준다"입니다. 열쇠가 테스트 클래스이므로 한 클래스의 모든 콜백이 같은 TestContextManager를 받습니다.
파란 상자 둘이 접착제와 관리자입니다. 실제 일은 맨 아래 줄의 리스너들이 합니다.
헷갈리기 쉬운 곳
SpringExtension은 컨텍스트를 만들지 않습니다. 컨텍스트는 누군가TestContext.getApplicationContext()를 처음 부를 때 만들어집니다(4절).supportsParameter의 설명 주석에는 컨텍스트를 너무 일찍 띄우지 않으려고 이 메서드에서는 일부러 컨텍스트를 건드리지 않는다고 적혀 있습니다(382–385행).- 이 커밋의
SpringExtension은 JUnit Jupiter 6 이상을 요구합니다. 클래스 설명에 그렇게 적혀 있고(104행), 위 코드의Store.computeIfAbsent가 그 버전의 API입니다. 이것은 아래 예제를 돌리다 실제로 걸린 곳이라 2절 끝에 다시 적었습니다. - 테스트 메서드의 인자로도 빈을 받을 수 있습니다.
resolveParameter가 컨텍스트에서 인자에 맞는 빈을 찾아 줍니다. 찾는 방법은 5편에서 본 의존성 해석과 같은 길입니다(ParameterResolutionDelegate.resolveDependency, 447–448행).
2. TestContextManager와 리스너 — 일은 리스너가 합니다
TestContextManager가 가진 것은 둘입니다. 지금 돌리는 테스트의 상태를 담는 TestContext 하나와, TestExecutionListener의 목록입니다(106–110행). 생성자가 그 둘을 마련합니다.
public TestContextManager(TestContextBootstrapper testContextBootstrapper) {
this.testContext = testContextBootstrapper.buildTestContext();
this.testContextHolder = ThreadLocal.withInitial(() -> copyTestContext(this.testContext));
registerTestExecutionListeners(testContextBootstrapper.getTestExecutionListeners());
}
spring-test/src/main/java/org/springframework/test/context/TestContextManager.java 141–145행
둘 다 TestContextBootstrapper에게 만들어 달라고 합니다. 부트스트래퍼(bootstrapper)는 "처음 시동을 거는 쪽"이라는 뜻이고, 3절에서 봅니다.
TestContextManager의 메서드들은 모두 같은 모양입니다. TestContext에 지금 상태(테스트 객체, 메서드, 예외)를 적고, 리스너를 하나씩 돌며 같은 이름의 메서드를 부릅니다(beforeTestClass 210–231행). TestExecutionListener 인터페이스에는 그렇게 불리는 메서드가 일곱 개 있고 모두 아무 일도 하지 않는 기본 구현을 가지고 있어서(141–285행), 리스너는 관심 있는 순간만 구현하면 됩니다.
화살표 왼쪽은 Jupiter가 부르는 이름, 오른쪽은 TestContextManager의 이름입니다. 파란 상자는 이 글에서 따라가는 리스너가 일하는 자리입니다.
기본 리스너는 어디서 오나
테스트 클래스에 리스너를 하나도 적지 않아도 주입과 트랜잭션이 동작합니다. 기본 리스너 목록이 spring-test jar 안의 파일에 적혀 있기 때문입니다.
org.springframework.test.context.TestExecutionListener = \
org.springframework.test.context.web.ServletTestExecutionListener,\
org.springframework.test.context.support.DirtiesContextBeforeModesTestExecutionListener,\
org.springframework.test.context.event.ApplicationEventsTestExecutionListener,\
org.springframework.test.context.bean.override.BeanOverrideTestExecutionListener,\
org.springframework.test.context.support.DependencyInjectionTestExecutionListener,\
org.springframework.test.context.observation.MicrometerObservationRegistryTestExecutionListener,\
org.springframework.test.context.support.DirtiesContextTestExecutionListener,\
org.springframework.test.context.support.CommonCachesTestExecutionListener,\
org.springframework.test.context.transaction.TransactionalTestExecutionListener,\
org.springframework.test.context.jdbc.SqlScriptsTestExecutionListener,\
org.springframework.test.context.event.EventPublishingTestExecutionListener,\
org.springframework.test.context.bean.override.mockito.MockitoResetTestExecutionListener
spring-test/src/main/resources/META-INF/spring.factories 3–15행
세어 보면 열두 개입니다. META-INF/spring.factories는 "인터페이스 이름 = 구현 클래스 이름들"을 적어 두는 파일이고, 이것을 읽어 객체로 만들어 주는 것이 SpringFactoriesLoader입니다. 부트스트래퍼는 테스트 클래스에 @TestExecutionListeners가 없으면 이 파일의 목록을 읽어 오고(AbstractTestContextBootstrapper 123–130행, 205–207행), 중복을 걸러낸 뒤 순서 값으로 정렬합니다(164–177행). 순서 값이 작을수록 먼저입니다. 정렬 규칙은 16편에서 본 OrderComparator 계열입니다.
대표적인 리스너 넷만 봅니다.
| 리스너 | 순서 값 | 하는 일 |
|---|---|---|
DependencyInjectionTestExecutionListener |
2000 (46행) | prepareTestInstance에서 테스트 객체의 @Autowired 필드를 채운다 |
DirtiesContextTestExecutionListener |
3000 (61행) | @DirtiesContext가 붙었으면 테스트 뒤에 컨텍스트를 캐시에서 빼고 닫는다 |
TransactionalTestExecutionListener |
4000 (154행) | 테스트 메서드 앞에서 트랜잭션을 열고 뒤에서 롤백 또는 커밋한다 (5절) |
SqlScriptsTestExecutionListener |
5000 (125행) | @Sql에 적은 SQL 스크립트를 실행한다 |
의존성 주입 리스너의 본론은 여섯 줄입니다.
protected void injectDependencies(TestContext testContext) throws Exception {
Object bean = testContext.getTestInstance();
String beanName = testContext.getTestClass().getName() + AutowireCapableBeanFactory.ORIGINAL_INSTANCE_SUFFIX;
AutowireCapableBeanFactory beanFactory = testContext.getApplicationContext().getAutowireCapableBeanFactory();
beanFactory.autowireBeanProperties(bean, AutowireCapableBeanFactory.AUTOWIRE_NO, false);
beanFactory.initializeBean(bean, beanName);
testContext.removeAttribute(REINJECT_DEPENDENCIES_ATTRIBUTE);
}
테스트 객체는 JUnit이 new로 만든 것이라 컨테이너의 빈이 아닙니다. 그래서 컨테이너에게 "이미 만들어진 이 객체에 주입과 초기화만 해 달라"고 부탁합니다. 4편에서 본 세 단계 가운데 인스턴스화만 빼고 주입(autowireBeanProperties)과 초기화(initializeBean)를 거치는 셈입니다. 그리고 셋째 줄의 testContext.getApplicationContext()가 4절에서 볼 컨텍스트 캐시로 들어가는 문입니다.
뒤쪽 콜백은 거꾸로 돕니다
before… 계열은 목록 순서대로 돌지만 after… 계열은 뒤집은 목록으로 돕니다(getReversedTestExecutionListeners 190–194행, afterTestMethod의 486행). 소스의 주석은 이것을 "wrapper"-style, 곧 감싸는 방식이라고 부릅니다(484–485행). 먼저 시작한 리스너가 나중에 끝나야 안쪽 리스너가 한 일을 바깥 리스너가 감쌀 수 있습니다. 예를 들어 SQL 스크립트 리스너(5000)는 트랜잭션 리스너(4000)보다 늦게 시작하고 먼저 끝나므로, 테스트가 @Transactional이고 @Sql의 트랜잭션 설정을 바꾸지 않았다면 스크립트는 테스트 트랜잭션 안쪽에서 실행됩니다(SqlScriptsTestExecutionListener 368–372행).
예제 1: 기본 리스너 목록과 감싸는 순서
기본 리스너를 찍어 보고, 직접 만든 리스너 둘(순서 값 100과 9000)을 끼워 호출 순서를 봅니다. 이 글의 예제는 모두 릴리스 버전인 Spring Framework 7.0.9와 JUnit 6.0.3으로 실행해 결과를 확인했습니다(이 시리즈가 읽는 7.1.0-SNAPSHOT을 직접 빌드해 돌린 것은 아닙니다). 테스트 클래스를 main에서 돌리려고 JUnit의 Launcher를 직접 불렀습니다.
// 이 글을 위해 쓴 예제입니다 (Spring의 소스가 아닙니다)
// 콜백이 불릴 때마다 이름을 찍는 리스너 (일곱 메서드 모두 같은 모양이라 둘만 적는다)
static class PrintingListener implements TestExecutionListener, Ordered {
// ...
@Override
public void beforeTestMethod(TestContext testContext) {
System.out.println(" " + this.name + ".beforeTestMethod");
}
@Override
public void afterTestMethod(TestContext testContext) {
System.out.println(" " + this.name + ".afterTestMethod");
}
}
@SpringJUnitConfig(EmptyConfig.class)
@TestExecutionListeners(listeners = {InnerListener.class, OuterListener.class}, // 일부러 거꾸로 적었다
mergeMode = MergeMode.MERGE_WITH_DEFAULTS) // 기본 리스너에 더한다
static class WrappedTests {
@BeforeEach void setUp() { System.out.println(" @BeforeEach"); }
@Test void test() { System.out.println(" @Test 본문"); }
@AfterEach void tearDown() { System.out.println(" @AfterEach"); }
}
public static void main(String[] args) {
// JUnit 없이 TestContextManager 만 만들어서 리스너 목록을 물어본다
List<TestExecutionListener> listeners = new TestContextManager(PlainTests.class).getTestExecutionListeners();
// ... 순서 값과 클래스 이름을 찍는다
JupiterRunner.run(WrappedTests.class); // JUnit Launcher 로 테스트 클래스를 실행
}
[1] 기본 리스너 (JUnit 없이 TestContextManager 만 만들어서 물어본다)
1,000 ServletTestExecutionListener
1,500 DirtiesContextBeforeModesTestExecutionListener
1,800 ApplicationEventsTestExecutionListener
1,950 BeanOverrideTestExecutionListener
2,000 DependencyInjectionTestExecutionListener
3,000 DirtiesContextTestExecutionListener
3,005 CommonCachesTestExecutionListener
4,000 TransactionalTestExecutionListener
5,000 SqlScriptsTestExecutionListener
10,000 EventPublishingTestExecutionListener
2,147,483,547 MockitoResetTestExecutionListener
모두 11개
[2] 리스너 둘을 끼운 테스트 클래스 실행
바깥(order=100).beforeTestClass
안쪽(order=9000).beforeTestClass
바깥(order=100).prepareTestInstance
안쪽(order=9000).prepareTestInstance
바깥(order=100).beforeTestMethod
안쪽(order=9000).beforeTestMethod
@BeforeEach
바깥(order=100).beforeTestExecution
안쪽(order=9000).beforeTestExecution
@Test 본문
안쪽(order=9000).afterTestExecution
바깥(order=100).afterTestExecution
@AfterEach
안쪽(order=9000).afterTestMethod
바깥(order=100).afterTestMethod
안쪽(order=9000).afterTestClass
바깥(order=100).afterTestClass
=> WrappedTests: 성공 1 / 실패 0
어노테이션에는 안쪽을 먼저 적었지만 순서 값대로 정렬되어 바깥이 먼저 불렸고, after…에서는 순서가 뒤집혔습니다. beforeTestMethod가 @BeforeEach보다 앞이고 beforeTestExecution은 뒤라는 것도 보입니다.
헷갈리기 쉬운 곳
- 파일에는 열두 개인데 실행하면 열한 개였습니다. 빠진 것은
MicrometerObservationRegistryTestExecutionListener입니다. 기본 리스너를 읽어 올 때 쓰는 실패 처리기는, 리스너 클래스가 필요로 하는 다른 클래스가 클래스패스에 없어서 생긴ClassNotFoundException·NoClassDefFoundError를 예외로 올리지 않고 그 후보만 건너뜁니다(TestContextFailureHandler41–49행). 다만 Micrometer가 통째로 없어서 빠진 것은 아닙니다.micrometer-observation은spring-context가 끌고 오는 의존성이라 예제 프로젝트의 클래스패스에 들어 있습니다(Class.forName으로 찍어 보니io.micrometer.observation.ObservationRegistry가micrometer-observation-1.16.7.jar에서 나왔습니다). 없는 것은 따로 넣어야 하는io.micrometer:context-propagation입니다. 이 리스너는 static 블록에서io.micrometer.context.ThreadLocalAccessor와ObservationThreadLocalAccessor를 찾아 보고(MicrometerObservationRegistryTestExecutionListener80–103행), 하나라도 못 찾으면 생성자에서 스스로NoClassDefFoundError를 던집니다(106–112행). 예제 프로젝트에서 이 생성자를 직접 불러 보면 메시지가io.micrometer.context.ThreadLocalAccessor를 가리킵니다. 그 예외를 위의 실패 처리기가 받아 후보를 건너뛴 것입니다. 그래서 "기본 리스너가 몇 개인가"의 답은 클래스패스에 따라 달라집니다. @TestExecutionListeners를 그냥 붙이면 기본 리스너가 빠집니다. 정렬과 기본 목록 합치기는mergeMode가MERGE_WITH_DEFAULTS일 때만 일어납니다(146–154행, 164행). 위 예제에서 이 속성을 빼면 의존성 주입 리스너도 없는 목록이 됩니다.- 예제를 돌리다 막힌 곳. 처음에는 JUnit Jupiter 5.13.4로 돌렸는데 테스트가 하나도 실행되지 않고
NoSuchMethodError: ExtensionContext$Store.computeIfAbsent(...)가 났습니다. 1절에서 본getTestContextManager가 부르는 메서드가 JUnit 5에는 없기 때문입니다. JUnit을 6.0.3으로 올리자 돌았습니다.new TestContextManager(…)로 리스너 목록만 묻는 [1]은 JUnit을 거치지 않아서 5.13.4에서도 찍혔습니다.SpringExtension만 접착제라는 것을 뜻하지 않게 확인한 셈입니다.
3. 부트스트랩 — 테스트 클래스의 어노테이션이 캐시 키가 되기까지
new TestContextManager(테스트클래스)는 BootstrapUtils에게 부트스트래퍼를 골라 달라고 합니다(125–127행). 테스트 클래스에 @BootstrapWith가 없으면, @WebAppConfiguration이 있을 때는 WebTestContextBootstrapper를, 없을 때는 DefaultTestContextBootstrapper를 씁니다(BootstrapUtils 212–217행).
노란 통 둘을 기억해 두세요. 오른쪽이 캐시의 키이고 왼쪽이 캐시입니다.
부트스트래퍼가 TestContext를 만드는 코드는 한 문장입니다.
@Override
public TestContext buildTestContext() {
return new DefaultTestContext(getBootstrapContext().getTestClass(), buildMergedContextConfiguration(),
getCacheAwareContextLoaderDelegate());
}
DefaultTestContext는 세 가지를 받습니다. 테스트 클래스, MergedContextConfiguration, 그리고 컨텍스트를 실제로 구해 오는 CacheAwareContextLoaderDelegate입니다. 여기에 컨텍스트 자체는 없습니다. TestContext는 컨텍스트를 들고 있는 것이 아니라 "컨텍스트를 구하는 데 필요한 것"을 들고 있습니다.
MergedContextConfiguration은 이름 그대로 "합쳐진 컨텍스트 설정"입니다. 테스트 클래스와 그 상위 클래스, 그리고 어노테이션 위의 어노테이션에 흩어져 있는 @ContextConfiguration, @ActiveProfiles, @TestPropertySource 등을 읽어 한 객체로 모읍니다. 모으는 코드의 끝부분이 이렇습니다.
MergedTestPropertySources mergedTestPropertySources =
TestPropertySourceUtils.buildMergedTestPropertySources(testClass);
MergedContextConfiguration mergedConfig = new MergedContextConfiguration(testClass,
StringUtils.toStringArray(locations), ClassUtils.toClassArray(classes),
initializers, ActiveProfilesUtils.resolveActiveProfiles(testClass),
mergedTestPropertySources.getPropertySourceDescriptors(),
mergedTestPropertySources.getProperties(),
contextCustomizers, contextLoader, cacheAwareContextLoaderDelegate, parentConfig);
AbstractTestContextBootstrapper.java 350–357행
생성자의 인자를 죽 읽으면 테스트용 컨텍스트 하나를 결정하는 재료가 그대로 나옵니다. 설정 파일 위치(locations), 설정 클래스(classes), 초기화 클래스(initializers), 활성 프로파일, 테스트용 프로퍼티 둘, 컨텍스트 커스터마이저, 컨텍스트를 만드는 로더(contextLoader), 부모 설정입니다. contextLoader는 따로 정하지 않으면 DelegatingSmartContextLoader입니다(DefaultTestContextBootstrapper 35–38행).
이 객체가 중요한 이유는 다음 절에 있습니다. 이것이 컨텍스트 캐시의 키입니다.
4. 컨텍스트 캐시 — 같은 설정이면 한 번만 띄웁니다
컨텍스트를 띄우는 일은 느립니다. 테스트 클래스가 300개인데 클래스마다 refresh()를 한다면 테스트 시간의 대부분이 컨테이너를 켜는 데 들어갑니다. 그런데 그 300개 가운데 대다수는 같은 설정을 씁니다. 그래서 spring-test는 한 번 띄운 컨텍스트를 보관해 두고, 설정이 같은 테스트 클래스에는 같은 컨텍스트를 그대로 줍니다.
TestContext.getApplicationContext()에서 시작합니다.
@Override
public ApplicationContext getApplicationContext() {
ApplicationContext context = this.cacheAwareContextLoaderDelegate.loadContext(this.mergedConfig);
// ...
this.cacheAwareContextLoaderDelegate.registerContextUsage(this.mergedConfig, this.testClass);
return context;
}
spring-test/src/main/java/org/springframework/test/context/support/DefaultTestContext.java 126–141행
loadContext라는 이름이지만 매번 새로 만드는 것이 아닙니다. 본문을 보면 이름 앞의 CacheAware(캐시를 아는)가 무슨 뜻인지 드러납니다. 로그와 실패 처리 부분은 // ...로 줄였습니다.
@Override
public ApplicationContext loadContext(MergedContextConfiguration mergedConfig) {
mergedConfig = replaceIfNecessary(mergedConfig);
synchronized (this.contextCache) {
try {
ApplicationContext context = this.contextCache.get(mergedConfig);
if (context != null) {
// ...
return context;
}
int failureCount = this.contextCache.getFailureCount(mergedConfig);
if (failureCount >= this.failureThreshold) {
throw new IllegalStateException("""
ApplicationContext failure threshold (%d) exceeded: \
skipping repeated attempt to load context for %s"""
.formatted(this.failureThreshold, mergedConfig));
}
return this.contextCache.put(mergedConfig, key -> {
try {
ApplicationContext newContext;
// ...
newContext = loadContextInternal(key);
// ...
return newContext;
}
// ...
});
}
finally {
this.contextCache.logStatistics();
}
}
}
노란 상자가 캐시를 읽고 쓰는 자리입니다. 대부분의 테스트 클래스는 왼쪽 길로 끝납니다.
- 캐시에서
mergedConfig를 키로 찾습니다. 있으면 그것을 돌려주고 끝입니다. - 없으면, 이 키로 컨텍스트를 만들다 실패한 횟수가 한도 이상인지 봅니다. 한도의 기본값은 1입니다(
CacheAwareContextLoaderDelegate51행). 한 번 띄우다 실패한 설정은 다시 시도하지 않고 바로 예외를 던집니다. 같은 설정을 쓰는 테스트 클래스 100개가 100번 실패를 기다리지 않게 하려는 장치입니다. - 아니면
loadContextInternal로 컨텍스트를 새로 만들어 캐시에 넣습니다.loadContextInternal은MergedContextConfiguration이 들고 있는ContextLoader에게 일을 넘기고(242–258행), 로더는GenericApplicationContext를 만들어 설정을 입힌 뒤refresh()를 부릅니다(AbstractGenericContextLoader237–258행). 7편에서 본 그refresh()입니다. 테스트라고 해서 다른 컨테이너를 쓰는 것이 아닙니다.
캐시는 어디에 있나 — static 필드 하나
3절의 그림에서 delegate는 테스트 클래스마다 새로 만들어진다고 했습니다(BootstrapUtils 100–112행). 그런데도 테스트 클래스끼리 컨텍스트를 나눠 쓸 수 있는 것은 그 delegate들이 가리키는 캐시가 하나이기 때문입니다.
/**
* Default static cache of Spring application contexts.
*/
static final ContextCache defaultContextCache = new DefaultContextCache();
DefaultCacheAwareContextLoaderDelegate.java 73–76행
인자 없는 생성자가 이 static 필드를 씁니다(102–104행). 생성자의 설명 주석은 이유를 이렇게 적어 두었습니다. 캐시가 static이어서 같은 JVM 프로세스 안에서 같은 설정을 선언한 뒤의 모든 테스트가 컨텍스트를 다시 쓸 수 있다는 것입니다(94–101행). 4편에서 싱글톤 빈의 맵은 static이 아니라고 했던 것과 대비됩니다. 빈은 컨테이너 하나 안에서 하나이고, 테스트 컨텍스트는 JVM 하나 안에서 (키마다) 하나입니다.
무엇이 "같은 설정"인가 — equals와 hashCode
캐시 안의 저장소는 맵입니다.
private final Map<MergedContextConfiguration, ApplicationContext> contextMap =
Collections.synchronizedMap(new LinkedHashMap<>(32, 0.75f, true));
spring-test/src/main/java/org/springframework/test/context/cache/DefaultContextCache.java 71–72행
Java의 맵은 키의 hashCode()와 equals()로 "같은 키"를 가립니다. 따라서 "같은 설정이면 컨텍스트를 한 번만 띄운다"는 약속은 결국 MergedContextConfiguration의 이 두 메서드가 지킵니다.
@Override
public int hashCode() {
int result = Arrays.hashCode(this.locations);
result = 31 * result + Arrays.hashCode(this.classes);
result = 31 * result + this.contextInitializerClasses.hashCode();
result = 31 * result + Arrays.hashCode(this.activeProfiles);
result = 31 * result + this.propertySourceDescriptors.hashCode();
result = 31 * result + Arrays.hashCode(this.propertySourceProperties);
result = 31 * result + this.contextCustomizers.hashCode();
result = 31 * result + (this.parent != null ? this.parent.hashCode() : 0);
result = 31 * result + nullSafeClassName(this.contextLoader).hashCode();
return result;
}
spring-test/src/main/java/org/springframework/test/context/MergedContextConfiguration.java 538–550행
equals()도 같은 아홉 가지를 비교합니다(485–531행). 눈여겨볼 것은 여기에 없는 필드입니다. testClass는 이 객체의 첫 필드인데도(86행) 두 메서드 어디에도 나오지 않습니다. hashCode()의 설명 주석이 "테스트 클래스를 뺀 모든 속성으로" 만든다고 밝혀 두었습니다(533–537행). 테스트 클래스가 달라도 나머지 아홉 가지가 같으면 같은 키이고, 그래서 서로 다른 테스트 클래스가 컨텍스트 하나를 나눠 씁니다.
거꾸로 아홉 가지 가운데 하나라도 다르면 다른 키이고, 컨텍스트가 하나 더 뜹니다.
| 키에 들어가는 것 | 테스트 클래스에서 이것을 바꾸는 것 |
|---|---|
locations |
@ContextConfiguration(locations = …) |
classes |
@ContextConfiguration(classes = …), @SpringJUnitConfig(…) |
contextInitializerClasses |
@ContextConfiguration(initializers = …) |
activeProfiles |
@ActiveProfiles |
propertySourceDescriptors |
@TestPropertySource(locations = …) |
propertySourceProperties |
@TestPropertySource(properties = …) |
contextCustomizers |
ContextCustomizerFactory가 만들어 내는 것. 예를 들어 @MockitoBean, @DynamicPropertySource |
parent |
@ContextHierarchy로 만든 부모 설정 |
contextLoader의 클래스 이름 |
@ContextConfiguration(loader = …) |
예제 2: 다섯 번 실행, 컨텍스트 세 개
설정 클래스의 빈 생성자에서 카운터를 올려 컨텍스트가 몇 번 만들어지는지 셉니다.
// 이 글을 위해 쓴 예제입니다 (Spring의 소스가 아닙니다)
static final AtomicInteger contextsCreated = new AtomicInteger(); // 컨텍스트가 하나 만들어질 때마다 1씩 오른다
static class Greeter {
Greeter() { contextsCreated.incrementAndGet(); }
}
@Configuration
static class AppConfig {
@Bean Greeter greeter() { return new Greeter(); }
}
@SpringJUnitConfig(AppConfig.class)
static class FirstTests { // 테스트 메서드 둘 (one, two)
@Autowired ApplicationContext context;
@Test void one() { report(this, this.context); } // 컨텍스트의 식별 번호와 카운터를 찍는다
@Test void two() { report(this, this.context); }
}
@SpringJUnitConfig(AppConfig.class) // FirstTests 와 설정이 글자 하나까지 같다
static class SameConfigTests { /* 테스트 메서드 하나 */ }
@SpringJUnitConfig(AppConfig.class)
@ActiveProfiles("slow") // 설정 클래스는 같지만 활성 프로파일이 다르다
static class OtherProfileTests { /* 테스트 메서드 하나 */ }
@SpringJUnitConfig(AppConfig.class)
@TestPropertySource(properties = "greeting=hi") // 설정 클래스는 같지만 테스트용 프로퍼티가 하나 더 있다
static class OtherPropertyTests { /* 테스트 메서드 하나 */ }
[1] FirstTests (테스트 메서드 2개)
FirstTests: 컨텍스트 @1ecee32c, 지금까지 만든 컨텍스트 1개
FirstTests: 컨텍스트 @1ecee32c, 지금까지 만든 컨텍스트 1개
=> FirstTests: 성공 2 / 실패 0
[2] SameConfigTests (FirstTests 와 같은 설정)
SameConfigTests: 컨텍스트 @1ecee32c, 지금까지 만든 컨텍스트 1개
=> SameConfigTests: 성공 1 / 실패 0
[3] OtherProfileTests (@ActiveProfiles 추가)
OtherProfileTests: 컨텍스트 @6058e535, 지금까지 만든 컨텍스트 2개
=> OtherProfileTests: 성공 1 / 실패 0
[4] OtherPropertyTests (@TestPropertySource 추가)
OtherPropertyTests: 컨텍스트 @5b69fd74, 지금까지 만든 컨텍스트 3개
=> OtherPropertyTests: 성공 1 / 실패 0
[5] SameConfigTests 한 번 더
SameConfigTests: 컨텍스트 @1ecee32c, 지금까지 만든 컨텍스트 3개
=> SameConfigTests: 성공 1 / 실패 0
테스트 클래스 실행 5번, 만든 컨텍스트 3개
(@ 뒤의 숫자는 실행할 때마다 달라지는 객체 식별 번호입니다.) FirstTests와 SameConfigTests는 다른 클래스인데도 같은 컨텍스트 @1ecee32c를 받았습니다. @ActiveProfiles 한 줄, @TestPropertySource 한 줄이 각각 컨텍스트를 하나씩 더 띄웠습니다. AppConfig에는 그 프로파일이나 프로퍼티를 쓰는 코드가 전혀 없는데도 그렇습니다. 캐시는 "결과로 나올 컨텍스트가 같은가"가 아니라 **"설정의 재료가 같은가"**를 봅니다. [5]에서는 사이에 다른 컨텍스트가 둘 끼었어도 처음 것이 그대로 돌아왔습니다.
꽉 차면 가장 오래 안 쓴 것부터 닫습니다
컨텍스트는 메모리와 연결(예를 들어 데이터베이스 커넥션 풀)을 붙잡고 있어서 무한정 쌓아 둘 수 없습니다. 위에서 본 contextMap의 LinkedHashMap은 세 번째 인자가 true입니다. 이렇게 만든 LinkedHashMap은 넣은 순서가 아니라 마지막으로 꺼내 쓴 순서로 줄을 세웁니다. 그래서 맨 앞에 있는 것이 가장 오래 쓰이지 않은 것입니다.
private void evictLruContextIfNecessary() {
if (this.contextMap.size() >= this.maxSize) {
Iterator<MergedContextConfiguration> iterator = this.contextMap.keySet().iterator();
Assert.state(iterator.hasNext(), "Failed to retrieve LRU context");
// The least recently used (LRU) key is the first/head in a LinkedHashMap
// configured for access-order iteration order.
MergedContextConfiguration lruKey = iterator.next();
remove(lruKey, HierarchyMode.CURRENT_LEVEL);
}
}
DefaultContextCache.java 224–233행
새 컨텍스트를 넣기 직전에 이 메서드가 불립니다(212행). 이렇게 "가장 오래 안 쓴 것부터 내보내는" 방식을 LRU(least recently used)라고 합니다. remove는 맵에서 빼는 데 그치지 않고 컨텍스트를 close()합니다(367–369행). 최대 크기의 기본값은 32이고(ContextCache 77행), spring.test.context.cache.maxSize 프로퍼티로 바꿀 수 있습니다(90행, ContextCacheUtils 41–45행).
헷갈리기 쉬운 곳
- 설정 클래스의 순서도 키의 일부입니다.
classes는 배열이고Arrays.equals로 비교하므로(498행),{A.class, B.class}와{B.class, A.class}는 다른 키입니다. - 컨텍스트가 공유되므로 빈의 상태도 공유됩니다. 한 테스트가 싱글톤 빈의 필드를 바꾸면 같은 컨텍스트를 받는 다음 테스트 클래스가 그 값을 봅니다. 이럴 때 쓰는 것이
@DirtiesContext입니다. 리스너가markApplicationContextDirty를 부르면(AbstractDirtiesContextTestExecutionListener69–72행) 그 키의 컨텍스트가 캐시에서 빠지고 닫히며(DefaultTestContext166–169행), 다음에 같은 키를 찾는 테스트는 새로 띄웁니다. 그만큼 느려지므로 꼭 필요한 곳에만 씁니다. - JVM이 다르면 캐시도 다릅니다. 캐시는 static 필드이므로 프로세스 하나 안에서만 통합니다. 빌드 도구가 테스트를 여러 프로세스로 나눠 돌리면 프로세스마다 따로 띄웁니다. 이것은 소스의 주석("within the same JVM process")에서 읽은 것이고, 빌드 도구의 동작을 이 글에서 확인한 것은 아닙니다.
5. 테스트 트랜잭션 — 열고, 기본은 롤백
데이터베이스를 쓰는 테스트는 뒷정리가 문제입니다. 테스트가 넣은 행이 남아 있으면 다음 테스트의 결과가 달라집니다. spring-test의 답은 "테스트 메서드를 트랜잭션 하나로 감싸고, 끝나면 롤백한다"입니다.
17편에서 본 @Transactional은 프록시가 메서드 호출을 가로채 트랜잭션을 열고 닫았습니다. 테스트 메서드는 사정이 다릅니다. 테스트 객체는 JUnit이 직접 만들고 직접 부르므로 프록시가 끼어들 자리가 없습니다. 그래서 같은 어노테이션을 리스너가 읽습니다.
public void beforeTestMethod(final TestContext testContext) throws Exception {
Method testMethod = testContext.getTestMethod();
Class<?> testClass = testContext.getTestClass();
// ...
PlatformTransactionManager tm = null;
TransactionAttribute transactionAttribute = this.attributeSource.getTransactionAttribute(testMethod, testClass);
if (transactionAttribute != null) {
// ...
tm = getTransactionManager(testContext, transactionAttribute.getQualifier());
// ...
}
if (tm != null) {
txContext = new TransactionContext(testContext, tm, transactionAttribute, isRollback(testContext));
runBeforeTransactionMethods(testContext);
txContext.startTransaction();
TransactionContextHolder.setCurrentTransactionContext(txContext);
}
}
- 테스트 메서드(없으면 클래스)에서
@Transactional을 읽습니다. 읽는 도구는 17편의 것과 같은AnnotationTransactionAttributeSource입니다(160행). 어노테이션이 없으면tm이null로 남아 아무 일도 하지 않습니다. - 컨텍스트에서 트랜잭션 매니저 빈을 찾습니다.
isRollback으로 "끝나면 롤백할지"를 정해TransactionContext에 넣고,startTransaction()으로 트랜잭션을 엽니다. 안에서는 17편에서 본transactionManager.getTransaction(…)그대로입니다(TransactionContext101행).
끝날 때는 afterTestMethod가 endTransaction()을 부르고(258–276행), 그 안에서 깃발 하나로 갈립니다.
try {
if (this.flaggedForRollback) {
this.transactionManager.rollback(this.transactionStatus);
}
else {
this.transactionManager.commit(this.transactionStatus);
}
}
finally {
this.transactionStatus = null;
}
17편의 TransactionInterceptor는 예외가 났는지를 보고 롤백을 정했습니다. 여기는 다릅니다. 테스트가 성공했는지 실패했는지를 보지 않고, 처음에 정해 둔 깃발만 봅니다. 그 깃발의 기본값이 true입니다. 클래스에 @Rollback이 없으면 isDefaultRollback은 true를 돌려주고(425–441행), 메서드에 @Rollback이 있으면 그 값이 이깁니다(454–481행). @Commit은 따로 처리하는 코드가 없습니다. @Rollback(false)가 붙은 어노테이션일 뿐입니다(Commit 60–61행). 어노테이션 위의 어노테이션을 찾아 주는 findMergedAnnotation(13편) 덕에 @Commit을 붙이면 @Rollback(false)가 읽힙니다.
예제 3: 롤백되는 테스트와 @Commit을 붙인 테스트
// 이 글을 위해 쓴 예제입니다 (Spring의 소스가 아닙니다)
@SpringJUnitConfig(JdbcConfig.class) // H2 DataSource, JdbcTemplate, DataSourceTransactionManager 빈
@Transactional // 이 클래스의 테스트 메서드는 모두 트랜잭션 안에서 돈다
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
static class MemberTests {
@Autowired JdbcTemplate jdbc;
@BeforeTransaction // 트랜잭션을 열기 전에 불린다
void beforeTx() { /* 트랜잭션 활성 여부와 행 수를 찍는다 */ }
@Test @Order(1)
void insertWithDefaultRollback() {
this.jdbc.update("insert into member(name) values ('kim')");
// 트랜잭션 활성 여부, 롤백 예정 여부, 행 수를 찍는다
}
@Test @Order(2)
@Commit // 이 메서드만 커밋
void insertWithCommit() {
this.jdbc.update("insert into member(name) values ('lee')");
// 위와 같이 찍는다
}
@AfterTransaction // 트랜잭션이 끝난 뒤에 불린다
void afterTx() { /* 트랜잭션 활성 여부와 행 수를 찍는다 */ }
}
@BeforeTransaction : 트랜잭션 활성=false, 행 0
insertWithDefaultRollback: 트랜잭션 활성=true, 롤백 예정=true, 행 1
@AfterTransaction : 트랜잭션 활성=false, 행 0
@BeforeTransaction : 트랜잭션 활성=false, 행 0
insertWithCommit: 트랜잭션 활성=true, 롤백 예정=false, 행 1
@AfterTransaction : 트랜잭션 활성=false, 행 1
=> MemberTests: 성공 2 / 실패 0
테스트가 끝난 뒤 표에 남은 이름: lee
두 테스트 모두 성공했지만 첫 번째가 넣은 kim은 사라졌고 @Commit을 붙인 두 번째의 lee만 남았습니다.
테스트의 성공·실패와 상관없이 처음에 정한 깃발 하나가 롤백과 커밋을 가릅니다. 회색·노란 칸의 값은 위 예제 3의 실제 출력입니다.
헷갈리기 쉬운 곳
- 테스트 안에서 보이는 데이터는 같은 트랜잭션이라 보이는 것입니다. 위에서 롤백될 테스트도 메서드 안에서는 "행 1"을 봤습니다.
JdbcTemplate이 테스트 트랜잭션에 묶인 커넥션을 쓰기 때문입니다(스레드에 묶이는 커넥션은 17편 5절). - 테스트 대상 코드가 새 트랜잭션을 열어 커밋하면 그것은 롤백되지 않습니다. 리스너가 되돌리는 것은 자기가 연 트랜잭션 하나입니다. 다른 스레드나
REQUIRES_NEW로 열린 트랜잭션은 리스너가 모르는 일입니다. 이것은 위 코드의 구조에서 따라 나오는 것이고 예제로 따로 돌려 보지는 않았습니다. @Transactional이 테스트에 붙었을 때와 서비스에 붙었을 때는 읽는 쪽이 다릅니다. 서비스 빈의 것은 프록시가, 테스트 메서드의 것은 리스너가 읽습니다. 테스트 클래스에@Transactional을 붙였다고 테스트 객체가 프록시가 되는 것이 아닙니다.
6. MockMvc — 서버 없이 진짜 DispatcherServlet을 돌립니다
컨트롤러를 시험하는 방법은 둘을 떠올릴 수 있습니다. 컨트롤러의 메서드를 그냥 Java 메서드로 부르면 빠르지만, 주소 매핑, 인자 변환, 예외 처리처럼 Spring MVC가 해 주는 일은 하나도 시험되지 않습니다. 서버를 실제로 띄워 HTTP로 부르면 전부 시험되지만 느리고 포트가 필요합니다. MockMvc는 그 사이입니다. 서버와 네트워크만 가짜로 바꾸고, DispatcherServlet부터 안쪽은 진짜를 씁니다.
만드는 입구는 둘입니다(MockMvcBuilders 51–53행, 76–78행).
webAppContextSetup(컨텍스트)— 이미 있는WebApplicationContext를 그대로 씁니다. 5절까지의 TestContext 프레임워크가 띄운 컨텍스트를 넘겨주는 것이 보통입니다.standaloneSetup(컨트롤러…)— 컨텍스트 없이 컨트롤러 객체만 건넵니다. 빌더가 빈을 손으로 넣을 수 있는 가짜 컨텍스트(StubWebApplicationContext)를 만들고, 핸들러 매핑과 어댑터 같은 MVC 부품을 직접 만들어 넣습니다(StandaloneMockMvcBuilder363–369행, 372–417행).
어느 쪽이든 마지막에는 같은 코드로 모입니다.
TestDispatcherServlet dispatcherServlet = new TestDispatcherServlet(webAppContext);
if (dispatcherServletCustomizers != null) {
for (DispatcherServletCustomizer customizers : dispatcherServletCustomizers) {
customizers.customize(dispatcherServlet);
}
}
try {
dispatcherServlet.init(servletConfig);
}
catch (ServletException ex) {
// should never happen...
throw new MockMvcBuildException("Failed to initialize TestDispatcherServlet", ex);
}
MockMvc mockMvc = new MockMvc(dispatcherServlet, filters);
spring-test/src/main/java/org/springframework/test/web/servlet/MockMvcBuilderSupport.java 71–85행
TestDispatcherServlet은 DispatcherServlet을 상속한 클래스입니다(53행). 그리고 init(servletConfig)를 부릅니다. 서블릿 컨테이너(Tomcat 같은)가 서블릿을 켤 때 부르는 바로 그 메서드입니다. 21편에서 본 초기화, 곧 컨텍스트에서 핸들러 매핑·어댑터·예외 리졸버를 모으는 일이 여기서 똑같이 일어납니다.
하위 클래스가 덧붙이는 것은 "지나가면서 적어 두기"뿐입니다.
@Override
protected @Nullable HandlerExecutionChain getHandler(HttpServletRequest request) throws Exception {
HandlerExecutionChain chain = super.getHandler(request);
if (chain != null) {
DefaultMvcResult mvcResult = getMvcResult(request);
mvcResult.setHandler(chain.getHandler());
mvcResult.setInterceptors(chain.getInterceptors());
}
return chain;
}
spring-test/src/main/java/org/springframework/test/web/servlet/TestDispatcherServlet.java 120–129행
super.getHandler(request)로 원래 일을 그대로 시키고, 그 결과(어떤 핸들러가 골라졌는지)를 MvcResult에 적습니다. render와 processHandlerException도 같은 식으로 ModelAndView와 해결된 예외를 적어 둡니다(132–152행). 실제 HTTP 응답에는 "어느 컨트롤러 메서드가 골라졌나" 같은 정보가 없습니다. 테스트가 그것까지 검사할 수 있는 것은 이 하위 클래스가 안에서 엿보고 적어 주기 때문입니다.
perform() 한 번의 흐름
MockHttpServletRequest request = requestBuilder.buildRequest(this.servletContext);
// ...
mockResponse = new MockHttpServletResponse();
servletResponse = mockResponse;
// ...
MvcResult mvcResult = new DefaultMvcResult(request, mockResponse);
request.setAttribute(MVC_RESULT_ATTRIBUTE, mvcResult);
RequestAttributes previousAttributes = RequestContextHolder.getRequestAttributes();
RequestContextHolder.setRequestAttributes(new ServletRequestAttributes(request, servletResponse));
MockFilterChain filterChain = new MockFilterChain(this.servlet, this.filters);
filterChain.doFilter(request, servletResponse);
spring-test/src/main/java/org/springframework/test/web/servlet/MockMvc.java 170–199행
회색 상자가 가짜로 바꾼 부분(요청·응답 객체), 파란 상자가 진짜 서블릿입니다.
- 요청을 만듭니다.
get("/hello")같은 빌더가MockHttpServletRequest를 만듭니다. 이것은 서블릿 API의HttpServletRequest인터페이스를 메모리 안의 값만으로 구현한 클래스입니다. 소켓도 바이트 스트림 파싱도 없습니다. - 응답을 담을
MockHttpServletResponse와, 결과를 적어 둘MvcResult를 만들어 요청의 속성으로 달아 둡니다. 위의TestDispatcherServlet이 이 속성에서MvcResult를 꺼내 씁니다(116–118행). MockFilterChain에 태웁니다. 등록한 필터들을 차례로 거친 다음 맨 끝에서 서블릿의service()를 부르는 체인입니다(MockFilterChain92행).- 끝나면
ResultActions를 돌려줍니다.andExpect(matcher)는matcher.match(mvcResult)를 부를 뿐입니다(209–224행). 요청은perform안에서 이미 끝났고,andExpect는 남아 있는 결과를 들여다보는 것입니다.
예제 4: 컨트롤러 안에서 호출 스택 찍어 보기
"진짜 DispatcherServlet을 거친다"를 눈으로 확인하려고, 컨트롤러 메서드 안에서 지금의 호출 스택 가운데 이름에 Servlet, MockMvc, FilterChain이 들어간 것만 바깥쪽부터 찍었습니다.
// 이 글을 위해 쓴 예제입니다 (Spring의 소스가 아닙니다)
@RestController
static class HelloController {
@GetMapping("/hello/{name}")
String hello(@PathVariable String name, HttpServletRequest request) {
// 요청 객체의 클래스 이름과, 여기까지 오는 길의 호출 스택을 찍는다
return "hello, " + name;
}
}
public static void main(String[] args) throws Exception {
MockMvc mockMvc = MockMvcBuilders.standaloneSetup(new HelloController()).build();
MvcResult result = mockMvc.perform(get("/hello/spring"))
.andExpect(status().isOk())
.andReturn();
// 응답 객체, 상태, 본문, 핸들러를 찍는다
MvcResult notFound = mockMvc.perform(get("/nope")).andReturn(); // 없는 주소
try {
mockMvc.perform(get("/hello/spring")).andExpect(status().isNotFound()); // 일부러 틀린 기대
}
catch (AssertionError error) {
System.out.println(" AssertionError: " + error.getMessage());
}
}
[1] perform(get("/hello/spring"))
컨트롤러가 받은 요청 객체: org.springframework.mock.web.MockHttpServletRequest
컨트롤러에 오기까지 거친 MockMvc·필터 체인·서블릿:
MockMvc.perform
MockFilterChain.doFilter
MockFilterChain$ServletFilterProxy.doFilter
HttpServlet.service
TestDispatcherServlet.service
FrameworkServlet.service
HttpServlet.service
FrameworkServlet.doGet
FrameworkServlet.processRequest
DispatcherServlet.doService
DispatcherServlet.doDispatch
ServletInvocableHandlerMethod.invokeAndHandle
[2] 결과
응답 객체: org.springframework.mock.web.MockHttpServletResponse
상태 200, 본문 "hello, spring"
핸들러: ex.u24.MockMvcStandaloneExample$HelloController#hello(String, HttpServletRequest)
[3] 없는 주소
상태 404, 핸들러: null
[4] 기대가 틀리면
AssertionError: Status expected:<404> but was:<200>
(실행하면 [3]에서 "No mapping for GET /nope"라는 경고 로그 두 줄이, 맨 앞에 Hibernate Validator의 버전 로그가 함께 찍힙니다. 위에서는 뺐습니다.) FrameworkServlet.processRequest → DispatcherServlet.doService → doDispatch는 21편에서 따라간 그 길입니다. 주소의 {name}에서 spring을 꺼내 인자로 넣어 준 것도, 없는 주소에 404를 돌려준 것도 테스트용 코드가 아니라 Spring MVC의 원래 코드가 한 일입니다.
헷갈리기 쉬운 곳
- 가짜인 것은 서블릿 컨테이너입니다.
MockMvc의 "Mock"은 컨트롤러나 서비스를 가짜로 만든다는 뜻이 아닙니다. 요청·응답 객체와 서블릿 컨테이너 자리를 가짜로 채운다는 뜻입니다. 그래서 서블릿 컨테이너가 해 주는 일은 일어나지 않습니다. 위 스택에 Tomcat의 클래스가 하나도 없습니다. standaloneSetup은 컨텍스트의 설정을 읽지 않습니다. 컨트롤러만 건넸으므로 내 프로젝트의 MVC 설정 클래스에 등록한 인터셉터나 메시지 컨버터는 들어 있지 않습니다. 빌더의 메서드로 직접 넣거나webAppContextSetup을 써야 합니다.- 예제를 돌리다 막힌 곳. 처음에는 본문 검사를
andExpect(content().string("hello, spring"))로 썼는데 컴파일이 되지 않았습니다(class file for org.hamcrest.Matcher not found).content()가 돌려주는ContentResultMatchers에는 Hamcrest의Matcher를 받는 메서드가 함께 있어서, 클래스패스에 Hamcrest가 없으면 컴파일러가 메서드를 고르지 못합니다. 예제에서는 본문을MvcResult에서 직접 꺼내 찍는 것으로 바꿨습니다.
AssertJ를 쓰는 프로젝트를 위해서는 MockMvc를 감싼 MockMvcTester가 따로 있습니다(MockMvcTester 131행, 컨트롤러로 만드는 of(…)는 221행). 안에서 도는 서블릿은 같고 결과를 검사하는 문법만 다릅니다. 예제 프로젝트에 AssertJ를 넣지 않아 이 글에서는 돌려 보지 않았습니다.
이 글에서 뺀 것
큰 줄기를 위해 다음은 이름과 위치만 적어 둡니다.
- 빈 오버라이드 — 테스트에서 컨텍스트의 빈을 가짜로 바꿔치기하는
@MockitoBean·@MockitoSpyBean(context/bean/override/mockito)과@TestBean(context/bean/override/convention). 바꿔치기 내용은BeanOverrideContextCustomizer가 되어 4절의 캐시 키에 들어갑니다(BeanOverrideContextCustomizerFactory42–54행,equals는BeanOverrideContextCustomizer74–83행). 그래서 무엇을 가짜로 바꾸느냐가 다르면 컨텍스트도 따로 뜹니다. @DynamicPropertySource— 테스트가 시작된 뒤에야 알 수 있는 값(예를 들어 임시로 띄운 데이터베이스의 포트)을 프로퍼티로 넣는 방법.DynamicPropertiesContextCustomizerFactory가 처리합니다.- 테스트 AOT — 16편에서 본 "미리 계산해 두기"를 테스트 컨텍스트에 적용하는 부분.
context/aot패키지의TestContextAotGenerator가 생성 쪽이고, 4절의loadContext첫 줄replaceIfNecessary(312–325행)가 실행 쪽의 갈림길입니다. - 쉬는 컨텍스트 멈춰 두기 — 캐시에 있지만 지금 쓰는 테스트 클래스가 없는 컨텍스트를
pause()해 두었다가 다시 쓸 때restart()하는 기능(DefaultContextCache183–194행,ContextCache.PauseMode379행). 4절의registerContextUsage가 이 기능을 위한 것입니다. - 컨텍스트 계층 —
@ContextHierarchy로 부모·자식 컨텍스트를 만드는 경우(AbstractTestContextBootstrapper220–244행). - 병렬 실행을 위한
TestContext복사 — 2절 생성자의ThreadLocal(143행, 654–674행). - 그 밖에
@Sql,@RecordApplicationEvents, 테스트 중간에 트랜잭션을 끝내고 새로 여는TestTransaction,WebTestClient·RestTestClient, 비동기 요청을 다루는MockMvc의asyncDispatch, JUnit 4와 TestNG용 접착제가 있습니다.
정리
| 개념 | 한 줄 요약 |
|---|---|
SpringExtension |
JUnit Jupiter의 콜백을 받아 TestContextManager로 넘기는 얇은 접착제 |
TestContextManager |
테스트 클래스마다 하나. TestContext 하나와 리스너 목록을 가진다 |
TestExecutionListener |
테스트의 일곱 순간에 불리는 리스너. 실제 일(주입, 트랜잭션, 컨텍스트 폐기)을 한다 |
| 기본 리스너 | spring-test의 META-INF/spring.factories에 열두 개. 순서 값으로 정렬되고, 뒤쪽 콜백은 거꾸로 돈다 |
TestContextBootstrapper |
테스트 클래스의 어노테이션을 읽어 MergedContextConfiguration과 리스너 목록을 만든다 |
MergedContextConfiguration |
합쳐진 설정이자 컨텍스트 캐시의 키. equals·hashCode가 아홉 가지를 비교하고 테스트 클래스는 뺀다 |
DefaultContextCache |
키 → 컨텍스트 맵. JVM에 하나(static), 기본 최대 32개, 꽉 차면 가장 오래 안 쓴 것을 닫는다 |
@DirtiesContext |
그 키의 컨텍스트를 캐시에서 빼고 닫는다 |
| 테스트 트랜잭션 | 프록시가 아니라 리스너가 연다. 성공·실패와 상관없이 기본은 롤백 |
@Commit |
@Rollback(false)가 붙은 어노테이션 |
MockMvc |
요청·응답 객체만 가짜. DispatcherServlet을 상속한 TestDispatcherServlet이 진짜 코드를 돌리며 결과를 적어 둔다 |
다음에 볼 것
이번 글로 "테스트가 컨테이너를 어떻게 빌려 쓰는가"를 봤습니다. 컨텍스트는 7편의 refresh()로, 트랜잭션은 17편의 트랜잭션 매니저로, MockMvc는 21편의 DispatcherServlet으로 돌아갑니다. spring-test는 새 장치를 만들기보다 앞에서 본 것들을 테스트의 리듬에 맞춰 불러 주는 모듈이었습니다.
분석 지도의 마지막 단위가 남았습니다. 컨테이너 위에 얹혀 있는 기능들, 곧 메서드의 결과를 저장해 두는 캐시 추상화, 정해진 시각에 메서드를 부르는 스케줄링, 값이 규칙에 맞는지 보는 검증, 실패했을 때 다시 시도하는 리질리언스입니다. 27편: 캐시, 스케줄링, 검증, 리질리언스 — 컨테이너 위에 얹힌 기능들에서 이어집니다.