내가 JUnit5에 글로벌 Extension 필터링 기능을 추가한 이야기

2025. 4. 22. 20:26·Backend
반응형

안녕하세요 junit5, spring-boot, fixture-monkey 등 여러 오픈소스에 기여를 활발히 기여하고 있는 YongGoose입니다.

이번 글에서는 제가 JUnit5에 처음으로 기여를 한 글로벌 Extension 필터링 기능에 대해 작성하려 합니다.

 

요즘 GitHub과 블로그를 찾아봐 주신 분들, 그리고 커피챗을 신청해 주신 분들 덕분에 뿌듯하고 기분 좋은 시간을 보내고 있습니다.

감사합니다. 🙇🏻‍♂️

JUnit in action 3판의 엮은이이신 동준님께도 이 글을 빌려, 감사의 말씀을 전합니다.

Extension이란?

우선, JUnit5에서 Extension이란 테스트 클래스나 메서드에 추가적인 기능을 제공하는 메커니즘입니다.

Extension을 활용해 테스트의 생명 주기나 이벤트에 관여할 수 있습니다.


이때 Extension을 등록하는 방법은 여러 가지가 있습니다.

1. 선언적 등록

JUnit5에서는 @ExtendWith 어노테이션을 통해 테스트 클래스, 메서드, 필드에 Extension을 적용할 수 있습니다.

JUnit4를 주로 사용하시던 분들이라면 @RunWith를 생각하시면 됩니다.

 

아래의 코드와 같이 적용할 수 있습니다.

@ExtendWith(MyExtension.class)
public class MyTest {
    @Test
    void test1() {
        // 테스트 코드
    }
}

2. 자동 등록

JUnit5에서는 ServiceLoader 메커니즘을 활용해 특정 파일에 작성된 Extension들을 자동으로 등록할 수 있습니다.

하지만, 이때 하나의 단점이 있는데 필터링 기능이 없어 Extension이 모두 적용됩니다.

 

본 글은 새로운 기능을 소개하는 것이 목적이기 때문에 Extension은 간략하게 소개하겠습니다.
Extension에 대해 더 알고싶으신 분은 공식문서를 보시는 것을 추천합니다.

 

이슈

이로 인해, 자동 등록된 Extension 중에서도 특정 Extension만 선택적으로 활성화할 수 있는 메커니즘을 도입하자는 이슈가 제기되었습니다.

간략하게 설명하자면, 현재 junit.jupiter.extensions.autodetection.enabled=true 구성 매개변수를 통해 모든 글로벌 Extension을 활성화하면 잠재적인 문제가 발생할 수 있습니다. 그로 인해 본인이 사용하고 싶은 Extension만 활성화시키고 싶다는 이슈입니다.

 

이러한 이슈가 등록되고 5일 후, 메인테이너님께서 JUnit-team 분들과 회의를 하시고, 특정 글로벌 확장을 선택적으로 활성화할 수 있도록, junit.jupiter.extensinos.autodetection.include / exclude 구성 매개변수를 도입하기로 결정했습니다.

 

https://github.com/junit-team/junit5/issues/3717

 

Introduce mechanism to enable specific global extensions in JUnit Jupiter · Issue #3717 · junit-team/junit5

Enabling all global extensions on the class/module path with junit.jupiter.extensions.autodetection.enabled=true is asking for surprises (and potentially trouble). Instead, I'd like to explicitly e...

github.com

 

작업

자동 등록 방식에서는 ServiceLoader 메커니즘을 통해 Extension을 조회한 뒤, 등록하는 방식으로 진행합니다.

그래서 이때 구성 매개변수를 통해 include / exclude 필터를 등록하고 ServiceLoader를 통해 Extension을 조회하는 시점에 해당 필터를 적용해 필요한 Extension만 등록할 수 있도록 하면 되었습니다.

 

코드와 함께 더 자세히 보겠습니다.

private static void registerAutoDetectedExtensions(MutableExtensionRegistry extensionRegistry,
		JupiterConfiguration configuration) {

	Predicate<Class<? extends Extension>> filter = configuration.getFilterForAutoDetectedExtensions();
	List<Class<? extends Extension>> excludedExtensions = new ArrayList<>();

	ServiceLoader<Extension> serviceLoader = ServiceLoader.load(Extension.class,
		ClassLoaderUtils.getDefaultClassLoader());
	ServiceLoaderUtils.filter(serviceLoader, clazz -> {
		boolean included = filter.test(clazz);
		if (!included) {
			excludedExtensions.add(clazz);
		}
		return included;
	}) //
			.forEach(extensionRegistry::registerAutoDetectedExtension);

	logExcludedExtensions(excludedExtensions);
}

