namalzi

← 글 목록

Spring 소스 파헤치기 26: spring-test — SpringExtension, TestContext, MockMvc

Spring · 게시

테스트 클래스에 어노테이션 한 줄을 붙이면 컨테이너가 뜨고 빈이 주입되고 트랜잭션이 롤백됩니다. JUnit과 Spring이 만나는 SpringExtension, 같은 설정이면 컨텍스트를 한 번만 띄우는 컨텍스트 캐시와 그 열쇠, 테스트 트랜잭션, 서버 없이 진짜 DispatcherServlet을 돌리는 MockMvc를 실제 소스에서 확인합니다.

이 글에서 다루는 것

"Spring 소스 파헤치기" 시리즈의 스물여섯 번째 글입니다. 25편까지는 Spring이 실제로 일하는 코드를 읽었습니다. 이번에는 그 코드를 시험해 보는 쪽, spring-test 모듈을 읽습니다. 테스트 클래스에 @SpringJUnitConfig 한 줄을 붙이면 컨테이너가 뜨고, @Autowired 필드가 채워지고, @Transactional을 붙인 테스트는 끝나면 데이터가 되돌아갑니다. 그 일을 누가 하는지 따라갑니다.

답하려는 질문은 네 가지입니다.

항목 내용
기준 소스 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 {

spring-test/src/main/java/org/springframework/test/context/junit/jupiter/SpringExtension.java 117–119행

그런데 메서드들의 본문은 하나같이 짧습니다. 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);
	}

SpringExtension.java 316–323행

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);
	}

SpringExtension.java 478–487행

computeIfAbsent는 "이 열쇠로 넣어 둔 것이 없으면 만들어 넣고, 있으면 그것을 준다"입니다. 열쇠가 테스트 클래스이므로 한 클래스의 모든 콜백이 같은 TestContextManager를 받습니다.

JUnit Jupiter에서 TestExecutionListener까지 내려가는 구조. Jupiter가 SpringExtension을 부르고, SpringExtension은 Jupiter의 루트 Store에서 테스트 클래스를 열쇠로 TestContextManager를 꺼낸다. TestContextManager는 TestContext 하나와 순서대로 정렬된 TestExecutionListener 목록을 가지고 있고, 그 목록에 의존성 주입, 트랜잭션, DirtiesContext 등의 리스너가 들어 있다. 파란 상자 둘이 접착제와 관리자입니다. 실제 일은 맨 아래 줄의 리스너들이 합니다.

헷갈리기 쉬운 곳

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행), 리스너는 관심 있는 순간만 구현하면 됩니다.

테스트 메서드 하나가 실행되는 동안 콜백이 불리는 순서. beforeAll이 beforeTestClass로, postProcessTestInstance가 prepareTestInstance로 넘어가 의존성 주입 리스너가 필드를 채운다. beforeEach가 beforeTestMethod로 넘어가 트랜잭션 리스너가 트랜잭션을 열고, 그 다음에 BeforeEach 메서드, beforeTestExecution, Test 메서드 본문, afterTestExecution, AfterEach 메서드가 차례로 실행된다. afterEach가 afterTestMethod로 넘어가 트랜잭션 리스너가 롤백 또는 커밋하고, 마지막에 afterAll이 afterTestClass로 넘어간다. 화살표 왼쪽은 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);
	}

spring-test/src/main/java/org/springframework/test/context/support/DependencyInjectionTestExecutionListener.java 152–160행

테스트 객체는 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은 뒤라는 것도 보입니다.

헷갈리기 쉬운 곳

3. 부트스트랩 — 테스트 클래스의 어노테이션이 캐시 키가 되기까지

new TestContextManager(테스트클래스)는 BootstrapUtils에게 부트스트래퍼를 골라 달라고 합니다(125–127행). 테스트 클래스에 @BootstrapWith가 없으면, @WebAppConfiguration이 있을 때는 WebTestContextBootstrapper를, 없을 때는 DefaultTestContextBootstrapper를 씁니다(BootstrapUtils 212–217행).

