개발 환경: IntelliJ 2024.3.1.1(Ultimate), JDK 21, Gradle 8.13
IntelliJ IDEA 환경과 Gradle 환경에서 Junit 살펴보기
오늘은 개발자들이 Junit을 사용하면서 스쳐지나갔을 main 함수가 없는데 Junit이 동작할 수 있는 이유에 대해 알아보고자 한다.
우선 인텔리제이에서 Build And Run 설정을 IntelliJ IDEA로 설정했다.
그리고 간단한 테스트 코드를 바로 프로파일링을 해보았다.
그럼 com.intellij.rt.junit.JunitStarter.main 메서드가 제일 먼저 실행된다.
이번에는 Build And Run 설정을 Gradle로 설정했다.
이제 다시 프로파일링을 진행해보자.
우선 제일 먼저 호출되는 코드는 GradleWorkerMain.main() 메서드다.
그럼 결론은 "IDE와 build tool인 gradle이 대신 실행시켜준다." 지만 이렇게 마무리하면 너무나도 허무하다.
그래서 JUnit 실행 로직을 살펴보려고한다.
사실 해당 글을 작성한 이유는 토비님의 아래 영상을 따라하면서 Junit3에 대한 구조를 어느 부분 학습하면서,
Junit5를 깊게 더 살펴보고 싶기 때문이다.
https://www.youtube.com/watch?v=tdKFZcZSJmg&t=348s
그럼 시작해보자.
우선 Gradle, IntelliJ IDEA로 실행하면서 공통되는 부분을 먼저 찾아보았다.
노란색 사각형부터 본격적인 공통 Junit 로직이 돌아가고 그 아래 부분은 호출하는 클래스 차이가 크다.
이 차이부터 알아보자.
먼저 얼티밋이 아닌 깃허브의 공개된 인텔리제이 커뮤니티 IDE 기준으로,
Junit5IdeaTestRunner는 Util 클래스를 활용해 테스트 진행할 파일을 읽어 LauncherDiscoveryRequest 객체를 만들어
Junit5의 Launcher에게 전달해 실행할 테스트를 결정하는데 사용된다.
intellij-community/plugins/junit5_rt/src/com/intellij/junit5/JUnit5IdeaTestRunner.java at master · JetBrains/intellij-community
IntelliJ IDEA Community Edition & IntelliJ Platform - JetBrains/intellij-community
github.com
Gradle도 동일하게 살펴보니 JUnitPlatformTestClassProcessor.CollectAllTestClassesExecutor 클래스가
execute() 메서드를 기반으로 동일하게 LauncherDiscoveryRequest 만들어 Junit5의 Launcher에게 전달하고 있다.
결국 Junit5은 LauncherDiscoveryRequest를 Launcher.exeucte를 기반으로 동작하는 것이다.
Junit5 아키텍쳐
그래서 JunitLauncher에 대해 찾아보다 다음과 같은 좋은 글을 발견하게 되었다.
https://nipafx.dev/junit-5-architecture-jupiter/
JUnit 5 Architecture or "What's Jupiter?" // nipafx
The JUnit 5 architecture promotes a better separation of concerns than JUnit 4 did. It also provides clear APIs for testers (Jupiter) and tools (Platform).
nipafx.dev
Junit은 모든 기능이 하나의 아티팩트(artifact)에 번들로 제공되면서,
개발자, IDE, 빌드 도구, 다른 테스트 프레임워크 등이 모두 같은 아티팩트에 의존한다.
또한 IDE와 빌드 도구는 JUnit의 내부 구현에 깊이 의존하게 되었고, Junit 개발자들이 내부 구현을 쉽게 변경할 수 없게 되었다.
따라서 Juni5은 이런 문제를 해결하기 위해 개발되었다.
관심사를 분리하면서 2가지의 관심사와 더 많은 하위 관심사로 분리되었다.
- 테스트를 작성하기 위한 API
- 테스트를 발견하고 실행하는 메커니즘
- 특정 유형의 테스트를 발견하고 실행하는 메커니즘 (Junit Jupiter, Junit vintage)
- 여러 특정 메커니즘을 조율하는 메커니즘 (Junit Platform Launcher)
- 이들 사이의 API (Junit Platform Engine API)
관심사가 분리되면서 도구로서의 JUnit(테스트를 작성할 때 사용)과 플랫폼으로서의 Junit(테스틀르 사용하는데 필요한)이 명확히 분리되었다.
이에 따라 Junit5는 3개의 하위 프로젝트로 분리되었다.
- Junit Jupiter: 우리가 테스트를 작성할 때 사용하는 API와 이를 이해하기 위한 엔진(TestEngine 구현체)
- Junit Vintage: Junit 3과 4로 작성된 테스트를 Junit5에서 실행할 수 있게 해주는 엔진(TestEngine 구현체)
- Junit Platform
- 테스트를 발견하고 계획을 생성하는 TestEngine API를 포함하며, 해당 API를 사용하면 IDE(예: IntelliJ, Eclipse), 빌드 도구(예: Gradle, Maven)에서 JUnit 테스트 실행 가능
더 세분화하면 5개로 쪼갤 수 있다.
- junit-jupiter-api
- 개발자가 테스트를 작성하는 API. JUnit 5의 기본 사항에서 논의했던 모든 어노테이션, 단언(assertions) 등을 포함
- junit-jupiter-engine
- JUnit 5 테스트를 실행하는 junit-platform-engine API의 구현체
- junit-vintage-engine
- JUnit 3 또는 4로 작성된 테스트를 실행하는 junit-platform-engine API의 구현체
- 여기서 JUnit 4 아티팩트 junit-4.12는 개발자가 테스트를 구현하는 API로 작용하지만 테스트 실행 방법에 대한 주요 기능도 포함하며 이 엔진은 JUnit 3/4를 버전 5에 맞게 조정하는 어댑터로 볼 수 있다.
- junit-platform-engine: 모든 테스트 엔진이 구현해야 하는 API로, 통일된 방식으로 접근할 수 있게 한다. 엔진은 일반적인 JUnit 테스트를 실행할 수도 있지만, 대안적으로 TestNG, Spock, Cucumber 등으로 작성된 테스트를 실행할 수도 있다.
- junit-platform-launcher: ServiceLoader를 사용하여 테스트 엔진 구현체를 발견하고 실행을 조율한다. IDE와 빌드 도구에 API를 제공하여 개별 테스트를 실행하고 결과를 표시하는 등의 테스트 실행과 상호 작용할 수 있게 한다.
우리는 눈으로 Gradle과 IntelliJ IDEA에서 junit-platform-launcher를 활용해 junit을 구동시키는 것을 보았다.
그럼 이제 큰 Junit5 내부 구조를 뜯어보자.
Junit 내부 구조
우선 내부 구조를 파악하려면 하나하나 뜯어야한다.
Build And Run을 Gradle로 설정했을 때 진행한 프로파일링 Call Tree로 뜯어보겠다.
먼저 Junit5의 Launcer가 junit.platform에 속한 EngineExecutionOrchestrator.execute 메서드를 호출한다.
이 클래스는 테스트 엔진들을 활용해 테스트 실행 과정 전체를 관리하고 진행한다.
다음으로 호출되는 클래스는 HierachicalTestEngine(정확한 구현체는 JupiterTestEngine)다.
TestEngine의 구현체로 Node 추상화를 기반으로 테스트 스위트를 계층적으로 구성하고자 하는 엔진에서 사용한다.
테스트 스위트!? 위에서 언급한 토비님 영상에서 코드를 따라치며 학습한 개념이다.
잠깐 삼천포로 빠져보자..
먼저 Junit에서 사용하는 용어 몇 가지를 정리해보자
- 테스트 케이스: 개별적인 테스트 메서드로, 특정 기능이나 동작을 검증 (@Test 붙은 메서드)
- 테스트 클래스: 여러 테스트 케이스(메서드)를 포함하는 클래스
- 테스트 스위트: Junit에서 테스트 스위트란 여러 테스트 클래스들을 모아서 그룹으로 실행할 수 있게 해준다
그리고 위에서 언급한 Node 추상화라는 개념은 무엇일까?
Node 인터페이스와 이를 구현한 다른 클래스들을 한번에 살펴보자.
Node 인터페이스는 '테스트 실행 구조에서 하나의 구성 요소'이다.
EngineExecutionContext, 실행 컨텍스트를 관리하며 테스트를 수행하고, 라이프 사이클 콜백 등을 제공한다.
그리고 구현체로 온갖 XxxTestDescriptor 클래스들이 들어가있는걸 확인할 수 있다.
TestDescriptor는 '테스트 엔진이 찾아낸 테스트나 테스트 그룹에 대한 정보를 담고 있는 수정 가능한 객체'이다.
생각보다 다양한 역할을 수행한다.
- 테스트 발견 단계에서 사용
- 테스트 구조의 메타 데이터 관리
- 계층적 테스트 구조 정의(부모 자식 관계)
- 테스트 식별자 관리
- 테스트 표시 이름 관리(IDE, 빌드 툴에서 사용)
테스트의 구조와 메타데이터를 표현하는 객체로, 테스트 실행 과정에서 필요한 정보를 담고 있다.
다시 호출 스택을 살펴보자
HierachicalTestEngine는 HierarchicalTestExecutor.execute()를 호출하며,
HierarchicalTestExecutor클래스는 SameThreadHierarchicalTestExecutorService에게 테스트 실행을 위임한다.
우리는 Junit 실행에 관해서 병렬 설정을 한게 없으므로 SameThreadHierarchicalTestExecutorService가 실행되는 것이다.
SameThreadHierarchicalTestExecutorService가 submit() 메서드를 호출하면서 NodeTesTask 클래스 객체를 인자로 넘겨주고, NodeTestTask 객체가 execute 메서드를 실행한다.
NodeTestTask 클래스가 테스트 스위트의 역할을 하는 core 클래스인데, execute() 메서드를 살펴보자.
executeRecursively() 메서드가 순환적으로 테스트를 수행한다.
메서드 내부에서 ThrowableCollector라는 클래스가 있는데, 예외가 터지면 인스턴스 변수 throwable에 Throwable 타입의 객체를 담는다.
reportCompletion()메서드에서 ThrowableCollector 클래스가 throwable 인스턴스 변수 값이 null이라면
TestExecutionResult를 SUCCESSFUL_RESULT로 돌려주고, 실패라면 FAILED를 리턴한다.
NodeTestTask의 코어 부분만 정리를 해보겠다.
- NodeTestTask에서 순환적으로 하위 NodeTestTask를 수행하며, 테스트를 수행한다.
ROOT에서 Test1 클래스와 Test2클래스를 수행하면, Test1, Test2 클래스 내부의 모든 테스트 메서드가 수행되는 것이다. - 모든 NodeTestTask는 ThrowableCollector 타입의 인스턴스 변수가 있는데, 해당 클래스의 필드에 Throwable 타입의 필드 값이 null이라면 ThrowableCollector 클래스가 TestExecutinoResult를 SUCCESSFUL_RESULT로 리턴하고, null이 아니라면 FAILED로 리턴한다.
- TestExecutionResult가 IDE에서 테스트가 성공했는지 실패했는지를 보여주는 결과물이다.
토비님 영상과 TDD 책에서는 TestCase.run의 파라미터로 TestResult를 받아서 처리했는데,
Junit5는 ThrowableCollector를 사용해 처리한다.
남아있는 의문은 TestExecutionResult를 어떻게 IDE에서 예쁘게 보여주는지다.
이는 추후에 확인을 해보는걸로..
이상으로 포스팅을 마칩니다. 감사합니다.
댓글