테스트의 Assert의 종류
우리는 테스트를 진행할 때, 단언(Assert)을 통해 기대값과 실제 값을 비교한다. 단언의 기대값과 실제 값이 다르다면 테스트는 실패할 것이고, 같다면 성공한다. 이러한 단언은 단순하게는 객체의 함수를 호출하고 결과값을 받아 해당 값을 비교하는 것부터 시작해, 객체와의 상호 작용을 확인하는 단언들 또한 존재한다. 이번 글에서는 단언에 어떤 종류가 있는지를 알아봄으로써 각 테스트 상황에 맞는 단언을 사용할 수 있도록 한다.
함수의 결과를 확인하는 단언
우리가 다룰 첫 단언은 함수의 결과를 확인하는 단언이다. 함수의 결과를 확인하는 단언은 가장 간단한 단언으로, 입력값과 출력 값이 같아야 하기 때문에 인터페이스의 구현체가 달라지더라도 테스트는 변경될 필요가 없다.
예를 들어 다음과 같은 SimpleAdder과 SimpleAdder의 구현체인 SimpleAdderImpl이 있다고 해보자.
interface SimpleAdder {
fun add(vararg number: Int) : Int
}
class SimpleAdderImpl() : SimpleAdder {
override fun add(vararg number: Int) : Int {
return number.sum()
}
}
이런 경우 이 SimpleAdder 객체에 대한 테스트는 다음과 같이 작성될 수 있으며, SimpleAdder을 구현하는 다른 객체가 생기더라도 SimpleAdder 인터페이스의 add 함수의 역할은 바뀌지 않기 때문에 테스트는 변경될 필요가 없다.
class SimpleAdderTest {
@Test
fun `one plus two plus three is six`() {
// Given
val simpleAdder : SimpleAdder = SimpleAdderImpl()
// When
val result = simpleAdder.add(1,2,3)
// Then
Assertions.assertEquals(6, result)
}
}
객체의 상태를 확인하는 단언
객체에는 가변 상태가 포함되지 않는 것이 좋지만, StateHolder 의 역할을 하는 객체에서는 객체의 상태를 변경시키게 된다. 이런 경우 객체의 상태가 제대로 바뀌었는지도 확인해야 한다. 예를 들어 다음과 같은 IntStateHolder가 있다고 해보자. 여기서 IntStateHolder은 상태를 외부에 노출하기 위한 intstateFlow 변수와 상태를 변경시키는 changeInt 함수를 외부에 노출한다.
interface IntStateHolder {
val intStateFlow: StateFlow<Int>
fun changeInt(newInt: Int)
}
이러한 IntStateHolder에 대한 구현체는 다음과 같이 작성될 수 있다.
class IntStateHolderImpl : IntStateHolder {
private val _intStateFlow: MutableStateFlow<Int> = MutableStateFlow(0)
override val intStateFlow: StateFlow<Int> = _intStateFlow
override fun changeInt(newInt: Int) {
_intStateFlow.value = newInt
}
}
이러한 구현체는 내부에 상태를 가지고 변경도 시키고 있기 때문에 객체의 상태가 제대로 변화했는지도 확인해야 한다. 따라서 IntStateHolder의 상태가 변화하는지에 대한 테스트는 다음과 같이 작성될 수 있다.
class IntStateHolderTest() {
@Test
fun testStateChange() {
// Given
val intStateHolder: IntStateHolder = IntStateHolderImpl()
// When
intStateHolder.changeInt(2)
// Then
Assertions.assertEquals(2, intStateHolder.intStateFlow.value)
}
}
다른 객체와의 상호작용을 확인하는 단언
마지막은 다른 객체와의 상호작용을 확인하는 단언이다. 일반적으로 객체는 다른 객체에 의존성을 가지고 있기 때문에, 다른 객체와 상호작용한다. 이런 상호작용이 제대로 일어나는지 확인하기 위해 단언을 사용할 수 있다.
예를 들어 다음과 같이 UserRepository와 ContactRepository에 대한 의존성을 가진 UserProfileFetcher이 있다고 해보자.
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
}
UserProfileFetcher의 getUSerProfileById함수를 호출하면, 이 함수는 UserRepository와 ContactRepository의 함수를 호출한다. 즉, 이들 객체들과 상호작용한다. 이런 경우 이들 객체들과 제대로 상호작용되는지 확인하기 위해 TestDoubles 중 Mock을 사용할 수 있다. 예를 들어 UserRepository에 대한 Mock 객체를 다음과 같이 만들면 객체가 getGetNameByUserIdCallHistory를 통해 getNameByUserId를 호출한 기록을 가져올 수 있다.
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
}
fun getGetNameByUserIdCallHistory() = getNameByUserIdCallHistory
data class GetNameByUserIdCallHistory(val inputId: String, val returnValue: String)
}
이렇게 만들어진 MockUserRepository를 사용하면 UserProfileFetcher객체가 UserRepository와 상호작용하는 것을 테스트 할 수 있다. UserProfileFetcher에 MockUserRepository의 인스턴스를 주입한 후, UserProfileFetcher에 getUserProfileById를 실행하면 MockUserRepository의 getNameByUserId을 호출해야 하므로, 이에 대한 호출 기록(Call History)을 가져와 호출 기록이 예상과 같은지 확인할 수 있다.
class UserProfileFetcherTest {
@Test
fun testInteraction() {
// Given
val mockUserRepository = MockUserRepository().apply {
saveUserName("dummyId", "TestUser")
}
val userProfileFetcher = UserProfileFetcher(
userRepository = mockUserRepository,
contactRepository = object : ContactRepository {
override fun getPhoneNumberByUserId(id: String): String {
return " "
}
}
)
// When
userProfileFetcher.getUserProfileById("dummyId")
// Then
val getUserProfileByIdCallHistory = mockUserRepository.getGetNameByUserIdCallHistory()
assertEquals("dummyId", getUserProfileByIdCallHistory[0].inputId)
assertEquals("TestUser", getUserProfileByIdCallHistory[0].returnValue)
}
}
이런 방식으로 다른 객체와의 상호 작용을 테스트 할 수 있지만, 이 방식으로 테스트를 하기 위해서는 테스트가 너무 복잡해지는 경향이 있다. 이 때문에 이런 테스트를 작성하기 위해서는 많은 유지보수 비용이 생기게 된다. 이를 완화하기 위해 Mockito 같은 라이브러리에서는 Mock 객체와 상호작용을 기록하는 기능을 지원하며, 간단하게 작성할 수 있도록 만든다. 이에 대해서는 Mockito를 통해 Mock 객체를 만드는 방법을 다루는 글에서 별도로 다루도록 한다.