TestContextManager가 만들어질 때의 흐름. BootstrapUtils가 테스트 클래스마다 DefaultCacheAwareContextLoaderDelegate를 새로 만들고 부트스트래퍼를 고른다. delegate는 JVM에 하나뿐인 static defaultContextCache를 가리킨다. 부트스트래퍼는 테스트 클래스의 어노테이션을 읽어 MergedContextConfiguration을 만들고, spring.factories의 기본 목록으로 리스너를 준비한다. MergedContextConfiguration과 delegate가 DefaultTestContext에 담긴다. 노란 통 둘을 기억해 두세요. 오른쪽이 캐시의 키이고 왼쪽이 캐시입니다.

부트스트래퍼가 TestContext를 만드는 코드는 한 문장입니다.

	@Override
	public TestContext buildTestContext() {
		return new DefaultTestContext(getBootstrapContext().getTestClass(), buildMergedContextConfiguration(),
				getCacheAwareContextLoaderDelegate());
	}

spring-test/src/main/java/org/springframework/test/context/support/AbstractTestContextBootstrapper.java 106–110행

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();
			}
		}
	}

spring-test/src/main/java/org/springframework/test/context/cache/DefaultCacheAwareContextLoaderDelegate.java 138–203행

컨텍스트를 구하는 흐름. getApplicationContext가 delegate의 loadContext를 부르고, 캐시에 같은 키가 있으면 보관된 컨텍스트를 그대로 반환한다. 없으면 실패 횟수가 한도에 닿았는지 보고, 닿았으면 예외를 던지고, 아니면 캐시가 꽉 찼을 때 가장 오래 쓰지 않은 컨텍스트를 닫고 뺀 뒤 ContextLoader로 컨텍스트를 만들어 refresh하고 캐시에 넣어 반환한다. 노란 상자가 캐시를 읽고 쓰는 자리입니다. 대부분의 테스트 클래스는 왼쪽 길로 끝납니다.

  1. 캐시에서 mergedConfig를 키로 찾습니다. 있으면 그것을 돌려주고 끝입니다.
  2. 없으면, 이 키로 컨텍스트를 만들다 실패한 횟수가 한도 이상인지 봅니다. 한도의 기본값은 1입니다(CacheAwareContextLoaderDelegate 51행). 한 번 띄우다 실패한 설정은 다시 시도하지 않고 바로 예외를 던집니다. 같은 설정을 쓰는 테스트 클래스 100개가 100번 실패를 기다리지 않게 하려는 장치입니다.
  3. 아니면 loadContextInternal로 컨텍스트를 새로 만들어 캐시에 넣습니다. loadContextInternal은 MergedContextConfiguration이 들고 있는 ContextLoader에게 일을 넘기고(242–258행), 로더는 GenericApplicationContext를 만들어 설정을 입힌 뒤 refresh()를 부릅니다(AbstractGenericContextLoader 237–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행).

헷갈리기 쉬운 곳

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);
		}
	}

spring-test/src/main/java/org/springframework/test/context/transaction/TransactionalTestExecutionListener.java 208–248행

  1. 테스트 메서드(없으면 클래스)에서 @Transactional을 읽습니다. 읽는 도구는 17편의 것과 같은 AnnotationTransactionAttributeSource입니다(160행). 어노테이션이 없으면 tm이 null로 남아 아무 일도 하지 않습니다.
  2. 컨텍스트에서 트랜잭션 매니저 빈을 찾습니다.
  3. isRollback으로 "끝나면 롤백할지"를 정해 TransactionContext에 넣고, startTransaction()으로 트랜잭션을 엽니다. 안에서는 17편에서 본 transactionManager.getTransaction(…) 그대로입니다(TransactionContext 101행).

끝날 때는 afterTestMethod가 endTransaction()을 부르고(258–276행), 그 안에서 깃발 하나로 갈립니다.

		try {
			if (this.flaggedForRollback) {
				this.transactionManager.rollback(this.transactionStatus);
			}
			else {
				this.transactionManager.commit(this.transactionStatus);
			}
		}
		finally {
			this.transactionStatus = null;
		}

