서론
테스트 코드를 작성하던 중 @BeforeAll
애노테이션을 사용하다가@BeforeEach
와의 차이점, 그리고 테스트 코드의 라이프 사이클을 정리해보고자 글을 작성합니다.
문제 상황
InstantService 클래스 생성 시에 Mocking처리한 InstantRepository를 세팅하기 위해
아래와 같은 코드를 작성하면서 문제를 마주쳤습니다.
@ExtendWith(MockitoExtension.class)
class test {
@Mock
private InstantRepository instantRepository;
private InstantService instantService;
@BeforeAll
void setUp() {
instantService = new InstantService(instantRepository);
}
@Test
void test() {
// ...
instantService.doSomething();
}
처음에 만난 이슈는 JUnitException 이었습니다.
스택 트레이스에서는, @BeforeAll 메서드는 @TestInstance(Lifecycle.PER_CLASS) 를 클래스 레벨에 붙이지 않는 이상
반드시 static 메서드에 선언해야 한다고 말해줍니다.
@BeforeAll method 'void com.devspacehub.ast.domain.orderTrading.service.SellOrderServiceImplTest.setUp()' must be static unless the test class is annotated with @TestInstance(Lifecycle.PER_CLASS).
org.junit.platform.commons.JUnitException: @BeforeAll method 'void com.devspacehub.ast.domain.orderTrading.service.SellOrderServiceImplTest.setUp()' must be static unless the test class is annotated with @TestInstance(Lifecycle.PER_CLASS).
at org.junit.jupiter.engine.descriptor.LifecycleMethodUtils.assertStatic(LifecycleMethodUtils.java:85)
at org.junit.jupiter.engine.descriptor.LifecycleMethodUtils.lambda$findMethodsAndAssertStaticAndNonPrivate$0(LifecycleMethodUtils.java:62)
at java.base/java.util.ArrayList.forEach(ArrayList.java:1511)
at java.base/java.util.Collections$UnmodifiableCollection.forEach(Collections.java:1092)
at org.junit.jupiter.engine.descriptor.LifecycleMethodUtils.findMethodsAndAssertStaticAndNonPrivate(LifecycleMethodUtils.java:62)
at org.junit.jupiter.engine.descriptor.LifecycleMethodUtils.findBeforeAllMethods(LifecycleMethodUtils.java:40)
at org.junit.jupiter.engine.descriptor.ClassBasedTestDescriptor.prepare(ClassBasedTestDescriptor.java:166)
at org.junit.jupiter.engine.descriptor.ClassBasedTestDescriptor.prepare(ClassBasedTestDescriptor.java:85)
at org.junit.platform.engine.support.hierarchical.NodeTestTask.lambda$prepare$2(NodeTestTask.java:123)
at org.junit.platform.engine.support.hierarchical.ThrowableCollector.execute(ThrowableCollector.java:73)
at org.junit.platform.engine.support.hierarchical.NodeTestTask.prepare(NodeTestTask.java:123)
at org.junit.platform.engine.support.hierarchical.NodeTestTask.execute(NodeTestTask.java:90)
at java.base/java.util.ArrayList.forEach(ArrayList.java:1511)
at org.junit.platform.engine.support.hierarchical.SameThreadHierarchicalTestExecutorService.invokeAll(SameThreadHierarchicalTestExecutorService.java:41)
at org.junit.platform.engine.support.hierarchical.NodeTestTask.lambda$executeRecursively$6(NodeTestTask.java:155)
at org.junit.platform.engine.support.hierarchical.ThrowableCollector.execute(ThrowableCollector.java:73)
at org.junit.platform.engine.support.hierarchical.NodeTestTask.lambda$executeRecursively$8(NodeTestTask.java:141)
at org.junit.platform.engine.support.hierarchical.Node.around(Node.java:137)
at org.junit.platform.engine.support.hierarchical.NodeTestTask.lambda$executeRecursively$9(NodeTestTask.java:139)
at org.junit.platform.engine.support.hierarchical.ThrowableCollector.execute(ThrowableCollector.java:73)
at org.junit.platform.engine.support.hierarchical.NodeTestTask.executeRecursively(NodeTestTask.java:138)
at org.junit.platform.engine.support.hierarchical.NodeTestTask.execute(NodeTestTask.java:95)
at org.junit.platform.engine.support.hierarchical.SameThreadHierarchicalTestExecutorService.submit(SameThreadHierarchicalTestExecutorService.java:35)
at org.junit.platform.engine.support.hierarchical.HierarchicalTestExecutor.execute(HierarchicalTestExecutor.java:57)
at org.junit.platform.engine.support.hierarchical.HierarchicalTestEngine.execute(HierarchicalTestEngine.java:54)
at org.junit.platform.launcher.core.EngineExecutionOrchestrator.execute(EngineExecutionOrchestrator.java:107)
at org.junit.platform.launcher.core.EngineExecutionOrchestrator.execute(EngineExecutionOrchestrator.java:88)
at org.junit.platform.launcher.core.EngineExecutionOrchestrator.lambda$execute$0(EngineExecutionOrchestrator.java:54)
at org.junit.platform.launcher.core.EngineExecutionOrchestrator.withInterceptedStreams(EngineExecutionOrchestrator.java:67)
at org.junit.platform.launcher.core.EngineExecutionOrchestrator.execute(EngineExecutionOrchestrator.java:52)
at org.junit.platform.launcher.core.DefaultLauncher.execute(DefaultLauncher.java:114)
at org.junit.platform.launcher.core.DefaultLauncher.execute(DefaultLauncher.java:86)
at org.junit.platform.launcher.core.DefaultLauncherSession$DelegatingLauncher.execute(DefaultLauncherSession.java:86)
at org.gradle.api.internal.tasks.testing.junitplatform.JUnitPlatformTestClassProcessor$CollectAllTestClassesExecutor.processAllTestClasses(JUnitPlatformTestClassProcessor.java:119)
at org.gradle.api.internal.tasks.testing.junitplatform.JUnitPlatformTestClassProcessor$CollectAllTestClassesExecutor.access$000(JUnitPlatformTestClassProcessor.java:94)
at org.gradle.api.internal.tasks.testing.junitplatform.JUnitPlatformTestClassProcessor.stop(JUnitPlatformTestClassProcessor.java:89)
at org.gradle.api.internal.tasks.testing.SuiteTestClassProcessor.stop(SuiteTestClassProcessor.java:62)
at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:77)
at java.base/jdk.internal.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)
at java.base/java.lang.reflect.Method.invoke(Method.java:568)
at org.gradle.internal.dispatch.ReflectionDispatch.dispatch(ReflectionDispatch.java:36)
at org.gradle.internal.dispatch.ReflectionDispatch.dispatch(ReflectionDispatch.java:24)
at org.gradle.internal.dispatch.ContextClassLoaderDispatch.dispatch(ContextClassLoaderDispatch.java:33)
at org.gradle.internal.dispatch.ProxyDispatchAdapter$DispatchingInvocationHandler.invoke(ProxyDispatchAdapter.java:94)
at jdk.proxy1/jdk.proxy1.$Proxy2.stop(Unknown Source)
at org.gradle.api.internal.tasks.testing.worker.TestWorker$3.run(TestWorker.java:193)
at org.gradle.api.internal.tasks.testing.worker.TestWorker.executeAndMaintainThreadName(TestWorker.java:129)
at org.gradle.api.internal.tasks.testing.worker.TestWorker.execute(TestWorker.java:100)
at org.gradle.api.internal.tasks.testing.worker.TestWorker.execute(TestWorker.java:60)
at org.gradle.process.internal.worker.child.ActionExecutionWorker.execute(ActionExecutionWorker.java:56)
at org.gradle.process.internal.worker.child.SystemApplicationClassLoaderWorker.call(SystemApplicationClassLoaderWorker.java:113)
at org.gradle.process.internal.worker.child.SystemApplicationClassLoaderWorker.call(SystemApplicationClassLoaderWorker.java:65)
at worker.org.gradle.process.internal.worker.GradleWorkerMain.run(GradleWorkerMain.java:69)
at worker.org.gradle.process.internal.worker.GradleWorkerMain.main(GradleWorkerMain.java:74)
@BeforeAll 애노테이션은 테스트 메서드들이 실행되기 전 딱 1번 수행시키고자 하는 메서드에 선언하는 애노테이션입니다.
그리고,
테스트 코드의 기본 라이프 사이클은 메서드 단위이기 때문에
라이프 사이클에 따라 각각의 메서드들이 수행될 때, 매번 초기화되지 않고 전역적으로 공유되길 원하는 변수를 초기화해주는 용도로 @BeforeAll 애노테이션을 사용할 수 있습니다.
그렇기 때문에 @BeforeAll을 사용할 수 있는 케이스를 아래 2가지 방식으로 제한하는 것으로 보입니다.
1) 라이프 사이클이 디폴트(메서드 단위)일 때는 @BeforeAll 애노테이션이 선언되는 메서드 및 내부 변수들이 static이어야 한다.
2) 라이프 사이클이 클래스 단위일 때는 하나의 클래스 내 모든 메서드들이 클래스 내 변수들을 공유하므로 non-static 메서드 및 내부 변수들에 @BeforeAll을 선언할 수 있다.
그래서 저는 2) 방식을 택하였습니다.
라이프 사이클을 클래스 단위로 바꾸기 위해 클래스에 TestInstance(LifeCycle=PER_CLASS)
를 선언하였습니다.
수정된 소스코드입니다.
@TestInstance.Lifecycle.PER_CLASS
@ExtendWith(MockitoExtension.class)
class test {
@Mock
private InstantRepository instantRepository;
private InstantService instantService;
@BeforeAll
void setUp() {
instantService = new InstantService(instantRepository);
}
@Test
void test() {
// ...
instantService.doSomething();
}
}
다시 실행해보니, 또 다른 이슈가 발생합니다. 이슈는 NullPointerException이었습니다.
이번에는 @BeforeAll 메서드를 수행하다가 에러가 발생하였습니다.
디버깅해보니.. 아래 오류 였습니다.
setUp() 메서드 내에서 Mocking 처리된 IntstantRepository 를 생성자에 전달하여 IntstantService 인스턴스를 초기화하려는데
InstantRepository가 Null 상태이다.
구글링을 해보았는데 초기화의 순서로 인한 이슈 글은 보이지 않아, ChatGPT에게 물어보았습니다.
Mockito를 통해 필드를 초기화한 후 @BeforeAll 메서드를 호출하는 것이 순서이지만
JUnit 표준 규격에 따라 테스트 메서드의 실행 순서는 보장되지 않는다..는 대답을 받았습니다.
테스트 삼아서 10번 정도 아래 소스코드에 대해 실행해보았는데,
@BeforeAll 메서드에서는 매번 InstantRepository가 Null로 출력되고, 테스트 메서드들 TEST1, TEST2 에서는 InstantRepository에 값이 제대로 들어가있는 것을 확인하였습니다.
결국 Mockito 통한 InstantRepository가 초기화되기 전에 @BeforeAll이 선언된 setUp 메서드가 실행될 수 있음을 짐작하였습니다.
항상 그런 것이 아닐지라도 그러할 가능성이 있기 때문에 순서에 대한 고려가 필요하다는 판단을 하였습니다.
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
@ExtendWith(MockitoExtension.class)
class SellOrderServiceImplTest {
IntstantService intstantService;
@Mock
IntstantRepository intstantRepository;
@BeforeAll
void setUp() {
System.out.println("==========BeforeAll 수행 (instance 메서드)=========");
System.out.println("OrderTradingRepository Null이야? : " + Objects.isNull(intstantRepository));
}
@DisplayName("TEST1")
@Test
void test1() {
System.out.println("==========TEST1 수행=========");
System.out.println("IntstantRepository Null이야? : " + Objects.isNull(intstantRepository));
}
@DisplayName("TEST2")
@Test
void test2() {
System.out.println("==========TEST2 수행=========");
System.out.println("IntstantRepository Null이야? : " + Objects.isNull(intstantRepository));
}
해결 방안
이를 해결하기 위한 방법은 2가지가 있습니다.
1) MockitoAnnotations.openMocks() 이용한 초기화 (라이프 사이클 : 클래스 단위)
아직까지는 Mockito를 사용하여 객체를 초기화하기 위해 @ExtendWith(MockitoExtension.class) 애노테이션을 이용하였는데, 애노테이션 선언 방법이 아닌 setUp 메서드 내 MockitoAnnotations의 openMocks() 메서드를 이용해 초기화하는 방법으로 수정해보았습니다.
이를 통해 나름의 순서를 보장시키도록 하였습니다.
수정된 setUp 메서드
@TestInstance.Lifecycle.PER_CLASS
//@ExtendWith(MockitoExtension.class)
class test {
@Mock
private InstantRepository instantRepository;
private InstantService instantService;
@BeforeAll
void setUp() {
MockitoAnnotations.openMocks(this); // 추가
instantService = new InstantService(instantRepository);
}
// 생략
}
실행해보니 아래와 같이 InstantService를 호출하는 순간에 InstantRepository가 Mockito를 이용해 초기화 되어있음을 확인하였습니다.
2) setUp 메서드에 @BeforeEach 선언 (라이프 사이클 : 메서드 단위)
@BeforeEach 는 @Test 붙은 테스트 메서드들이 실행되기 전 실행되는 메서드를 지정하는 애노테이션입니다.
라이프 사이클이 메서드 단위든, 클래스 단위든 상관없이 각각의 테스트 메서드가 실행되기 전에 실행됩니다.
라이프 사이클은 메서드 단위(디폴트)로 두고, setUp() 메서드에 @BeforeEach
를 선언하였습니다.
@ExtendWith(MockitoExtension.class)
class test {
@Mock
private InstantRepository instantRepository;
private InstantService instantService;
@BeforeEach
void setUp() {
instantService = new InstantService(instantRepository);
}
// 생략
}
TEST1 수행 전, TEST2 수행 전 setUp() 메서드가 수행되며, InstantRepository는 초기화되어있습니다.
저는 애노테이션을 사용하는 것을 선호하기도 하고, setUp 메서드가 매 테스트 메서드마다 수행되어도 속도 지연에 영향을 미칠 만큼의 작업이 아닌, 간단한 작업들만 있었기 때문에
방법 2)를 선택하였습니다.
개념 정리
테스트 코드의 라이프 사이클
JUnit5에서 테스트 코드의 life cycle은 기본적으로 메서드 단위로 설정되어 있습니다.
기본적으로, JUnit5는 테스트 클래스에서 선언한 인스턴스들을 각 메서드를 수행시키기 전에 생성합니다. (JUnit4도 동일.)
이를 통해서 테스트들 사이에 인스턴스 상태를 깔끔하게 분리시킵니다.
그런데, 여러 테스트들에 걸쳐서 공유되는 객체가 존재해야하도록 해야할 때가 있습니다.
테스트 데이터인데, 엄청 큰 용량의 파일을 읽어야 한다면? 이럴 때 모든 테스트를 수행하기 전에 큰 용량의 파일을 읽어서 세팅하는 것은 많은 시간이 소요되기 때문에 우리는 이럴 때 객체(데이터)를 전체 test fixture에 유지시키는 것을 선호할 것입니다.
@TestInstance
애노테이션
JUnit5에서만 제공하는 애노테이션으로, 테스트 코드의 라이프 사이클을 설정하는 애노테이션입니다. 두개의 모드를 지원합니다.
- LifeCycle.PER_METHOD
: life cycle이 메서드가 되어서, 테스트 메서드들이 수행되기 전 매번 초기화됩니다. (default)
: 테스트 클래스 내부의 테스트 메서드들이 실행될 때마다 테스트 클래스 내 변수들이 새로 생성되고 삭제됩니다.
- LifeCycle.PER_CLASS
: life cycle이 클래스가 되어서, 테스트 클래스에서 하나의 인스턴스만 생성하면 클래스 내 테스트들 간에도 그 인스턴스를 재사용하도록 합니다.
: 특정 테스트 메서드에서의 인스턴스 변수의 상태의 변화는 다른 테스트 메서드에서도 공유됩니다.
: life cycle을 클래스 단위로 설정해야하는 상황으로 3가지가 있습니다.
1. 비싼 자원을 사용할 때
모든 테스트 이전에 클래스를 인스턴스화 하는 것이 매우 비싼 상황에서 라이프 사이클을 클래스 단위로 설정함으로써 이점을 가져올 수 있습니다.
EX) db 커넥션 생성, 용량이 큰 파일 로드
2. 공유 상태 유지
상태를 공유하는 것은 단위 테스트에서 anti-pattern으로 여겨지지만, 통합 테스트에서는 유용할 수 있습니다.
life cycle을 클래스 단위로 설정하면, 의도적으로 상태를 공유하는 순차(sequential) 테스트를 지원할 수 있습니다.
JUnit5는 테스트들이 순차적으로 수행될 수 있도록 `@TestMethodOrder` 애노테이션을 type-level로 제공합니다.
그리고 각 메서드에는 `@Order` 애노테이션을 추가해서 우리가 원하는 순서대로 테스트 메서드들이 수행되도록 설정할 수 있습니다.
3. 일부 인스턴스의 상태만 공유하기
테스트 클래스에서 인스턴스들을 공유할때 이슈는 일부 인스턴스들은 테스트들 간 사이에서 리셋되어야하고 일부는 전체 테스트 실행동안 유지되어야할 때입니다.
추가로, 동시에 테스트 간 정리되어야 하는 변수들도 있다면
@BeforeEach
애노테이션이나 @AfterEach
애노테이션을 붙인 메서드들을 사용해서 해당 변수들에 대해서만 리셋할 수도 있습니다.
<끝.>
참고
https://adjh54.tistory.com/346#1.%20Mock%20초기화-1
https://www.baeldung.com/java-beforeall-afterall-non-static
[F-Lab 모각코 챌린지 63일차] @BeforeAll, @AfterAll Non-static method로 구현하기
'Test Code' 카테고리의 다른 글
[참고 글] Test Double (0) | 2024.05.17 |
---|---|
테스트 환경에서 환경변수 설정하기 (0) | 2024.04.16 |
[JUnit5] 실패 케이스 테스트하기 (0) | 2024.02.24 |
[테스트 코드] Controller Test Code / @WebMvcTest / 행위 검증 (0) | 2024.02.12 |