개요
현재 프로젝트에서 SesseionUser라는 클래스가 있다.
SessionUser는 세션에 User 정보를 관리하는 유틸리티 클래스로, static 메서드로 작성되어있다.
public class SessionsUser {
public static void setSessionUser(HttpSession session, Users users) {
session.setAttribute("userSessionInfo", users);
}
public static Users getSessionUser(HttpSession session) {
return (Users) session.getAttribute("userSessionInfo");
}
public static boolean isLoginUser(HttpSession session) {
return getSessionUser(session) != null;
}
public static void removeSessionUser(HttpSession session) {
session.removeAttribute("userSessionInfo");
}
}
Controller 단위 테스트를 작성하는 중 SessionUser 클래스를 Mocking 해줘야 했는데, Mockito는 static 메서드를 가진 클래스를 Mock으로 만들 수 없었다. (정확히는 Mockito-core가 만들 수 없다.)
Mockito가 static 메서드를 Mocking할 수 없는 이유와 static Mock을 만들 수 있는 방법에 대해 알아보자
개발 환경
- Java 8
- Spring Boot 2.7.x
- Gradle
- JUnit5, Mockito
Static 메소드를 Mocking할 수 없는 이유
해결 방법만 보고싶다면 링크 클릭
Mockito Mocking 흐름
static 메소드를 Mocking할 수 없는 이유를 알기 위해서 Mockito가 어떻게 Mock을 만드는지 알아보자
- 프록시 객체 생성
- Mockito는 프록시 기반으로 Mocking한다.
- Mockito는 지정된 인터페이/클래스를 구현/상속한 프록시 객체를 동적으로 생성한다.
- 생성된 프록시 객체는 원본 객체의 메소드를 Override해서 Mockito가 제어할 수 있도록 한다.
- 메소드 인터셉터
- 프록시 객체의 메서드가 호출되면 Mockito가 가로채서 Stubbing한 동작대로 처리한다.
- Stubbing : when(), thenReturn() 등으로 개발자가 특정 메서드가 호출될 때의 반환값을 지정할 수 있다.
public class SubclassByteBuddyMockMaker implements ClassCreatingMockMaker {
...
public <T> T createMock(MockCreationSettings<T> settings, MockHandler handler) {
Class<? extends T> mockedProxyType = this.createMockType(settings); // Proxy 객체 타입 생성
Instantiator instantiator = Plugins.getInstantiatorProvider().getInstantiator(settings);
T mockInstance = null;
try {
mockInstance = instantiator.newInstance(mockedProxyType); // Proxy 객체 인스턴스 생성
MockAccess mockAccess = (MockAccess)mockInstance;
mockAccess.setMockitoInterceptor(new MockMethodInterceptor(handler, settings)); // Proxy 객체의 메서드 호출을 가로채고 처리
return ensureMockIsAssignableToMockedType(settings, mockInstance); // Proxy 객체 타입과 원본 객체 타입이 일치하는지 체크
} catch (ClassCastException var7) {
ClassCastException cce = var7;
throw new MockitoException(StringUtil.join(new Object[]{"ClassCastException occurred while creating the mockito mock :", " class to mock : " + describeClass(settings.getTypeToMock()), " created class : " + describeClass(mockedProxyType), " proxy instance class : " + describeClass(mockInstance), " instance creation by : " + instantiator.getClass().getSimpleName(), "", "You might experience classloading issues, please ask the mockito mailing-list.", ""}), cce);
} catch (InstantiationException var8) {
InstantiationException e = var8;
throw new MockitoException("Unable to create mock instance of type '" + mockedProxyType.getSuperclass().getSimpleName() + "'", e);
}
}
...
}
Proxy 생성 할 수 없다 == Mocking 할 수 없다
Mockito의 Mocking 과정에서 힌트를 얻을 수 있다.
Mockito는 Proxy 기반으로 Mocking을 한다. 프록시는 인터페이스/클래스의 인스턴스를 상속/구현해서 생성된다.
하지만 static 메소드는 인스턴스가 아니기 때문에 Heap 영역이 아닌 Method 영역에 저장되어 Heap과 별도로 관리된다. (JVM 구조를 생각해보자)
static 메소드는 상속/구현도 할 수 없고 메소드를 오버라이드 할 수 없기 때문에 static 메소드를 프록시 객체로 생성할 수 없다.
결론적으로 프록시 기반으로 Mocking을 하는 Mockito에게 프록시 할 수 없다는건 Mocking할 수 없다는 것과 마찬가지이다.
static 메소드를 모킹하기 위해서는 클래스의 바이트코드를 직접 수정해야 한다.
방법이 없는걸까?
안된다고는 했지만 Mockito에서 제공하는 MockedStatic 인터페이스가 있다. 한 번 사용해보자.
MockedStatic을 생성하는 방법은 일반 Mock과 사용법이 조금 다르다. MockedStatic은 AutoClosable을 구현한 객체이기 때문에 close() 메서드로 직접 자원을 해제해줘야 한다. 또는 try-resource-with문을 사용하면 사용된 이후 알아서 자원을 해제해준다.
이제 아래 테스트를 실행해보자
@WebMvcTest(LoginManagerController.class)
class LoginManagerControllerTest {
@Autowired
private MockMvc mockMvc;
private final String BASE_URL = "/am/manager";
@Test
@DisplayName("로그인 페이지로 이동")
void login1() throws Exception {
// given
try(MockedStatic<SessionsUser> sessionUser = Mockito.mockStatic(SessionsUser.class)) {
given(SessionsUser.isLoginUser(Mockito.any(MockHttpSession.class))).willReturn(false);
// when & then
mockMvc.perform(get(BASE_URL + "/login")
.session(new MockHttpSession()))
.andExpect(status().isOk())
.andExpect(view().name("/manager/loginForm"))
.andDo(print());
}
}
}
org.mockito.exceptions.base.MockitoException:
The used MockMaker SubclassByteBuddyMockMaker does not support the creation of static mocks
Mockito's inline mock maker supports static mocks based on the Instrumentation API. You can simply enable this mock mode, by placing the 'mockito-inline' artifact where you are currently using 'mockito-core'. Note that Mockito's inline mock maker is not supported on Android.
테스트를 실행하면 위 내용과 같은 예외가 발생한다.
내용을 해석해보면 지금 사용중인 MockMaker의 SubclassByteBuddyMockMaker는 static mock 생성을 지원해주지 않고 Inline MockMaker가 Instrumentation API를 기반으로 static mock을 생성해준다고 한다.
mockito-core 대신 mockito-inline을 사용하라고 이야기하고 있다. (안드로이드는 지원하지 않는다고 한다.)
Instrumentation API는 JVM에 로드된 클래스의 바이트 코드를 동적으로 추가/수정할 수 있게해주는 API라고 한다.
MockMaker 인터페이스의 createStaticMock() 메서드를 확인해보면 default로 위에서 봤던 예외가 발생하도록 되어있다.
default <T> StaticMockControl<T> createStaticMock(Class<T> type, MockCreationSettings<T> settings, MockHandler handler) {
throw new MockitoException(StringUtil.join(new Object[]{"The used MockMaker " + this.getClass().getSimpleName() + " does not support the creation of static mocks", "", "Mockito's inline mock maker supports static mocks based on the Instrumentation API.", "You can simply enable this mock mode, by placing the 'mockito-inline' artifact where you are currently using 'mockito-core'.", "Note that Mockito's inline mock maker is not supported on Android."}));
}
❓ 나는 mockito-core를 추가한 적이 없는데?
mockito-core는 spring-boot-starter-test에 포함되어있기 때문에 우리는 별도로 mockito를 gradle에 추가하지 않고도 사용할 수 있었다. gradle의 Dependencies를 확인해보면 알 수 있다.
해결 방법
예외 메세지에서 알려준대로 mockito-inline을 의존성에 추가해보자
build.gradle
의존성을 추가할 때 주의할 점은 mockedStatic은 3.4.0 버전에서부터 제공된 기능이기 때문에 mockito-inline 3.4.0 이상의 버전을 추가해야 한다.
dependencies {
...
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation 'org.mockito:mockito-inline' // 추가
}
InlineDelegateByteBuddyMockMaker
InlineDelegateByteBuddyMockMaker 클래스를 보면 예외를 발생시켰던 MockMaker의 createStaticMock() 메서드를 오버라이드하고 있다.
@SuppressSignatureCheck
class InlineDelegateByteBuddyMockMaker implements ClassCreatingMockMaker, InlineMockMaker, Instantiator {
...
public <T> MockMaker.StaticMockControl<T> createStaticMock(Class<T> type, MockCreationSettings<T> settings, MockHandler handler) {
if (type == ConcurrentHashMap.class) {
throw new MockitoException("It is not possible to mock static methods of ConcurrentHashMap to avoid infinitive loops within Mockito's implementation of static mock handling");
} else if (type != Thread.class && type != System.class && type != Arrays.class && !ClassLoader.class.isAssignableFrom(type)) {
this.bytecodeGenerator.mockClassStatic(type);
Map<Class<?>, MockMethodInterceptor> interceptors = (Map)this.mockedStatics.get();
if (interceptors == null) {
interceptors = new WeakHashMap();
this.mockedStatics.set(interceptors);
}
return new InlineStaticMockControl(type, (Map)interceptors, settings, handler);
} else {
throw new MockitoException("It is not possible to mock static methods of " + type.getName() + " to avoid interfering with class loading what leads to infinite loops");
}
}
...
}
Test Code
이제 테스트를 실행해보면
@WebMvcTest(LoginManagerController.class)
class LoginManagerControllerTest {
@Autowired
private MockMvc mockMvc;
private final String BASE_URL = "/am/manager";
@Test
@DisplayName("로그인 페이지로 이동")
void login1() throws Exception {
// given
try(MockedStatic<SessionsUser> sessionUser = Mockito.mockStatic(SessionsUser.class)) {
given(SessionsUser.isLoginUser(Mockito.any(MockHttpSession.class))).willReturn(false);
// when & then
mockMvc.perform(get(BASE_URL + "/login")
.session(new MockHttpSession()))
.andExpect(status().isOk())
.andExpect(view().name("/manager/loginForm"))
.andDo(print());
}
}
}
추가
위에서는 하나의 테스트 코드만 작성했기 때문에 try-resource-with문으로 작성해도 문제가 없었지만, 테스트 케이스가 많아질수록 모든 테스트마다 위 코드를 작성해줘야 했다. (굉장히 번거롭고 짜증났다.)
그래서 @BeforeEach, @AfterEach를 사용해서 직접 리소스를 해제하는 방식으로 리팩토링해주었다.
@WebMvcTest(LoginManagerController.class)
class LoginManagerControllerTest {
@Autowired
private MockMvc mockMvc;
@MockBean
private LoginManagerService loginManagerService;
private MockedStatic<SessionsUser> sessionUser;
private final String BASE_URL = "/am/manager";
@BeforeEach
void beforeEach() {
sessionUser = Mockito.mockStatic(SessionsUser.class);
}
@AfterEach
void afterEach() {
sessionUser.close();
}
@Test
@DisplayName("로그인 페이지로 이동")
void login1() throws Exception {
// given
sessionUser.when(() -> SessionsUser.isLoginUser(Mockito.any(MockHttpSession.class))).thenReturn(false);
// when & then
mockMvc.perform(get(BASE_URL + "/login")
.session(new MockHttpSession()))
.andExpect(status().isOk())
.andExpect(view().name("/manager/loginForm"));
}
...
}
참조
https://www.baeldung.com/java-instrumentation
https://www.baeldung.com/mockito-mock-static-methods
'Spring' 카테고리의 다른 글
Spring Interceptor - 인터셉터로 로그인 체크하기 (0) | 2023.09.12 |
---|---|
Spring - 회원 팔로우 기능 구현 (1) | 2023.06.08 |
Spring - Spring Boot 초기 데이터 설정 (data.sql) (0) | 2023.05.29 |
Spring - 통합 테스트에서 S3 Mock 객체로 S3 자원 아끼기 (2) | 2023.05.24 |
Spring - 좋은 단위 테스트를 만드는 방법(JUnit) (2) | 2023.04.22 |