[Kotlin Coroutine] 코루틴 단위 테스트
목표
- 코루틴 효율적으로 테스트하기
단위 테스트
단위(Unit)
명확히 정의된 역할의 범위를 갖는 코드의 집합을 의미하며, 정의된 동작을 실행하는 개별 함수나 클래스 또는 모듈 모두가 단위가 될 수 있습니다.
단위 테스트란, 이런 단위에 대한 자동화된 테스트를 작성하고 실행하는 프로세스를 말합니다. 객체 지향 프로그래밍에서는 책임을 객체에 할당하고 객체간 협력을 구축하고 있기 때문에, 객체 지향 프로그래밍에서의 단위 테스트는 주로 객체에 대해 이루어집니다.
코틀린 또한 객체 지향 언어이기 때문에 코틀린에서는 일반적으로 객체가 단위 테스트 대상이 됩니다.
테스트는 보통 객체가 예상한 대로 동작하는지 확인하게 되며, 확인하는 대상은 아래와 같이 다양합니다.
- 결과값이 제대로 반환되는지
- 결과값이 원하는데로 잘 변화하는지
- 객체간 의존관계가 있을 때 상호작용이 잘 이루어지는지
테스트 환경 설정하기
코틀린에서 단위 테스트를 작성하기 위해서는 프로젝트에 테스트 라이브러리에 대한 의존성을 추가해야 합니다.
현재 실무에서 사용중인 kotest를 사용하기 위해서는 아래와 같이 build.gradle.kt에 추가해주어야 합니다.
- 테스트 환경 설정과 관련하여 좀 더 자세한 내용이 알고 싶다면, 참고 자료에 넣어둔 Kotest Quick Start 문서를 확인하길 바랍니다.
dependencies {
...
testImplementation("io.kotest:kotest-runner-junit5:5.9.1") // kotest 사용 시, kotest plugin 사용하면 테스트별로 수행 가능
}
tasks.test {
useJUnitPlatform()
}
이렇게 build.gradle.kt를 설정하고 그레이들 동기화를 완료하면, src/test 경로에 테스트를 작성할 수 있습니다.
간단한 테스트 만들어보기
앞서 추가한 의존성을 활용해 간단한 테스트를 만들어보겠습니다.
import io.kotest.core.spec.style.StringSpec
import io.kotest.matchers.shouldBe
class UseCase {
fun add(a: Int, b: Int): Int {
return a + b
}
}
class UnitTest: StringSpec({
"add" {
val useCase = UseCase()
val result = useCase.add(1, 2)
result shouldBe 3
}
})
위 테스트는 add() 함수의 결과가 원하는대로 나오는지 확인하는 것으로, 아래와 같이 실행하면 성공하는 것을 확인할 수 있습니다.
- 위 예시처럼, 각 테스트별로 실행할 수 있는 실행 버튼을 설정하고 싶다면 이전 글 내용을 토대로 plugin 설치를 진행하면 됩니다.
테스트 더블을 사용해 의존성 있는 객체 테스트하기
다른 객체들과 의존성이 있는 객체를 테스트할 때에는 테스트 더블을 통해 의존성 객체들을 모방하여 진행할 수 있습니다. 테스트 더블이란, 객체에 대한 대체물을 뜻하여, 객체의 행동을 모방하는 객체를 만드는 데 사용합니다.
테스트 더블 필요한 상황
interface UserNameRepository {
fun saveUserName(userId: String, name: String)
fun getUserNameByUserId(userId: String): String
}
interface UserPhoneNumberRepository {
fun saveUserPhoneNumber(userId: String, phoneNumber: String)
fun getUserPhoneNumberByUserId(userId: String): String
}
data class UserProfile(val userId: String, val name: String, val phoneNumber: String)
class UserProfileFetcher(
private val userNameRepository: UserNameRepository,
private val userPhoneNumberRepository: UserPhoneNumberRepository
) {
fun fetchUserProfileByUserId(userId: String): UserProfile {
val name = userNameRepository.getUserNameByUserId(userId)
val phoneNumber = userPhoneNumberRepository.getUserPhoneNumberByUserId(userId)
return UserProfile(userId = userId, name = name, phoneNumber = phoneNumber)
}
}
테스트 더블은 위의 UserProfileFetcher 클래스의 함수를 테스트할 때 필요합니다.
- UserProfileFetcher의 fetchUserProfileByUserId 메소드를 테스트하기 위해서는 UserNameRepository와 UserPhoneNumberRepository를 주입받아야 합니다.
테스트를 위해 실제 Repository를 주입받게 되면, 잘못된 데이터가 들어갈 수 있어 제대로 테스트가 어렵습니다. 이럴 때, 테스트 더블을 사용하여 의존성 있는 객체의 대체물을 만들어 테스트를 할 수 있습니다.
테스트 더블 종류
테스트 더블에는 대표적으로 아래 항목들이 있으며, 이 외에도 여러 종류가 있습니다.
- Stub
- Fake
- Mock
- Dummy
- Spy
Stub
Stub 객체는 미리 정의된 데이터를 반환하는 모방 객체로 반환값이 없는 동작은 구현하지 않으며, 반환값이 있는 동작만 미리 정의된 데이터를 반환하도록 구현합니다.
다만, Stub은 미리 정의된 값들만 반환할 수 있어 유연하지 않습니다.
앞선 예시 상황이라면, 아래와 같이 의존성 있는 객체에 대해 Stub 객체를 만들어 볼 수 있습니다.
class StubUserNameRepository: UserNameRepository {
private val userNameByUserId = mapOf("1" to "Earth", "2" to "Moon")
override fun saveUserName(userId: String, name: String) {
// do nothing
}
override fun getUserNameByUserId(userId: String): String {
return userNameByUserId[userId] ?: ""
}
}
class StubUserPhoneNumberRepository: UserPhoneNumberRepository {
private val userPhoneNumberByUserId = mapOf("1" to "010-1234-5678", "2" to "010-9876-5432")
override fun saveUserPhoneNumber(userId: String, phoneNumber: String) {
// do nothing
}
override fun getUserPhoneNumberByUserId(userId: String): String {
return userPhoneNumberByUserId[userId] ?: ""
}
}
- Stub은 반환값이 없는 동작은 따로 구현하지 않고, 반환값이 있는 동작만 특정 값을 반환하도록 합니다.
이때의 테스트 코드는 아래와 같이 구현할 수 있으며, 미리 userNameByUserId나 userPhoneNumberByUserId 맵에 등록하지 않은 정보에 대해서는 테스트를 할 수 없습니다.
class UserProfileFetcherTest: StringSpec({
"fetchUserProfileByUserId - STUB" {
// given
val userProfileFetcher = UserProfileFetcher(
userNameRepository = StubUserNameRepository(),
userPhoneNumberRepository = StubUserPhoneNumberRepository()
)
// when
val userProfile = userProfileFetcher.fetchUserProfileByUserId("1")
// then
userProfile shouldBe UserProfile(userId = "1", name = "Earth", phoneNumber = "010-1234-5678")
}
})
Fake
Fake 객체는 실제 객체와 비슷하게 동작하도록 구현된 모방 객체입니다.
예를 들어, UserPhoneNumberRepository 인터페이스의 실제 구현체가 로컬 데이터베이스를 사용해 유저 전화번호를 저장한다고 하면 아래와 같이 구현할 수 있습니다.
class FakeUserNameRepository: UserNameRepository {
private val userNameByUserId = mutableMapOf<String, String>()
override fun saveUserName(userId: String, name: String) {
userNameByUserId[userId] = name
}
override fun getUserNameByUserId(userId: String): String {
return userNameByUserId[userId] ?: ""
}
}
class FakeUserPhoneNumberRepository: UserPhoneNumberRepository {
private val userPhoneNumberByUserId = mutableMapOf<String, String>()
override fun saveUserPhoneNumber(userId: String, phoneNumber: String) {
userPhoneNumberByUserId[userId] = phoneNumber
}
override fun getUserPhoneNumberByUserId(userId: String): String {
return userPhoneNumberByUserId[userId] ?: ""
}
}
Fake 객체를 사용하면 Stub과 다르게 인메모리에 원하는 값을 저장해서 실제 객체처럼 동작할 수 있도록 만들 수 있습니다. 이 경우, 아래와 같이 Given, When, Then 방식으로 테스트가 가능합니다.
앞선 Stub 예제와 다르게, 원하는 값을 given절에서 등록할 수 있어서 원하는 값을 언제든 Fake 객체를 따로 건들지 않고 테스트코드 내에서만 변경하여 테스트할 수 있습니다.
class UserProfileFetcherTest: StringSpec({
"fetchUserProfileByUserId - FAKE" {
// given
val userProfileFetcher = UserProfileFetcher(
userNameRepository = FakeUserNameRepository().apply {
saveUserName("1", "Earth")
},
userPhoneNumberRepository = FakeUserPhoneNumberRepository().apply {
saveUserPhoneNumber("1", "010-1234-5678")
}
)
// when
val userProfile = userProfileFetcher.fetchUserProfileByUserId("1")
// then
userProfile shouldBe UserProfile(userId = "1", name = "Earth", phoneNumber = "010-1234-5678")
}
})
MockK
앞선 Fake나 Stub 객체를 통해 의존성 있는 객체를 테스트할 수 있지만, 매번 테스트를 위해 인터페이스를 구현해 테스트 더블을 만드는 것은 비효율적입니다. 이런 문제를 해결할 수 있도록 테스트 더블을 쉽게 만들 수 있게 하는 Mokito나 MockK 같은 라이브러리들이 있습니다.
먼저, MockK을 사용하기 위해서는 build.gradle.kts에 아래와 같은 의존성 추가가 필요합니다. 버전은 자신의 프로젝트에 맞는 버전을 찾아 사용하시면 됩니다.
dependencies {
...
testImplementation("io.mockk:mockk:1.12.0")
}
MockK을 사용하면 아래와 같이 앞선 테스트 코드를 수정할 수 있으며, 따로 UserNameRepository나 UserPhoneNumberRepository 구현체를 만들지 않고 테스트를 할 수 있습니다.
class UserProfileFetcherTest(
): StringSpec({
lateinit var userNameRepository: UserNameRepository
lateinit var userPhoneNumberRepository: UserPhoneNumberRepository
lateinit var userProfileFetcher: UserProfileFetcher
beforeTest() {
userNameRepository = mockk()
userPhoneNumberRepository = mockk()
userProfileFetcher = UserProfileFetcher(userNameRepository, userPhoneNumberRepository)
}
"fetchUserProfileByUserId" {
// given
every { userNameRepository.getUserNameByUserId("1") } returns "Earth"
every { userPhoneNumberRepository.getUserPhoneNumberByUserId("1") } returns "010-1234-5678"
// when
val userProfile = userProfileFetcher.fetchUserProfileByUserId("1")
// then
userProfile shouldBe UserProfile(userId = "1", name = "Earth", phoneNumber = "010-1234-5678")
}
})
코루틴 단위 테스트
코루틴 단위 테스트는 앞선 예시와 유사하게 작성할 수 있습니다.
Kotest 활용하기
먼저 코루틴 단위 테스트 작성을 위해 테스트를 진행할 객체를 작성해보겠습니다. 아래 예시의 add 일시 중단 함수는 Default 디스패처를 사용하여 주어진 repeatTime만큼 1씩 더하여 최종적으로 result 값을 반환하는 함수입니다.
class RepeatAddUserCase {
suspend fun add(repeatTime: Int): Int = withContext(Dispatchers.Default) {
var result = 0
repeat(repeatTime) {
result += it
}
result
}
}
위 예시에서는 StringSpec을 사용하여서 StringSpecScope 내에서 동작하며 이 스코프에는 coroutineContext가 존재하여 일시 중단 함수도 정상적으로 테스트가 가능합니다.
JUnit 활용하기
만약, JUnit 테스트를 사용하여 아래와 같이 테스트를 작성할 경우에는, 테스트 함수가 일반 함수가 일시 중단 함수를 그냥 테스트할 수는 없습니다.
위 문제를 해결하기 위해서는 테스트 코드를 runBlocking으로 감싸면 됩니다.
runBlocking 함수를 사용해 테스트를 진행할 수도 있지만, 수행에 오랜 시간이 걸리는 테스트라면 수행하는데에 너무 많은 시간이 걸리게 될 수 있습니다.
이런 문제를 해결하기 위해 코루틴 테스트 라이브러리는 가상 시간에서 테스트를 진행하여 오래 걸리는 테스트도 단시간에 수행할 수 있는 코루틴 스케줄러를 제공합니다.
코투틴 테스트 라이브러리
먼저, 코투린 테스트 라이브러리 의존성을 추가해야 코루틴 테스트 라이브러리를 사용할 수 있습니다.
dependencies {
...
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.8.1-Beta")
}
TestCoroutineScheduler 사용해 가상 시간에서 테스트하기
TestCoroutineScheduler 객체는 가상 시간과 관련된 여러 함수를 제공합니다. 예제를 통해 하나씩 알아보겠습니다.
advanceTimeBy()와 currentTime 프로퍼티
advanceTimeBy() 함수는 함수의 인자로 입력된 값만큼 가상 시간이 ms초 단위로 흐르게 됩니다. 가상 시간이 얼마나 흘렀는지 확인하고자 할 때에는 TestCoroutineScheduler 객체의 currentTime 프로퍼티를 확인하면 됩니다.
- 위 예시에서 볼 수 있듯이, 실제 테스트는 24ms만에 끝났지만 가상 시간으로는 11초가 흘렸음을 확인할 수 있습니다.
TestCoroutineScheduler와 StandardTestDispatcher 사용해 가상 시간에서 테스트하기
특정 코루틴스코프의 시간을 가상시간으로 빠르게 돌려서 테스트를 수행하기 위해서는 아래와 같이 CoroutineScope에 TestCoroutineScheduler를 가진 TestDispatcher를 지정하고, 앞선 예시에서처럼 시간을 돌리면 됩니다.
위 예시에서 launch는 testCoroutineScope 내에서 이루어지는 코루틴이고, 해당 스코프의 dispatcher는 TestCoroutineScheduler를 가졌기 때문에 가상 시간을 통해 테스트가 가능합니다.
advanceUntilIdle()
참고로, 보통 위처럼 가상 시간을 직접 제어하면서 테스트하지는 않고 아래 예시처럼 advanceUntilIdle() 함수를 통해 테스트 대상 코드가 모두 실행된 뒤 assert 구문이 동작하도록 작성합니다.
TestCoroutineScheduler를 포함하는 StandardTestDispatcher
앞서서는 TestCoroutineScheduler 객체를 직접 생성하여 StandardTestDispatcher에 주입하여 사용하였습니다. 사실, StandardTestDispatcher 함수에는 기본적으로 TestCoroutineScheduler 객체가 포함되어 있어서, 따로 생성할 필요는 없습니다.
- StandardTestDispatcher 구현을 보면, scheduler가 따로 넘어가지 않으면, TestCoroutineScheduler를 만드는 것을 확인할 수 있습니다.
앞선 예시에서 StandardTestDispatcher()에 스케줄러를 따로 등록하지 않으면 아래와 같이 테스트가 가능합니다.
TestScope 사용해 가상 시간에서 테스트하기
앞선 예시에서 TestCoroutineScheduler 생성 로직은 제거되었지만, 여전히 TestDispatcher를 생성하고 CoroutineScope에 해당 디스패처를 등록하는 작업은 남아있습니다. 이를 좀 더 간소하게 할 수 있도록 코루틴 테스트 라이브러리는 TestScope 함수를 제공합니다.
TestScope 함수를 호출하면, 내부에 TestDispatcher 객체를 가진 TestScope 객체가 반환되며, 코루틴 테스트 라이브러리는 TestScope에 대한 확장 함수를 통해 TestCoroutineScheduler 객체의 함수들과 프로퍼티를 TestScope 객체가 직접 호출할 수 있게 해줍니다.
TestScope를 사용한 예시는 아래와 같으며, 앞선 테스트보다 좀 더 코드가 간결해졌습니다.
runTest 사용해 테스트하기
runTest 함수는 TestScope 객체를 사용해 코루틴을 실행시키고, 그 코루틴 내부에서 일시 중단 함수가 실행되더라도 작업이 곧바로 실행 완료될 수 있도록 가상 시간을 흐르게 만드는 기능을 가진 코루틴 빌더입니다.
runTest 함수를 활용한 테스트 예시는 아래와 같습니다. 아래 예시를 보면, 앞선 예시들과 다르게 따로 스케줄러를 통해 가상 시간을 흐르게 하지 않아도, runTest 내부에 있는 중단 로직이 모두 바로 완료되도록 가상 시간이 흐르는 것을 볼 수 있습니다.
- runTest를 활용할 때는 보통 아래 오른쪽 예시처럼, runBlocking 사용하듯 함수를 runTest로 감싸는 형태로 테스트합니다.
아래 runTest 함수 구현에서 확인할 수 있듯이, runTest 함수는 TestScope를 반환하기 때문에, 앞서 살펴보았던 TestScope의 확장함수들을 모두 사용할 수 있습니다. 따라서, runTest 내부에서도 스케줄러와 관련된 함수나 프로퍼티에 모두 접근이 가능합니다.
runTest는 기본적으로 일시 중단 함수가 실행되어도 곧바로 가상 시간을 흐르게 하는데, 언제 TestScope의 확장 함수들이 필요할까?
runTest의 TestScope 내부에서 새로운 코루틴이 실행될 때, 해당 코루틴 실행 완료될 때까지 가상 시간을 흐르게 하는 데 사용합니다.
아래 오른쪽 예시를 보면, testScope 내부에서 새로 코루틴을 생성하면 해당 코루틴에 대해서는 자동으로 가상시간이 흐르지 않는 것을 출력된 로그를 통해 확인할 수 있습니다.
- runTest 내에서 advanceUntilIdle() 함수를 호출해야 runTest 내 새로 생성된 코루틴들이 실행 완료될 때까지 시간이 흐르게 됩니다.
왜 runTest는 내부에 새로 생성된 코루틴은 바로 가상 시간을 흐르게 하지 않을까?
멀티 스레드 환경에서의 코루틴 테스트를 위해서는 여러 코루틴이 병렬로 실행되는 상황에 대해 테스트가 필요합니다.
만약, runTest가 내부에 생성된 코루틴들에 가상 시간을 바로 돌려버리면, 순차적으로 실행되어 병렬로 테스트가 어렵습니다.
따라서, runTest 내부에서 모든 코루틴이 생성되고 나서 advanceUntilIdle() 함수를 호출해 모든 코루틴이 함께 실행될 수 있도록 합니다.
참고로, 앞선 예시처럼 advanceUntilIdle() 함수를 통해 가상 시간이 흐르게도 할 수 있지만, runTest 내부에서 코루틴 생성 후 join을 호출하면 runTest의 가상 시간이 흐르게 됩니다. 이는, join 함수 호출 시, runTest 코루틴을 일시 중단 시켜 runTest가 가상 시간을 돌려버리기 때문입니다.
코루틴 테스트 심화
backgroundScope를 사용해 테스트 만들기
runTest 함수를 호출해 생성되는 코루틴은 메인 스레드를 사용하고 내부의 모든 코루틴이 실행될 때까지 종료되지 않습니다.
따라서, runTest 코루틴 내부에서 코루틴 생성 후 무한히 실행되는 테스트가 실행된다면, 아래 예시처럼 끝나지 않아 실패하는 테스트가 만들어질 수 있습니다.
- 위와 같은 에러 로그가 출력되는 이유는, runTest 코루틴이 마지막 코드를 수행하고 나서, while문에 의해 코루틴이 계속 실행되어서 테스트가 종료되지 않았기 때문입니다.
- 원칙적으로는 테스트가 무한으로 실행되어야 하지만, runTest 코루틴은 테스트가 무한히 실행되는 것을 방지하고자 코루틴이 실행 완료 중으로 변한 뒤 일정 시간 뒤에도 테스트가 종료되지 않으면 UncompletedCoroutinesError를 던지고 테스트 실패로 종료하게 됩니다.
이렇게 무한히 실행되는 작업을 테스트하기 위해서는 runTest 람다식의 수신 객체인 TestScope가 제공하는 backgroundScope를 사용해야 합니다. backgroundScope는 runTest 코루틴의 모든 코드가 실행되면 자동으로 취소되어, 테스트가 무한히 실행되는 것을 방지할 수 있습니다.
앞선 예시를 backgroundScope로 변경하면 아래와 같은 결과를 반환해줍니다.
참고 자료