위 코드는 필터를 생성한 뒤, ServiceLoader에서 Extension을 조회하는 시점에 필터를 적용해 사용자가 지정한 Extension만 가져오도록 하는 코드입니다. 

아까 전부터 궁금했던 내용인데 ServiceLoader는 Extension을 어떻게 가져오는 거야?

 

매우 좋은 질문입니다.

이를 자세히 알기 위해서는 ServiceLoader과 ClassLoader에 대해 알아야 합니다.

ServiceLoader<Extension> serviceLoader = ServiceLoader.load(Extension.class,
	ClassLoaderUtils.getDefaultClassLoader());

위 코드는 ServiceLoader를 활용해 Extension 인터페이스의 구현체를 동적으로 로드하는 코드입니다.

 

ClassLoader는 자바에서 클래스 파일을 메모리에 로드하고 초기화하는 역할을 하는 클래스입니다.

실제로 Java는 JVM을 통해 실행이 되는데 이때 JVM 내부에 위치한 ClassLoader가 컴파일러에 의해 바이트 코드로 변환된 클래스 파일을 메모리에 로드하는 역할을 합니다.

 

ServiceLoader는 자바에서 특정 서비스의 구현체를 동적으로 검색하고 로드하는 기능을 제공하는 클래스입니다.

META-INF/services 디렉토리에 텍스트 파일로 등록된 클래스의 이름을 검색해 로드합니다.

 

이때 ServiceLoader에서는 ClassLoader를 활용해 META-INF/services 디렉토리에 텍스트 파일로 등록된 클래스의 이름을 읽어온 뒤, 이름을 통해 해당 클래스 파일을 메모리에 로드합니다. 이후, ServiceLoader에서 내부적으로 클래스 파일의 구현체를 생성하는 것입니다.

1. ServiceLoader.load() 호출
- ClassLoader 지정
2. 지연 초기화(Lazy Initialization)
- 실제 순회 시점에 리소스 탐색
3. 리소스 탐색
- ClassLoader.getResources()로 모든 META-INF/services 파일 수집
4. 구현체 인스턴스화
- 각 클래스명을 Class.forName()으로 로드 후 인스턴스 생성

 

public static <T> Stream<T> filter(ServiceLoader<T> serviceLoader,
		Predicate<? super Class<? extends T>> providerPredicate) {
	return StreamSupport.stream(serviceLoader.spliterator(), false).filter(it -> {
		@SuppressWarnings("unchecked")
		Class<? extends T> type = (Class<? extends T>) it.getClass();
		return providerPredicate.test(type);
	});
}

위 코드는 필터를 통해 ServiceLoader에서 생성한 구현체들의 인스턴스를 선별하는 코드입니다.

자세히 보면 serviceLoader.spliterator()을 통해 Stream 연산을 하는 것을 볼 수 있습니다.

 

이때 spliterator API를 사용하기 위해선 iterator가 필요한 것을 볼 수 있습니다.

그러면 ServiceLoader에서 어떻게 iterator을 생성한 지 보겠습니다.

 

ServiceLoader의 iterator API 내부 코드입니다.

ServiceLoader에서는 iterator API를 통해 Iterator을 생성할 때 지연 로드를 위한 반복자를 생성합니다.

 

newLookupIterator API는 ModuleServicesLookupItertor, LazyClassPathLookupIterator을 사용해 타입에 맞는 요소를 탐색합니다. 이때 탐색 우선순위는 모듈 시스템 → 클래스패스 순입니다.

 

이때 LazyClassPathLookupIterator가 바로 지연 로드를 하는 반복자이면서, META-INF/services/에 정의된 확장자들을 가져와 객체로 생성해 주는 반복자입니다. (코드의 양이 많아 링크로 대체합니다)

https://github.com/AdoptOpenJDK/openjdk-jdk11/blob/19fb8f93c59dfd791f62d41f332db9e306bc1422/src/java.base/share/classes/java/util/ServiceLoader.java#L1108

 

이렇게 생성된 iterator을 ServiceLoaderUtils Spliterator로 변환해 stream 연산을 수행하고, 이때 사용자로부터 입력받은 패턴을 적용해서 특정 Extension만 적용이 되게 하는 것입니다.

 

