단위테스트에서 사용되는 Static 메소드를 Mock으로 주입할때 발생하는 문제 해결 방법
에러 발생 코드
일단 내가 단위 테스트를 진행하고 싶은 서비스는 아래와 같다.
사용자에게 받은 refresh token을 검증하고 유효한 토큰이면 TokenUtils의 static 메서드를 사용해 accessToken을 재발행해주는 로직이다.
import lombok.RequiredArgsConstructor;
import org.springframework.data.util.Pair;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
@Transactional(readOnly = true)
@RequiredArgsConstructor
@Service
public class JwtService implements JwtUseCase {
private final MemberPort memberPort;
private final JwtPort jwtPort;
/**
* memberId, refresh token으로 access token 재발행
*/
@Override
public String republishAccessToken(Jwt jwt) {
// 다른 기존 로직 ...
TokenMemberInfoDto tokenMemberInfoDto = TokenMemberInfoDto.of(member.getId(), member.getEmail(), null, member.getNickname(), member.getStatus(), member.getRoleType());
String accessToken = TokenUtils.generateAccessToken(tokenMemberInfoDto);
return accessToken;
}
}
TokenUtils.generateAccessToken 메서드는 아래와 같이 static 메서드다.
/**
* 사용자 pk를 기준으로 Access Token을 발급하여 반환해 준다.
*/public static String generateAccessToken(TokenMemberInfoDto tokenMemberInfoDto) {
Pair<String, LocalDateTime> jwtPair = generateToken(tokenMemberInfoDto, ACCESS_TOKEN_EXPIRATION_SECONDS, ACCESS_TOKEN_TYPE);
return jwtPair.getFirst();
}
여기서 republishAccessToken 메서드 테스트하려고 아래처럼 service 테스트코드를 작성했다.
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.time.LocalDateTime;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
@DisplayName("[단위] JWT Service 테스트")
class JwtServiceTest {
@InjectMocks
private JwtService sut;
@Mock
private JwtPort jwtPort;
@Mock
private MemberPort memberPort;
@DisplayName("[happy] memberId, refresh token으로 jwt 가져와서 만료기한 안지났으면 access token 리턴 성공")
@Test
void republishAccessTokenSuccess() {
//given
// 다른 로직 테스트 진행 ..
when(TokenUtils.generateAccessToken(any())).thenReturn("successfully-generate-access-token");
//when
String accessToken = sut.republishAccessToken(jwt);
//then
assertThat(accessToken).isNotNull();
assertThat(accessToken).isEqualTo("successfully-generate-access-token");
}
public Jwt createJwtUsingValidRefresh () {
// Jwt 도메인 생성 코드..
}
public Jwt createJwtUsingInvalidRefresh () {
// Jwt 도메인 생성 코드..
}
private Member createMember() {
// Member 도메인 생성 코드..
}
}
그러나 아래와 같은 에러가 발생했다.
java.lang.NullPointerException: Cannot invoke "com.recipia.member.config.dto.TokenMemberInfoDto.id()" because "tokenMemberInfoDto" is null
at com.recipia.member.config.jwt.TokenUtils.createClaims(TokenUtils.java:111)
at com.recipia.member.config.jwt.TokenUtils.generateToken(TokenUtils.java:74)
at com.recipia.member.config.jwt.TokenUtils.generateAccessToken(TokenUtils.java:51)
at com.recipia.member.application.service.JwtServiceTest.republishAccessTokenSuccess(JwtServiceTest.java:46)
at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:77)
에러 코드 분석
위 단위 테스트 코드에서 java.lang.NullPointerException 에러가 발생하는 이유는 TokenUtils.generateAccessToken 메서드를 호출할 때 TokenMemberInfoDto 객체가 null로 전달되고 있기 때문이다.
이 문제를 해결하려면 TokenMemberInfoDto 객체를 제대로 생성하여 generateAccessToken 메서드에 전달해야 할 것 같다.
일단 문제가 발생하는 코드 부분은 다음과 같다:
when(TokenUtils.generateAccessToken(any())).thenReturn("successfully-generate-access-token");
이 코드에서 any() 메서드는 Mockito가 TokenUtils.generateAccessToken 메서드에 대한 모든 호출을 가로채고, "successfully-generate-access-token" 문자열을 반환하도록 설정한다.
하지만 실제 TokenUtils.generateAccessToken 메서드 내부에서 TokenMemberInfoDto 객체의 속성에 접근하려 할 때, null 객체로 인해 NullPointerException이 발생했다.
이 문제를 해결하기 위해서 여러 가지 해결방법을 사용했었다.
해결 시도 1 - any() 제거 후 실제 객체 주입
generateAccessToken 메서드의 인자로 any()가 아니라 실제 인자인 TokemMemberInfoDto를 만들어줘서 전달해 줬다.
아래는 실제 TokenMemberInfoDto를 생성하는 코드로 수정된 코드다.
TokenMemberInfoDto tokenMemberInfoDto = TokenMemberInfoDto.of(member.getId(), member.getEmail(), member.getPassword(), member.getNickname(), member.getStatus(), member.getRoleType());
when(TokenUtils.generateAccessToken(tokenMemberInfoDto)).thenReturn("successfully-generate-access-token");
그러나 이렇게 해도 해결되지 않았다.
해결 시도 2 - 실제 객체 말고 mock 객체로 파라미터 수정
혹시 when().thenReturn() 안에서 실제 객체를 사용해서 그런가 해서 TokenMemberInfoDto 객체를 mock 객체로 바꿔주고 코드를 진행했다.
아래는 TokenMemberInfoDto를 Mock객체로 만들어주고 when().thenReturn() 메서드에 인자로 넣어준 코드다.
TokenMemberInfoDto tokenMemberInfoDto = mock(TokenMemberInfoDto.class);
when(tokenMemberInfoDto.id()).thenReturn(1L);
when(tokenMemberInfoDto.email()).thenReturn("example@email.com");
when(tokenMemberInfoDto.nickname()).thenReturn("nickname");
when(tokenMemberInfoDto.memberStatus()).thenReturn(MemberStatus.ACTIVE);
when(tokenMemberInfoDto.roleType()).thenReturn(RoleType.MEMBER);
when(TokenUtils.generateAccessToken(tokenMemberInfoDto)).thenReturn("successfully-generate-access-token");
이렇게 하니까 실제로 내가 thenReturn에 설정한 반환값이 아니라 실제 TokenUtils.generateAccessToken 메서드에서 반환하는 실제 accessToken이 반환되었다.
아래는 에러 로그다.
OpenJDK 64-Bit Server VM warning: Sharing is only supported for boot loader classes because bootstrap classpath has been appended
12:44:29.295 [main] INFO com.recipia.member.config.jwt.TokenUtils -- generateJwtToken - Token generated for user email: example@email.com
12:44:29.319 [main] INFO com.recipia.member.config.jwt.TokenUtils -- generateJwtToken - Token generated for user email: test1@example.com
org.opentest4j.AssertionFailedError:
expected: "successfully-generate-access-token"
but was: "eyJyZWdEYXRlIjoxNzAzNDc1ODY5MzE5LCJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ0ZXN0MUBleGFtcGxlLmNvbSIsInJvbGUiOiJNRU1CRVIiLCJuaWNrbmFtZSI6Ik5pY2tuYW1lMSIsImlzcyI6IlJlY2lwaWEiLCJ0eXBlIjoiYWNjZXNzIiwiZXhwIjoxNzAzNDc3NjY5LCJtZW1iZXJJZCI6Nn0.w9ZEaTvmyEgFTVbQ-R5aGQqFr44XWGOgusHYELZ5GLM"
Expected :"successfully-generate-access-token"
Actual :"eyJyZWdEYXRlIjoxNzAzNDc1ODY5MzE5LCJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ0ZXN0MUBleGFtcGxlLmNvbSIsInJvbGUiOiJNRU1CRVIiLCJuaWNrbmFtZSI6Ik5pY2tuYW1lMSIsImlzcyI6IlJlY2lwaWEiLCJ0eXBlIjoiYWNjZXNzIiwiZXhwIjoxNzAzNDc3NjY5LCJtZW1iZXJJZCI6Nn0.w9ZEaTvm ...
찾아보니까 static 메서드는 Mockito의 기본 기능으로는 모의할 수 없다고 한다,,
그래서 실제 TokenUtils.generateAccessToken 메서드가 호출되고 있고, 실제 반환 값을 받게 되는 것이다. 이는 예상된 thenReturn 값 대신 실제 메서드 실행 결과를 받는 원인이 된다.
해결 시도 3 - MockedStatic 이용해서 static 메소드 테스트
Mockito에서 static 메서드를 모의하기 위해서는 Mockito.mockStatic 메서드를 사용하면 된다고 한다. 그래서 이 메서드를 사용해 MockedStatic 객체를 생성하고, 이 객체를 통해 static 메서드를 테스트해 보기로 했다.
다음 코드는 MockedStatic를 사용하여 TokenUtils.generateAccessToken 메서드를 테스트한다. 이 방법을 사용하면 실제 메서드 호출 대신 지정된 반환 값을 받을 수 있으며, static 메서드의 모의 테스트를 수행할 수 있다고 한다.
@DisplayName("[happy] memberId, refresh token으로 jwt 가져와서 만료기한 안지났으면 access token 리턴 성공")
@Test
void republishAccessTokenSuccess() {
// MockedStatic 객체 생성
try (MockedStatic<TokenUtils> mockedTokenUtils = Mockito.mockStatic(TokenUtils.class)) {
// given
// 다른 로직 테스트 진행 ..
TokenMemberInfoDto tokenMemberInfoDto = TokenMemberInfoDto.of(1L, "email", "pass", "nickname", MemberStatus.ACTIVE, RoleType.MEMBER);
mockedTokenUtils.when(() -> TokenUtils.generateAccessToken(tokenMemberInfoDto)).thenReturn("successfully-generate-access-token");
// when
String accessToken = sut.republishAccessToken(jwt);
// then
assertThat(accessToken).isNotNull();
assertThat(accessToken).isEqualTo("successfully-generate-access-token");
}
}
그러나!!!! 여전히 아래와 같은 에러가 떴다.
OpenJDK 64-Bit Server VM warning: Sharing is only supported for boot loader classes because bootstrap classpath has been appended
java.lang.AssertionError:
Expecting actual not to be null
에러 로그를 분석하면 테스트 실행 중 발생하는 AssertionError: Expecting actual not to be null 에러는 sut.republishAccessToken(jwt) 호출 결과로 null이 반환되었음을 나타낸다.
이는 TokenUtils.generateAccessToken 메서드가 모의된 반환값 "successfully-generate-access-token"을 제대로 반환하지 않았음을 의미한다,,,
혹시 해결 방법을 알고 있다면 댓글 부탁드립니다,,
해결 시도 4 - generateAccessToken이 service 메서드에서 호출되는지 확인하는 테스트로 수정
TokenUtils.generateAccessToken()이 정적 메서드인 경우, Mockito 라이브러리를 사용하여 직접적으로 호출 여부를 검증하는 것은 어렵다. Mockito는 기본적으로 정적 메서드에 대한 검증을 지원하지 않기 때문이다. 하지만, Mockito의 mockStatic 메서드를 사용하는 방법으로 해결할 수 있다. 이 기능은 Mockito의 추가 모듈인 mockito-inline에서 제공한다.
다음은 TokenUtils.generateAccessToken() 호출을 검증하는 방법을 보여준다.
1. mockito-inline 의존성 추가
프로젝트의 pom.xml 또는 build.gradle 파일에 mockito-inline 의존성을 추가한다.
(버전은 각자 프로젝트 환경에 맞는 버전을 찾아보길 바란다.)
testImplementation 'org.mockito:mockito-inline:5.2.0'
2. mockStatic 사용
TokenUtils 클래스에 대한 정적 메소드 호출을 모의 처리하기 위해 mockStatic을 사용한다.
예시 코드는 다음과 같다:
@ExtendWith(MockitoExtension.class)
class JwtServiceTest {
// 기존 코드 ...
@Test
void republishAccessTokenSuccess() {
// given
// 기존 코드 ...
try (MockedStatic<TokenUtils> mockedStatic = mockStatic(TokenUtils.class)) {
mockedStatic.when(() -> TokenUtils.generateAccessToken(any())).thenReturn("successfully-generate-access-token");
// when
String accessToken = sut.republishAccessToken(jwt);
// then
mockedStatic.verify(() -> TokenUtils.generateAccessToken(any()));
assertThat(accessToken).isEqualTo("successfully-generate-access-token");
}
}
// 기존 메소드들 ...
}
이 코드는 TokenUtils.generateAccessToken()이 호출되었는지를 검증하고, 해당 메서드가 호출되었을 때 "successfully-generate-access-token" 문자열을 반환하도록 설정한다. 이 방법을 사용하면 정적 메서드의 호출 여부를 검증할 수 있다.
실행한 결과, 성공한 모습을 확인할 수 있다.
MockedStatic은 Mockito의 mockito-inline 모듈에서 제공하는 기능으로, Java의 정적 메서드를 모의(mock) 처리하기 위해 사용된다. 기존의 Mockito는 인스턴스 메소드에 대해서만 모의 처리를 지원했지만, MockedStatic을 사용하면 정적 메소드에 대해서도 유사한 방식으로 테스트를 진행할 수 있다.
mockStatic 메소드 사용
MockedStatic<T> 타입의 객체를 생성하기 위해 mockStatic(Class<T> classToMock) 메서드를 사용한다. 이때 T는 모의 처리하고자 하는 클래스이다. 예를 들어, TokenUtils 클래스의 정적 메소드를 모의 처리하려면 mockStatic(TokenUtils.class)를 호출한다.
try-with-resource 구문
이 구문은 MockedStatic 객체의 범위를 관리한다. MockedStatic 객체는 AutoCloseable을 구현하므로, try-with-resources 구문을 사용하여 자동으로 리소스를 해제할 수 있다. try 블록 내에서 정의된 모의 설정과 검증은 블록을 벗어나면 자동으로 해제되어, 이후의 테스트에 영향을 주지 않는다.
when과 thenReturn 사용
MockedStatic 객체를 사용하여 특정 조건에서 특정 결과를 반환하도록 설정할 수 있다. 예를 들어, TokenUtils.generateAccessToken(any())메서드가 호출될 때 "successfully-generate-access-token"을 반환하도록 설정할 수 있다.
verify 메소드
특정 정적 메소드가 호출되었는지 검증할 때는 MockedStatic 객체의 verify메서드를 사용한다. 이를 통해 특정 조건에서 메소드가 호출되었는지 확인할 수 있다.
💡 when과 verify에서 람다 사용
when과 verify에서 "그냥 바로 메소드를 적는" 경우는 인스턴스 메서드에 대한 Mockito의 모의 처리에서 일반적으로 볼 수 있다.
하지만, 정적 메서드의 경우에는 이러한 접근 방식이 직접적으로 사용될 수 없다. 여기서 () -> 구문은 Java의 람다 표현식을 사용하는 것으로, 이는 정적 메소드 호출을 나타내는 방식 중 하나이다.
Mockito에서 정적 메소드의 모의 처리를 위해 람다 표현식을 사용하는 이유는 정적 메소드 호출을 하나의 '행동'으로 캡슐화하기 위해서이다. Mockito의 when 또는 verify메서드는 특정 메소드 호출을 인자로 받아야 하는데, 정적 메서드의 경우 이를 직접 전달할 수 없으므로, 람다 표현식을 사용하여 해당 호출을 나타내게 된다.
인스턴스 메서드(기존 방식)
when(mockObject.someMethod()).thenReturn("value");
verify(mockObject).someMethod();
정적 메소드 (람다 표현식 사용)
try (MockedStatic<SomeClass> mockedStatic = mockStatic(SomeClass.class)) {
mockedStatic.when(() -> SomeClass.someStaticMethod()).thenReturn("value");
mockedStatic.verify(() -> SomeClass.someStaticMethod());
}
정적 메서드를 모의 처리할 때는 람다 표현식을 사용하여 메소드 호출을 캡슐화한다.
람다 표현식의 사용은 정적 메서드에 대한 Mockito의 모의 처리를 가능하게 하며, 이를 통해 테스트 코드의 유연성과 가독성을 높일 수 있다.
마무리
static 메서드를 테스트하는 과정이 너무 험난했다... 결국 service에서는 실제로 내가 의도한 값이 리턴되는지는 확인하지 않고 service 내에서 정적 메서드가 호출 되었는지 확인하는거로 테스트를 마무리했다.
실제로 TokenUtils의 메소드가 제대로 동작하는지 확인하려면 service의 통합 테스트로 테스트를 해보거나, TokenUtilsTest 클래스를 따로 만들어서 거기서 확인해야 할 것 같다.
지금 단위 테스트는 service 외에 모든 의존성을 Mock으로 주입해야 하기 때문에 이렇게 먼 길을 돌아온 것 같다. 이렇게 또 한 번 테스트 코드에 대해서 더 깊은 이해가 된 것 같다,,,,
🔻 단위 테스트와 통합 테스트의 차이가 궁금하다면? 🔻
테스트 전략에서 단위 테스트와 통합 테스트의 차이점과 의존성 관리
📣 이 글은 내가 소속된 Team Chillwave에서 진행한 사이드 프로젝트에 적용한 내용을 다시 공부하고 정리한 것이다.
다른 팀원인 "개발자의 서랍" 님의 블로그도 방문하면 도움이 될 것 같다 :)
'Spring > Spring 트러블 슈팅' 카테고리의 다른 글
QueryDSL에서 Projections.constructor 사용해서 SQL 함수 사용하기 (0) | 2024.02.01 |
---|---|
스칼라 서브쿼리(Scalar Subquery)에서 Limit절 오류 해결 (0) | 2024.01.25 |
QueryDSL에서 NPE 해결하기 - 서브쿼리와 외부 조인의 활용 (2) | 2024.01.16 |
스프링 시큐리티: 커스텀 에러 처리 실패 원인 분석 및 해결 방법 (0) | 2023.12.23 |
로그인 실패 문제 - BCryptPasswordEncoder의 Salt 값과 Matches 메소드 이해하기 (2) | 2023.12.21 |