2024. 4. 30. 11:53ㆍ스프링 (Spring)/스프링 팁 (Spring Tip)
Mockito
Mock이란 건 진짜를 흉내내는 가짜를 의미합니다.
가령 아래 코드가 있다고 하면
repository.save(entity)
실행하고 싶으면 repository 구현부가 존재해야겠죠.
하지만 테스트 단계에서 실제 데이터베이스를 연결해서 사용하기란 참 난감합니다.
로컬의 데이터베이스를 사용한다고 해도, 테스트 자체가 실제 데이터베이스에 강하게 결합되어 있다는 건 여전합니다.
따라서 저 구현부를 그럴듯하게 흉내내주는 것이 필요하고, 이때 Mock 개념을 사용하는 것입니다.
repository.save 함수에 어떤 걸 인자로 제공하면, 이걸 무조건 리턴하도록 하세요.
이렇게 코드에서 직접 주고 받고하는 것들을 작성하므로 다른 모듈에 의존하지 않는다는 장점이 있습니다.
Mockito는 mocking 프레임워크로, 쉽게 Mock 객체를 생성하고 - Mock 행위를 정의할 수 있도록 도와주는 API가 있습니다.
다른 이야기지만, 실무에서는 MockK를 사용하긴 했어서 이것도 추천합니다. 코틀린 기반이라 더 쉽게 사용할 수 있습니다.
이제와서 Mockito를 사용하는 이유는 Spring에 내장되어 있기 때문이겠네요 ㅎ
RepositoryMock
편리하게 Mock을 사용할 수 있도록 Repository을 만들어볼 겁니다.
먼저 실제 Repository 관계를 그려보면 아래와 같습니다.
Repository에 대응되는 Mock을 만듭니다.
// RepositoryMock.kt
interface RepositoryMock<T : Entity, ID> {
val repository: Repository<T, ID>
val entities: Collection<T>
}
먼저 당연하게 repository가 필요합니다. 이 자체도 mock으로 만들어 쓸 겁니다.
entities는 save, find 등 엔티티를 리턴할 때 쓸 대용으로 만듭니다.
개념적으로는 테이블에 적재된 데이터라고 생각해도 됩니다.
이를 상속해서 CrudRepository에 대한 Mock을 만들면 아래와 같습니다.
abstract class CrudRepositoryMock<T : Entity>(
override val repository: CrudRepository<T, Long>,
override val entities: Collection<T>
) :
RepositoryMock<T, Long>
개인적으로는 ID를 Long으로 써서 이렇게 만들었는데, 본인 입맛에 따라 바꿔줘도 됩니다.
예를 들어 mongo에서는 String 자료형을 사용하니까, 나중에 별도로 구분할 필요는 있겠네요.
Mockito for Kotlin
자바 진영은 그냥 사용하면 됩니다.
코틀린에서 쓰기에는 뭔가... 트렌디하지 않더군요.
abstract class CrudRepositoryMock<T : Entity>
...
fun save() {
`when`(repository.save(ArgumentMatchers.any())).thenAnswer {
val argumentEntity: T = it.getArgument(0)
this.entities.first { entity -> argumentEntity.srl == entity.srl }
}
}
나름 코드를 아름답게 꾸미기 위해 top-level 함수를 정의해봅니다.
// MockUtils.kt
fun <T> whenever(block: () -> T): OngoingStubbing<T> = `when`(block())
infix fun <T> OngoingStubbing<T>.answer(callAnswer: (InvocationOnMock) -> T): OngoingStubbing<T> {
val answer: Answer<T> = Answer { callAnswer(it) }
return this.thenAnswer(answer)
}
fun <T> capture(position: Int): Answer<T> = returnsArgAt(position)
fun <T> any(): T = ArgumentMatchers.any()
여전히 이름은 본인 입맛대로 만들면 됩니다.
저는 MockK를 썼다보니 여기 용어가 익숙해서 이렇게 만들어보았네요.
이제 간단히 save 함수를 만들어봅니다.
abstract class CrudRepositoryMock<T : Entity>(
override val repository: CrudRepository<T, Long>,
override val entities: Collection<T>
) :
RepositoryMock<T, Long> {
fun save() {
whenever {
repository.save(any())
} answer { invocation ->
val capturedEntity: T = capture<T>(position = 0).answer(invocation)
this.entities.first { capturedEntity.srl == it.srl }
}
}
}
하나씩 뜯어보면
whenever {
...
}
는 호출 대리부분입니다. 쉽게 가상의 시나리오를 쓴다고 보면 됩니다.
whenever {
repository.save(any())
}
처럼 쓰면 'save 함수에 아무 인자든 상관없이 받아서 호출할 때'라는 의미가 됩니다.
당연히 결과도 있어야겠죠?
answer { invocation ->
val capturedEntity: T = capture<T>(position = 0).answer(invocation)
this.entities.first { capturedEntity.srl == it.srl }
}
마지막에 리턴되는 부분이 위 시나리오의 결과입니다.
'save 함수에 아무 인자든 상관없이 받아서 호출할 때'에 결과는 '~ 를 통해 나온 엔티티 결과'로 해석해주면 됩니다.
answer 로직은 본인 입맛에 따라 작성해도 됩니다.
저는 srl를 primary key로 정의했기 때문에 이렇게 썼네요.
여기서 capture 함수를 주목해볼 필요가 있는데
capture<T>(position = 0)
아래 answer 함수 정의부분에서 확인할 수 있듯 InvocationOnMock 환경에서 호출됩니다.
infix fun <T> OngoingStubbing<T>.answer(callAnswer: (InvocationOnMock) -> T) ...
아쉽게도 구현부를 깊게 보기 어려웠는데, 대략 생각해보면 mock으로 등록된 함수들의 컬렉션이 있고
해당 함수가 invoke()되면 매칭되는 함수를 찾고 아래 InvocationOnMock을 만드는 것 같네요. (정확한 건 아닙니다)
@NotExtensible
public interface InvocationOnMock extends Serializable {
Object getMock();
Method getMethod();
Object[] getRawArguments();
Object[] getArguments();
<T> T getArgument(int var1);
<T> T getArgument(int var1, Class<T> var2);
Object callRealMethod() throws Throwable;
}
어쨌든 여기까지 생각이 도달했을 때,
저 getArgument 함수를 통해 - whenever에서 제공한 인자를 가져올 수 있다고 생각했네요.
여담으로 첫 번째, 두 번째 그리고 마지막 인자를 가져오는 건 꽤 빈번하게 사용하므로 미리 함수로 만들어두면 좋습니다.
fun <T> capture(position: Int): Answer<T> = returnsArgAt(position)
fun <T> captureFirst(): Answer<T> = capture(position = 0)
fun <T> captureSecond(): Answer<T> = capture(position = 1)
fun <T> captureLast(): Answer<T> = capture(position = -1)
Implement CrudRepositoryMock
CrudRepositoryMock 구현을 이어서 작성해보겠습니다.
참고로 abstract class 가 아닌 interface - impl 구조로 만들어도 좋습니다.
2개 더 예시로 함수를 만들어보겠습니다.
abstract class CrudRepositoryMock<T : Entity>(
override val repository: CrudRepository<T, Long>,
override val entities: Collection<T>
) :
RepositoryMock<T, Long> {
fun save() {
whenever {
repository.save(any())
} answer { invocation ->
val capturedEntity: T = captureFirst<T>().answer(invocation)
this.entities.first { capturedEntity.srl == it.srl }
}
}
fun saveAll() {
whenever {
repository.saveAll(anyCollection())
} answer { invocation ->
val capturedEntities: Collection<T> = captureFirst<Collection<T>>().answer(invocation)
val capturedSrls = capturedEntities.map { it.srl }
this.entities.filter { capturedSrls.contains(it.srl) }
}
}
fun findById() {
whenever {
repository.findById(any())
} answer { invocation ->
val capturedSrl: Long = captureFirst<Long>().answer(invocation)
this.entities.firstOrNull { capturedSrl == it.srl }
?.let { Optional.of(it) }
?: Optional.empty()
}
}
}
이때 whenever 중에
...
fun saveAll() {
whenever {
repository.saveAll(anyCollection())
}
발견할 수 있는 anyCollection() 경우에는 아래 또 함수를 만들어서 사용했습니다.
fun <T> anyCollection(): Collection<T> = ArgumentMatchers.anyCollection()
이렇듯 상황에 따라 ArgumentMatchers 를 잘 사용하면 됩니다.
마지막으로 실제 repository 정의에 맞춰 클래스를 만들어줍니다.
class UserRepositoryMock(
override val entities: Collection<User>,
override val repository: UserRepository
) :
CrudRepositoryMock<User>(repository, entities)
예시에서 볼 수 있듯 UserRepository 를 위한 UserRepositoryMock을 만들었습니다.
이렇게 만드는 부분, 실제 사용해봤듯 JPA 수준에서 제공하지 못하는 커스텀 함수들도 정의해야 되기 때문입니다.
결을 맞춰서 만들어주면 좋겠죠?
예를 들어 findAllByName(name: String) 처럼입니다.
class UserRepositoryMock(
override val entities: Collection<User>,
override val repository: UserRepository
) :
CrudRepositoryMock<User>(repository, entities) {
fun findAllByName() { ... }
}
Use Mock in Test
지금까지 만든 mock 함수들은 아래 테스트 코드의 @BeforeEach 부분에서 호출해주면 됩니다.
// UserServiceImplTest.kt
@ExtendWith(MockitoExtension::class)
class UserServiceImplTest(
@Mock private val userRepository: UserRepository
) {
...
private val userRepositoryMock = UserRepositoryMock(
entities = listOf(user),
repository = userRepository
)
@BeforeEach
fun setMocks() {
userRepositoryMock.save()
}
}
이렇게 되면 아래 구현 코드처럼 userRepository.save를 사용하는 장소에서
// ../main/../UserServiceImpl.kt
@Service
class UserServiceImpl(
private val userRepository: UserRepository,
...
) :
UserService {
@Transactional
override fun save(name: String, ..., createdBy: String): UserVo {
val user = User.of(
name = name,
...,
createdBy = createdBy
)
val savedUser = userRepository.save(user)
return UserVo(user = savedUser)
}
아래 테스트 코드 내 UserRepositoryMock에게 제공한 user가 나오게 될 것입니다.
(물론 user는 srl이 서로 같게 만들었습니다)
// ../test/../UserServiceImplTest.kt
...
private val userRepositoryMock = UserRepositoryMock(
entities = listOf(user),
repository = userRepository
)
테스트 코드 하나 만들어서 actualUser를 찍어보면
// ../test/../UserServiceImplTest.kt
...
@Nested
inner class SaveUserTest {
@Test
fun givenName_whenSave_thenPass() {
// given
val givenName = user.name
// when
val actualUser: UserVo = userService.save(
name = givenName,
...,
createdBy = UNSET
)
log.debug { actualUser }
// then
...
}
}
...
잘 나오네요!
... DEBUG --- [ main] c.o.a.d.s.user.UserServiceImplTest : UserVo(name=<unset>, ... )
'스프링 (Spring) > 스프링 팁 (Spring Tip)' 카테고리의 다른 글
Kotlin 플러그인 All-open 파헤치기 (feat, JPA Fetch) (0) | 2024.05.18 |
---|---|
테스트 시 발생하는 위험 메시지 무시해보기 (0) | 2024.05.01 |
Kotlin 답게 간단하게 로깅해보기 (0) | 2024.04.30 |
Spring에 Jacoco 적용해보기 (0) | 2024.04.29 |
Spring에서 Gmail 보내기 (1) | 2024.04.29 |