@Test
void registryIncludesAllAutoDetectedExtensionsAndExcludesNone() {
	when(configuration.isExtensionAutoDetectionEnabled()).thenReturn(true);
	when(configuration.getFilterForAutoDetectedExtensions()).thenReturn(extensionFilter("*", ""));
	registry = createRegistryWithDefaultExtensions(configuration);

	List<Extension> extensions = registry.getExtensions(Extension.class);

	assertEquals(NUM_DEFAULT_EXTENSIONS + 2, extensions.size());
	assertDefaultGlobalExtensionsAreRegistered(4);

	assertExtensionRegistered(registry, ServiceLoaderExtension.class);
	assertExtensionRegistered(registry, ConfigLoaderExtension.class);
	assertEquals(4, countExtensions(registry, BeforeAllCallback.class));
}

---

@Test
void registryIncludesSpecificAutoDetectedExtensionsAndExcludesAll() {
	when(configuration.isExtensionAutoDetectionEnabled()).thenReturn(true);
	when(configuration.getFilterForAutoDetectedExtensions()).thenReturn(
		extensionFilter(ServiceLoaderExtension.class.getName(), "*"));
	registry = createRegistryWithDefaultExtensions(configuration);

	List<Extension> extensions = registry.getExtensions(Extension.class);

	assertEquals(NUM_CORE_EXTENSIONS, extensions.size());
	assertDefaultGlobalExtensionsAreRegistered(2);

	assertExtensionNotRegistered(registry, ServiceLoaderExtension.class);
	assertEquals(2, countExtensions(registry, BeforeAllCallback.class));
}

해당 기능을 테스트하기 위해 작성한 통합 테스트입니다. 

 

아래 제가 작성한 공식 문서를 보면 기능을 사용하는 방법이 자세히 나와있습니다!

https://junit.org/junit5/docs/current/user-guide/#extensions-registration-automatic-filtering

 

JUnit 5 User Guide

Although the JUnit Jupiter programming model and extension model do not support JUnit 4 features such as Rules and Runners natively, it is not expected that source code maintainers will need to update all of their existing tests, test extensions, and custo

junit.org

 

 

 

반응형
저작자표시 동일조건 (새창열림)
'Backend' 카테고리의 다른 글
  • Spring 관점에서 보는 Seata의 내부 통신
  • Apache Seata란?
  • 내가 JUnit5에 병렬화를 도입한 이야기 - 메서드 단위
  • 내가 JUnit5에 병렬화를 도입한 이야기 - 클래스 단위
코딩하는_대학생
코딩하는_대학생
Java Developer, Open Source Enthusiast, Proud Son
  • 코딩하는_대학생
    코딩하는 대학생에서 개발자까지
    코딩하는_대학생
  • 전체
    오늘
    어제
    • 분류 전체보기 (218)
      • 코딩하는 대학생의 책 추천 (8)
        • 클린코드 (5)
        • 헤드퍼스트 디자인패턴 (3)
      • Backend (8)
        • Spring (14)
        • AWS (3)
        • 회고 (4)
        • Redis (5)
        • 다양한 시각에서 바라본 백엔드 (3)
      • Python (35)
        • 개념 및 정리 (15)
        • 백준 문제풀이 (20)
      • JAVA (17)
        • 개념 및 정리 (14)
        • 백준 문제풀이 (2)
      • 왜? (7)
      • C언어 (42)
        • 개념 및 정리 (9)
        • 백준 문제풀이 (32)
      • 개인 공부 (27)
        • 대학 수학 (5)
        • 대학 영어 (10)
        • 시계열데이터 처리 및 분석 (5)
        • 컴퓨터 네트워크 (6)
        • 운영체제 (1)
      • 솔직 리뷰 (23)
        • 꿀팁 (6)
        • IT기기 (1)
        • 국내 여행 (7)
        • 맛집 (2)
        • 알바 리뷰 (2)
      • 대외활동 (17)
        • 체리피우미 3기 (4)
        • 꿀잠이들 6기 (13)
      • 음식 평가 (1)
      • 일상 & 근황 (2)
  • 최근 글

  • hELLO· Designed By정상우.v4.10.3
코딩하는_대학생
내가 JUnit5에 글로벌 Extension 필터링 기능을 추가한 이야기
상단으로

티스토리툴바