Test Doubles란 무엇인가?
테스트 대상 객체가 다른 객체와 의존성이 있는 경우를 생각해보자. 만약 객체를 테스트 하기 위해 의존성이 있는 객체에 대한 실제 구현체를 사용한다면, 의존성이 있는 객체의 구현체에 의해 테스트가 실패할 수 있다.
이런 경우 실제 구현체 대신 해당 객체의 동작을 모방하는 객체를 만들어 테스트에 영향이 없도록 만들어야 한다. 이 때 의존성 있는 객체의 동작을 모방하는 객체를 바로 Test Doubles라 부른다.
Test Doubles를 사용하기 위한 환경 설정
Test Doubles를 사용하기 위해 다른 객체에 의존성이 있는 UserProfileFetcher 객체를 만들어보자. UserProfileFetcher은 UserRepository 객체로부터 유저의 이름(name)을 가져오고, ContactRepository 객체로부터 유저의 휴대폰 번호를 가져와 UserProfile을 만드는 객체이다.
class UserProfileFetcher(
private val userRepository: UserRepository,
private val contactRepository: ContactRepository
) {
fun getUserProfileById(id: String): UserProfile {
return UserProfile(
id = id,
name = userRepository.getNameByUserId(id),
phoneNumber = contactRepository.getPhoneNumberByUserId(id)
)
}
}
interface UserRepository {
fun saveUserName(id: String, userName: String)
fun getNameByUserId(id: String): String
}
interface ContactRepository {
fun getPhoneNumberByUserId(id: String): String
}
data class UserProfile(val id: String, val name: String, val phoneNumber: String)
UserProfileFetcher은 UserRepository와, ContactRepository에 모두 의존성을 가지고 있다. 때문에 UserProfileFetcher에 대한 테스트를 만들기 위해서는 UserRepository와 ContactRepository의 구현체가 필요하다. 하지만, 이들의 실제 구현체를 사용하면 테스트 대상 객체인 UserProfileFetcher의 동작이 영향을 받으므로, 이 객체들의 행동을 모방하는 TestDoubles를 만들어야 한다. 이어서 어떤 종류의 Test Doubles를 만들 수 있는지 살펴보자.
Test Doubles의 종류
Dummy
Dummy는 아무런 동작도 하지 않는 객체이다. 구현 자체가 없으며, 반환이 필요한 값에는 빈 값을 반환한다. 예를 들어 ContactRepository에 대한 Dummy는 다음과 같이 만들 수 있다.
class DummyContactRepository : ContactRepository {
override fun getPhoneNumberByUserId(id: String): String {
return ""
}
}
Stub
Stub은 더미와 비슷하지만, 실제 데이터를 반환하는 객체이다. ContactRepository에 대한 Stub은 다음과 같이 만들 수 있다.
class StubContactRepository : ContactRepository {
override fun getPhoneNumberByUserId(id: String): String {
return "010-xxxx-xxxx"
}
}
Fake
Fake는 실제처럼 동작하는 모방 객체이다. 간단한 구현이 들어가 있어 실제처럼 동작하도록 만드는 객체이다. 예를 들어 UserRepository에 대한 Fake를 다음과 같이 만들 수 있다.
class FakeUserRepository : UserRepository {
private val userNameMap = mutableMapOf<String,String>()
override fun saveUserName(id: String, userName: String) {
userNameMap[id] = userName
}
override fun getNameByUserId(id: String): String {
return userNameMap[id] ?: throw IllegalArgumentException("User Id가 존재하지 않습니다.")
}
}
이 UserRepository는 영속성 있는 데이터베이스에 저장되지는 않지만 인메모리에서 실제 데이터베이스에 저장되는 것과 비슷하게 동작한다.
Spy
Spy는 객체와의 상호작용을 기록하는 모방 객체이다. 예를 들어 UserProfileFetcher가 UserRepository의 getNameByUserId를 호출했는지 확인하기 위해서는 내부에 isGetNameByUserIdCalled를 두고 getNameByUserId이 호출되면 이 값을 true로 바꾸면 된다. 이후, SpyUserRepository에 대해 verifyGetUserNameByUserByIdCalled를 호출하면 getNameByUserId이 실행된 적이 있는지를 확인할 수 있다.
class SpyUserRepository : UserRepository {
private val userNameMap = mutableMapOf<String, String>()
private var isGetNameByUserIdCalled = false
override fun saveUserName(id: String, userName: String) {
userNameMap[id] = userName
}
override fun getNameByUserId(id: String): String {
isGetNameByUserIdCalled = true
return userNameMap[id] ?: throw IllegalArgumentException("User Id가 존재하지 않습니다.")
}
fun verifyGetUserNameByUserByIdCalled() {
if(!isGetNameByUserIdCalled) {
assert(false)
}
}
}
Mock
Mock은 Spy보다 조금 더 정확한 상호작용을 기록한다. 어떤 함수에 대한 입력과 출력이 있었는지를 기록하며, 예를 들어 getNameByUserId 호출 시 상호작용을 기록하기 위해 다음과 같이 코드가 작성될 수 있다. GetNameByUserIdCallHistory는 상호작용을 기록하기 위한 데이터 클래스이며, getNameByUserId이 호출될 때마다 getNameByUserIdCallHistory에 GetNameByUserIdCallHistory이 추가된다.
class MockUserRepository : UserRepository {
private val userNameMap = mutableMapOf<String, String>()
private val getNameByUserIdCallHistory: MutableList<GetNameByUserIdCallHistory> = mutableListOf()
override fun saveUserName(id: String, userName: String) {
userNameMap[id] = userName
}
override fun getNameByUserId(id: String): String {
val result = userNameMap[id] ?: throw IllegalArgumentException("User Id가 존재하지 않습니다.")
getNameByUserIdCallHistory.add(GetNameByUserIdCallHistory(id, result))
return result
}
private data class GetNameByUserIdCallHistory(val inputId: String, val returnValue: String)
}
정리
여기까지 다양한 TestDoubles를 알아보았다. 일반적으로 테스트가 필요한 객체는 다른 객체들과 상호작용하는 경우가 많기 때문에 Test Doubles를 이해하는 것은 좋은 테스트를 만들기 위해 필수적이다.
하지만, 테스트마다 매번 직접 Test Doubles를 만들면 매 테스트마다 많은 코드가 생성될 것이다. 이 때문에 실제로 Test Doubles를 직접 만드는 경우는 많지 않으며, 대부분 Test Doubles를 만드는 것을 도와주는 Mokito나 Mockk같은 모킹 라이브러리를 사용해 코드의 절대량을 줄인다. 그럼에도 이들 라이브러리에서 모킹하는 객체들은 모두 위에서 다룬 Test Doubles에 바탕을 두고 있어, 어떤 Test Doubles가 있는지 아는 것은 매우 중요하다.