spring-test/src/main/java/org/springframework/test/context/transaction/TransactionContext.java 128–138행

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의 두 테스트가 지나는 순서. insertWithDefaultRollback(@Rollback 없음, 기본값 true): beforeTestMethod 가 먼저 runBeforeTransactionMethods 로 @BeforeTransaction 을 부르고(활성=false, 행 0), txContext.startTransaction() 이 getTransaction 으로 트랜잭션을 연다. 테스트 메서드가 kim 을 넣으면 활성=true, 롤백 예정=true, 행 1. afterTestMethod 의 endTransaction 은 flaggedForRollback 이 true 라 rollback 하고, 그 뒤 runAfterTransactionMethods 의 @AfterTransaction 에서 활성=false, 행 0. insertWithCommit(@Commit): 같은 순서로 열고, 테스트 메서드가 lee 를 넣어 활성=true, 롤백 예정=false, 행 1. endTransaction 은 flaggedForRollback 이 false 라 commit 하고, @AfterTransaction 에서 활성=false, 행 1 테스트의 성공·실패와 상관없이 처음에 정한 깃발 하나가 롤백과 커밋을 가릅니다. 회색·노란 칸의 값은 위 예제 3의 실제 출력입니다.

헷갈리기 쉬운 곳

6. MockMvc — 서버 없이 진짜 DispatcherServlet을 돌립니다

컨트롤러를 시험하는 방법은 둘을 떠올릴 수 있습니다. 컨트롤러의 메서드를 그냥 Java 메서드로 부르면 빠르지만, 주소 매핑, 인자 변환, 예외 처리처럼 Spring MVC가 해 주는 일은 하나도 시험되지 않습니다. 서버를 실제로 띄워 HTTP로 부르면 전부 시험되지만 느리고 포트가 필요합니다. MockMvc는 그 사이입니다. 서버와 네트워크만 가짜로 바꾸고, DispatcherServlet부터 안쪽은 진짜를 씁니다.

만드는 입구는 둘입니다(MockMvcBuilders 51–53행, 76–78행).

어느 쪽이든 마지막에는 같은 코드로 모입니다.

		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행

MockMvc의 perform 한 번의 흐름. RequestBuilder가 MockHttpServletRequest를 만들고, MockHttpServletResponse와 DefaultMvcResult를 만든 뒤, MockFilterChain이 등록된 필터들을 거쳐 맨 끝에서 TestDispatcherServlet의 service를 부른다. 거기서 DispatcherServlet의 doDispatch가 실제 서버에서와 같은 코드로 컨트롤러 메서드를 부르고, 응답은 MockHttpServletResponse에 쌓인다. 도중에 핸들러, 뷰, 예외가 MvcResult에 적히고, 둘이 합쳐져 ResultActions로 돌아온다. 회색 상자가 가짜로 바꾼 부분(요청·응답 객체), 파란 상자가 진짜 서블릿입니다.

  1. 요청을 만듭니다. get("/hello") 같은 빌더가 MockHttpServletRequest를 만듭니다. 이것은 서블릿 API의 HttpServletRequest 인터페이스를 메모리 안의 값만으로 구현한 클래스입니다. 소켓도 바이트 스트림 파싱도 없습니다.
  2. 응답을 담을 MockHttpServletResponse와, 결과를 적어 둘 MvcResult를 만들어 요청의 속성으로 달아 둡니다. 위의 TestDispatcherServlet이 이 속성에서 MvcResult를 꺼내 씁니다(116–118행).
  3. MockFilterChain에 태웁니다. 등록한 필터들을 차례로 거친 다음 맨 끝에서 서블릿의 service()를 부르는 체인입니다(MockFilterChain 92행).
  4. 끝나면 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의 원래 코드가 한 일입니다.

헷갈리기 쉬운 곳

AssertJ를 쓰는 프로젝트를 위해서는 MockMvc를 감싼 MockMvcTester가 따로 있습니다(MockMvcTester 131행, 컨트롤러로 만드는 of(…)는 221행). 안에서 도는 서블릿은 같고 결과를 검사하는 문법만 다릅니다. 예제 프로젝트에 AssertJ를 넣지 않아 이 글에서는 돌려 보지 않았습니다.

이 글에서 뺀 것

큰 줄기를 위해 다음은 이름과 위치만 적어 둡니다.

정리

개념 한 줄 요약
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편: 캐시, 스케줄링, 검증, 리질리언스 — 컨테이너 위에 얹힌 기능들에서 이어집니다.

다른 글