Mockito는 mocking framework 중 하나이다. Java 진영에서 가장 많이 쓰이는 목 프레임워크로 스프링이나 안드로이드 개발하는 분들이라면 들어봤을 수 있다. 간단하게 Mock이 무엇인지 Mockito가 무엇인지 조금만 다뤄보자. (참고로 여기서는 JUnit과 관련해서는 자세히 다루지 않는다.)
Mock? Mockito?
목(Mock)이라는 단어는 개발 분야가 아니더라도 사용하는 곳이 많다. 실제 구현보다 가상 혹은 모의의 형태가 필요할 때가 있다. 간단한 테스트나 프로토타입을 공유할 때인데 이럴 때 Mock을 사용한다. 개발에서는 실제 객체를 만드는데 부담이 될 때 사용하는 모의 객체를 Mock이라고 한다. 테스트를 할 때 주로 사용되며 비용과 시간, 의존성 이슈에서 많은 이점을 가진다.
이런 Mock 객체를 쉽게 만들고 관리, 검증을 할 수 있는 프레임워크가 Mockito이다. 테스트할 때마다 데이터베이스나 외부 API등이 정확한 동작을 하도록 요구한다면 테스트 작성이 어려울 것이다. Mockito로 필요한 시나리오를 미리 세팅하는 방식으로 정확한 동작을 위한 세팅을 생략하여 비교적 쉽게 테스트가 가능하다. 여기서 언급하는 시나리오는 뒤에서 설명하겠다.
만약 스프링부트를 사용한다면
이미 Mockito가 내장되어 있어서 편하게 사용할 수 있다. 스프링부트를 코틀린으로 사용한다고 해도 사용은 할 수 있다. 다만 불편한게 몇가지 있는데 이런 점을 편하게 사용하려면 코틀린용 Mockito를 사용하면 된다.
// build.gradle.kts
// 코틀린용 Mockito. 없어도 Mockito를 사용할 수 있다
testImplementation("org.mockito.kotlin:mockito-kotlin:4.0.0")
// 아래 의존성 추가가 되어 있다면 Mockito는 문제없이 사용 가능하다
testImplementation("org.springframework.boot:spring-boot-starter-test")
테스트 코드와 Mock 어노테이션
만약 아래와 같은 서비스가 있다고 하자. 이 서비스를 테스트하고 싶으면 무엇을 고려해야 할까? 먼저 이 서비스는 2개의 의존성 주입을 받는다. userRepository와 authApiClient이다. 만약 그냥 테스트를 한다면 이 두개의 실제 객체가 필요하다. 만약 이 객체들도 의존성 주입을 받고 있다면 추가로 객체를 가지고 와야한다. 이렇게 추가하기 시작하면 테스트하려는데 배보다 배꼽이 커지는 상황이 생길 수 있다.
@Service
@Transactional(readOnly = true)
class UserSignInProvider(
private val userRepository: UserRepository,
private val authApiClient: AuthApiClient
) {
fun refreshToken(refreshToken: String): ResponseEntity<UserSignModel> {
val response = authApiClient.validateRefreshToken(refreshToken)
/**
* 생략
*/
val token = Token.from(
authApiClient.refreshToken(authModel).body?.payload
)
val model = UserSignModel(
id = userId,
email = user.email,
token = token
)
return ResponseEntity.ok().body(model)
}
}
그렇게 하지 않고 의존성 주입이 되는 두개의 객체를 Mock으로 만들면 간단해진다. 그리고 Mock 객체화된 객체를 UserSignInProvider에 주입해주면 의존성 이슈는 해결된다. 여러 방법이 있지만 여기서는 가장 간단한 방법인 어노테이션을 사용한 방식을 사용한다.
@ExtendWith(MockitoExtension::class)
internal class UserSignInProviderTest {
@Mock
lateinit var authApiClient: AuthApiClient
@Mock
lateinit var userRepository: UserRepository
@InjectMocks
lateinit var userSignInProvider: UserSignInProvider
}
위 코드에서 확인해야 할 사항은 아래와 같다.
- 테스트 내에서 Mockito 기능을 사용할 것이기에 MockitoExtension을 추가한다.
- 의존성 주입되어야 할 객체를 @Mock으로 정의한다. 물론 이 객체 자체도 사용가능하다.
- 의존성 주입받아야 할 객체는 @InjectMocks으로 정의한다.
이렇게 정의해두면 마치 Bean으로 등록한 것처럼 사용이 가능하다. 이제 본격적으로 테스트 코드를 작성해보자.
Stubbing과 Matcher
우선 Stubbing을 알아보자. stub의 사전적 의미를 통해 왜 stubbing으로 부르는지 이해해보는 것도 좋은 방법이다.
stub (출처 : 네이버 사전)
- (쓰다 남은 물건의) 토막, (담배) 꽁초; 몽당연필
- (표수표 등에서 한 쪽을 떼어 주고) 남은 부분
stubbing
- 메소드의 행동을 미리 정의하는 것
- Mock 객체 일부(stub)의 기능(method)의 결과를 미리 세팅하는 것
여기서 중요한 것은 '어떤 행위를 정의할 것인가'와 '어떤 결과로 보여줄 것인가'이다. 이 두가지만 정의하면 객체가 Mock으로만 존재해도 테스트가 가능해진다. 테스트는 기본적으로 given/when/then 형태로 작성했고 아래는 given에 해당하는 코드로 Stubbing이 작성된 것이다.
@Test
fun `리프레시 토큰 갱신 요청`() {
// given
whenever(authApiClient.validateRefreshToken(anyString())).then {
println(">>> authApiClient.validateRefreshToken")
return@then RefreshTokenValidateResponse(
payload = RefreshTokenValidateResponse.Payload(anyLong())
)
}
}
- Given – When – Then 패턴 (출처 : 라떼쉬폰의 코드 베이커리)
- Given: 테스트를 위한 준비 과정입니다. 변수를 선언하고, Mock 객체에 대한 정의도 함께 작성
- When: 테스트를 실행하는 과정입니다. 테스트하고자 하는 내용을 작성
- Then: 테스트를 검증하는 과정입니다. 예상한 값과 결괏값이 일치하는 지 확인
- Mockito에서 Stubbing 행위를 정의할 때, when 메소드를 사용한다. 그런데 코틀린에서는 이미 when 키워드를 사용하기에 Mockito의 when을 그대로 사용할 수 없다.
- 백틱 기호를 when 키워드의 양쪽에 사용하거나 (`when`)
- 코틀린용 Mockito에서 whenever을 사용하면 된다.
- whenever 내부에는 정의해야 할 메소드를 지정한다.
- then 메소드에서는 결과를 정의해준다. 여기서는 로그를 먼저 출력해주고 응답값을 만들어준다.
여기서 의문이 생긴다. 위 샘플 코드에서 등장하는 anyString과 anyLong은 무엇인가? Argument Matchers라고 부른다. 파라미터에 들어가는 값에 임의의 값을 지정해서 특정값에 종속적이지 않은 테스트를 할 수 있다. 여기서 anyString을 사용하면 String 타입을 가지는 임의의 값이 된다. 다른 Stub도 확인해보자.
whenever(authApiClient.refreshToken(any())).then {
println(">>> authApiClient.refreshToken")
return@then ResponseEntity.ok().body(
IssueTokenResponse(
payload = IssueTokenResponse.Payload(
"anyString",
99,
"anyString"
)
)
)
}
val user = User(
id = 999,
email = "anyString",
name = "anyString"
)
whenever(userRepository.findById(anyLong())).then {
println(">>> userRepository.findById")
Optional.of(user)
}
[미해결 사건] then 메소드 내부에서 any() 사용 불가?
validateRefreshToken 메소드를 정의한 then 내부에서 anyLong을 사용하고 있다. 그런데 refreshToken 메소드를 정의한 then 내부에는 IssueTokenResponse.Payload에서는 Argument Matchers를 사용하는 대신 "anyString"이나 99와 같이 특정값을 지정했다. 만약 하나라도 Argument Matchers를 사용하면 아래와 같은 에러를 볼 수 있다.
whenever(authApiClient.refreshToken(any())).then {
println(">>> authApiClient.refreshToken")
return@then ResponseEntity.ok().body(
IssueTokenResponse(
payload = IssueTokenResponse.Payload(
anyString(), // <-- 변경점
99,
"anyString"
)
)
)
}
// Error
org.mockito.exceptions.misusing.InvalidUseOfMatchersException:
Misplaced or misused argument matcher detected here:
-> at (package명 생략).UserSignInProviderTest.리프레시_토큰_갱신_요청$lambda-1(UserSignInProviderTest.kt:53)
-> at (package명 생략).UserSignInProviderTest.리프레시_토큰_갱신_요청$lambda-1(UserSignInProviderTest.kt:54)
-> at (package명 생략).UserSignInProviderTest.리프레시_토큰_갱신_요청$lambda-1(UserSignInProviderTest.kt:55)
You cannot use argument matchers outside of verification or stubbing.
Examples of correct usage of argument matchers:
when(mock.get(anyInt())).thenReturn(null);
doThrow(new RuntimeException()).when(mock).someVoidMethod(anyObject());
verify(mock).someMethod(contains("foo"))
This message may appear after an NullPointerException if the last matcher is returning an object
like any() but the stubbed method signature expect a primitive argument, in this case,
use primitive alternatives.
when(mock.get(any())); // bad use, will raise NPE
when(mock.get(anyInt())); // correct usage use
Also, this error might show up because you use argument matchers with methods that cannot be mocked.
Following methods *cannot* be stubbed/verified: final/private/equals()/hashCode().
Mocking methods declared on non-public parent classes is not supported.
이는 Argument Matchers를 사용할 경우에 다른 매개변수에도 동일하게 Argument Matchers를 사용해야 한다는 의미로 해석했다. 그래서 모든 변수를 Argument Matchers로 변경했다. 하지만 위와 동일한 에러가 나타났다.
whenever(authApiClient.refreshToken(any())).then {
println(">>> authApiClient.refreshToken")
return@then ResponseEntity.ok().body(
IssueTokenResponse(
payload = IssueTokenResponse.Payload(
anyString(),
anyLong(),
anyString()
)
)
)
}
// Error (위와 동일)
org.mockito.exceptions.misusing.InvalidUseOfMatchersException:
Misplaced or misused argument matcher detected here:
// 이하 생략
여기서부터는 추정이다. 제대로된 자료를 찾을 수 없어서 다음과 같은 추정을 했다.
기본적으로 then 메소드 내에서는 Argument Matchers를 사용할 수 없는게 아닐까. 뒤에서 설명하겠지만 verify로 검증을 할 때 특정 응답값이 지정되지 않으면 검증 진행이 불가하다. 불특정값으로 검증한다는 것이 꽤 무책임한 절차일 수 있기 때문이다. 게다가 이를 확인하려면 입력할 수 있는 모든 값을 실제로 입력해봐야 한다. 만약 임의의 값으로 테스트를 한다고 하면, 에러가 나타날 값을 피해서 입력될 수 있다. 그런 점에서 then 메소드에 들어갈 값은 명확한 값이 지정되어야 하는게 아닐까 추정했다.
그렇다면 첫번째 when 구문은 어떤 경우일까? Long 타입이기만 하면 문제가 없다고 판단했거나 어떤 값이 들어와도 이슈가 생길 수 없다고 판단했을 때라고 봐야하는데 그걸 결정하는 기준을 알 수 없다.
물론 이 추정의 전제 자체부터 잘못됬을 수 있다. 하지만 첫번째 whenever 구문을 제외하고 필자가 테스트해본 온갖 stub에서는 then에 Argument Matchers를 사용할 수 없었다. 혹시나 이 이슈에 대해 조금이라도 알고 계신 분이 있다면 댓글로 가르침을 나눠주길 간절히 바란다.
테스트 실행 및 검증
when 구문에서 실행을 하고 then에서 검증을 한다. verify는 검증 메소드이며 times를 사용해서 몇번 호출하는지 검증을 한다. 최소 호출 횟수나 호출해서는 안될 메소드가 호출되었는지 등의 조건도 가능하다.
// when
userSignInProvider.refreshToken(anyString())
// then
verify(userRepository, times(1)).findById(anyLong())
verify(authApiClient, times(1)).validateRefreshToken(anyString())
verify(authApiClient, times(1)).refreshToken(any())
이렇게 기본적인 Mockito 사용 사례를 공유해보았다. 아쉽게도 Argument Matchers 관련 이슈가 미스테리로 남았지만 이 정도 수준에서도 원하는 테스트를 작성할 수 있다. 테스트 코드 작성은 대부분 현업에서 필수가 되고 있다. 테스트에 대한 접근 방식부터 각종 도구에 대해 꾸준히 학습해보도록 하겠다.
[참고자료]
- 스터빙 (Stubbing) (OngoingStubbing, Stubber)
- Mockito 사용하기
- Mockito 사용하기1
- Mockito @Mock @MockBean @Spy @SpyBean 차이점 (추천👍)
🍸
'Spring' 카테고리의 다른 글
Event 처리는 비동기가 아니다? (2) | 2022.08.17 |
---|---|
스프링부트에서 쿠키(Cookie)를 구워보자 (0) | 2022.07.28 |
data.sql이 동작하지 않을 때, 의심해봐야 할 것 (0) | 2022.07.13 |
Google Oauth - Token 획득하기 (Signup/Signin용) (0) | 2022.03.30 |
코틀린 환경에서 Async 어노테이션 테스트하기 (0) | 2022.02.08 |
Comment