티스토리 뷰
class FetchUserUseCase(
private val repo: UserDataRepository,
) {
suspend fun fetchUserData(): User = coroutineScope {
val name = async { repo.getName() }
val friends = async { repo.getFriends() }
val profile = async { repo.getProfile() }
User(
name 三 name.await(),
friends = friends.await(),
profile = profile.await(),
)
}
}
class FetchUserDataTest {
@Test
fun `should construct user`() = runBlocking {
// given
val repo = FakeUserDataRepository()
val useCase = FetchUserUseCase(repo)
// when
val result = useCase.fetchUserData()
// then
val expectedUser = User(
name = "Ben",
friends = listOf(Friend("some-friend-id-1")),
profile = Profile("Example description"),
)
assertEquals(expectedUser, result)
}
}
class FakeUserDataRepository : UserDataRepository {
override suspend fun getName(): String = "Ben"
override suspend fun getFriends(): List<Friend> =
listOf(Friend("some-friend-id-1"))
override suspend fun getProfile(): Profile =
Profile("Example description")
}
대부분의 중단 함수는 일반 함수와 유사하게 테스트할 수 있다.
위 코드처럼 fake 객체(또는 mock)와 runBlocking, 간단한 assertion을 사용해 원하는 데이터가 들어왔는지 쉽게 확인할 수 있다.
시간 의존성 테스트하기
suspend fun produceCurrentUserSeq(): User {
val profile = repo.getProfile()
val friends = repo.getFriends()
return User(profile, friends)
}
suspend fun produceCurrentUserSym(): User = coroutineScope {
val profile = async { repo.getProfile() }
val friends = async { repo.getFriends() }
User(profile.await(), friends.await())
}
시간 의존성을 가지는 함수를 테스트하는 것은 기존 테스트와 다르다.
위 두 함수 모두 같은 결과를 만들지만, 첫 번째 함수는 순차적으로 생성하고 두 번째 함수는 동시에 생성한다.
getProfile()과 getFriends() 각각 1초씩 걸린다면, 첫 번째 함수는 2초가 걸리고 두 번째 함수는 1초가 걸린다.
이 두 함수의 차이를 어떻게 테스트할 수 있을까?
class FakeDelayedUserDataRepository : UserDataRepository {
override suspend fun getProfile(): Profile {
delay(1000)
return Profile("Example description")
}
override suspend fun getFriends(): List<Friend> {
delay(1000)
return listOf(Friend("some-friend-id-1"))
}
}
fake 객체에서 delay(1000)을 추가해 함수 각각 1초가 걸린다는 것을 나타낸다.
그런데 한 단위 테스트에서 1~2초 걸린다면, 한 프로젝트의 단위 테스트 수천 개를 수행하는 데는 굉장히 오랜 시간이 걸릴 것이다.
이러한 문제를 해결하기 위해 시간을 조작하여 테스트에 걸리는 시간을 줄일 수 있다.
kotlinx-coroutines-test 라이브러리의 StandardTestDispatcher를 사용하면 된다.
TestCoroutineScheduler와 StandardTestDispatcher
fun main() {
val scheduler = TestCoroutineScheduler()
println(scheduler.currentTime) // 0
scheduler.advanceTimeBy(1000)
println(scheduler.currentTime) // 1000
scheduler.advanceTimeBy(1000)
println(scheduler.currentTime) // 2000
}
TestCoroutineScheduler는 delay를 가상 시간 동안 실행시킨다.
실제 시간만큼 기다리지는 않지만, 실제 시간이 흘러간 것과 동일하게 작동한다.
가상 시간은 어떻게 작동할까?
delay()가 호출되면 Delay 인터페이스를 구현한 디스패처인지 확인한다. Delay 인터페이스를 구현했다면 실제 시간만큼 기다리는 DefaultDelay 대신, 디스패처가 가진 scheduleResumeAfterDelay()를 호출한다. StandardTestDispatcher는 Delay 인터페이스를 구현한 디스패처다.
fun main() {
val scheduler = TestCoroutineScheduler()
val testDispatcher = StandardTestDispatcher(scheduler)
CoroutineScope(testDispatcher).launch {
println("Some work 1")
delay(1000)
println("Some work 2")
delay(1000)
println("Coroutine done")
}
println("[${scheduler.currentTime}] Before")
scheduler.advanceUntilIdle()
println("[${scheduler.currentTime}] After")
}
[0] Before
Some work 1
Some work 2
Coroutine done
[2000] After
TestCoroutineScheduler를 사용하려면 이를 지원하는 디스패처를 사용해야 한다.
일반적으로 StandardTestDispatcher를 사용한다.
테스트 디스패처로 시작된 코루틴은 설정된 가상 시간이 흐르기 전까지는 실행되지 않는다.
일반적으로 테스트에서 코루틴을 시작하기 위해 advanceUntilIdle()를 호출한다.
advanceUntilIdle()는 대기 큐에 있는 모든 코루틴을 실행시키고, 그만큼의 가상 시간을 흐르게 한다.
fun main() {
val dispatcher = StandardTestDispatcher()
CoroutineScope(dispatcher).launch {
println("Some work 1")
delay(1000)
println("Some work 2")
delay(1000)
println("Coroutine done")
}
println("[${dispatcher.scheduler.currentTime}] Before")
dispatcher.scheduler.advanceUntilIdle()
println("[${dispatcher.scheduler.currentTime}] After")
}
[0] Before
Some work 1
Some work 2
Coroutine done
[2000] After
StandardTestDispatcher 내부에서 TestCoroutineScheduler를 사용하기 때문에 명시적으로 지정하지 않아도 된다.
디스패처의 scheduler 프로퍼티로 스케줄러에 접근할 수 있다.
fun main() {
val testDispatcher = StandardTestDispatcher()
runBlocking(testDispatcher) {
delay(10)
println("Coroutine done")
}
}
// 영원히 실행된다.
StandardTestDispatcher는 직접 시간을 흐르게 하지는 않는다는 것을 기억해야 한다.
시간을 흐르게 하는 함수를 따로 호출하지 않으면, 코루틴이 재개되지 않아 영원히 실행된다.
fun main() {
val testDispatcher = StandardTestDispatcher()
CoroutineScope(testDispatcher).launch {
delay(1)
println("Done1")
}
CoroutineScope(testDispatcher).launch {
delay(2)
println("Done2")
}
testDispatcher.scheduler.advanceTimeBy(2) // Done1
testDispatcher.scheduler.runCurrent() // Done2
}
시간을 흐르게 하는 또 다른 방법은 advanceTimeBy()를 호출하는 것이다.
advanceTimeBy()는 인자로 전달받은 시간을 흐르게 하고, 그동안 일어났을 모든 연산을 수행한다.
따라서 2ms를 흐르게 하면 2ms 이전에 지연된 모든 코루틴(0ms~1ms)이 재개된다.
2ms와 정확히 일치하는 시간에 예정된 연산을 재개하기 위해서는 runCurrent()를 추가로 호출한다.
fun main() {
val dispatcher = StandardTestDispatcher()
CoroutineScope(dispatcher).launch {
delay(1000)
println("Coroutine done")
}
Thread.sleep(Random.nextLong(2000)) // 이 문장은 결과에 영향을 주지 않는다.
val time = measureTimeMillis {
println("[${dispatcher.scheduler.currentTime}] Before")
dispatcher.scheduler.advanceUntilIdle()
println("[${dispatcher.scheduler.currentTime}] After")
}
println("Took $time ms")
}
[0] Before
Coroutine done
[1000] After
Took 15 ms
가상 시간이 실제 시간과 무관하기 때문에, 위 코드의 Thread.sleep()은 StandardTestDispatcher의 코루틴에 영향을 주지 않는다.
advanceUntilIdle()을 호출하면 몇 ms밖에 걸리지 않기 때문에, 실제 시간만큼 기다리지 않고 실행이 끝난다.
fun main() {
val scope = TestScope()
scope.launch {
delay(1000)
println("First done")
delay(1000)
println("Coroutine done")
}
println("[${scope.currentTime}] Before") // [0] Before
scope.advanceTimeBy(1000)
scope.runCurrent() // First done
println("[${scope.currentTime}] Middle") // [1000] Middle
scope.advanceUntilIdle() // Coroutine done
println("[${scope.currentTime}] After") // [2000] After
}
이전 코드에서는 CoroutineScope()에 StandardTestDispatcher 인자로 전달해 사용했다.
TestScope를 사용해도 같은 역할을 수행한다.
스코프의 스케줄러에 advanceUntilIdle(), advanceTimeBy(), currentTime 프로퍼티가 위임되기 때문에, 스코프에서 바로 함수와 프로퍼티에 접근할 수 있다.
테스트 디스패처나 TestScope를 직접 사용하면 신경써야할 부분이 많다.
테스트 함수에서 코루틴이 모두 실행될 때까지 시간을 직접 흐르게 해야 하고, 얼마큼의 시간이 흘렀는지 확인해야 한다.
이런 복잡한 방법 대신, 동일한 목적으로 만들어진 runTest를 사용할 수 있다.
runTest
class TestTest {
@Test
fun test() = runTest {
assertEquals(0, currentTime)
delay(1000)
assertEquals(1000, currentTime)
}
@Test
fun test2() = runTest {
assertEquals(0, currentTime)
coroutineScope {
launch { delay(1000) }
launch { delay(1500) }
launch { delay(2000) }
}
assertEquals(2000, currentTime)
}
}
runTest()는 테스트 함수 중 가장 많이 사용된다.
내부적으로 TestScope를 사용하고, 코루틴이 모두 실행이 완료되어 유휴 상태가 될 때까지의 시간을 흐르게 한다.
람다 리시버가 TestScope 타입이기 때문에 람다 내부에서 바로 currentTime을 사용할 수 있다.
@Test
fun `Should produce user sequentially`() = runTest {
// given
val userDataRepository = FakeDelayedUserDataRepository()
val useCase = ProduceUserUseCase(userDataRepository)
// when
useCase.produceCurrentUserSeq()
// then
assertEquals(2000, currentTime)
}
@Test
fun `Should produce user simultaneously`() = runTest {
// given
val userDataRepository = FakeDelayedUserDataRepository()
val useCase = ProduceUserUseCase(userDataRepository)
// when
useCase.produceCurrentUserSym()
// then
assertEquals(1000, currentTime)
}
데이터를 순차적으로 가져오는 함수와 동시에 가지고 오는 함수를 runTest로 테스트하면 매우 간단하다.
가상 시간을 사용하기 때문에 테스트는 즉시 완료되고, currentTime와 1000, 2000을 정확하게 비교할 수 있다.
runTest는 TestScope를 사용한다.
TestScope는 StandardTestDispatcher를 사용하며, StandardTestDispatcher는 TestCoroutineScheduler를 사용한다.
백그라운드 스코프
@Test
fun `should increment counter`() = runTest {
var i = 0
launch {
while (true) {
delay(1000)
i++
}
}
delay(1001)
assertEquals(1, i)
delay(1000)
assertEquals(2, i)
// coroutineContext.job.cancelChildren()을 추가하면 테스트가 통과한다.
}
runTest 함수는 새로운 스코프를 만들어 자식 코루틴이 끝날 때까지 기다린다.
따라서 코루틴에서 무한 루프가 발생한다면 테스트도 종료되지 않는다.
@Test
fun `should increment counter`() = runTest {
var i = 0
backgroundScope.launch {
while (true) {
delay(1000)
i++
}
}
delay(1001)
assertEquals(1, i)
delay(1000)
assertEquals(2, i)
}
runTest는 테스트가 끝나지 않을 경우를 대비해 backgroundScope를 제공한다.
backgroundScope도 가상 시간을 지원하지만, runTest의 스코프가 종료될 때까지 기다리지 않는다.
따라서 위 테스트는 정상적으로 통과한다.
backgroundScope는 모든 작업이 끝날 때까지 기다릴 필요가 없는 코루틴을 테스트할 때 사용한다.
취소와 컨텍스트 전달 테스트하기
suspend fun <T, R> Iterable<T>.mapAsync(
transformation: suspend (T) -> R,
): List<R> = coroutineScope {
this@mapAsync.map { async { transformation(it) } }
.awaitAll()
}
특정 함수가 구조화된 동시성을 지키고 있는지 테스트하려면, 중단 함수의 컨텍스트를 가져오고 해당 컨텍스트가 기대한 값을 가지고 있는지, 잡이 적절한 상태인지를 확인하면 된다.
순서를 보장하여 비동기적으로 매핑하는 mapAsync() 함수를 사용해 구조화된 동시성을 테스트해 보자.
@Test
fun `should support context propagation`() = runTest {
var ctx: CoroutineContext? = null
val name1 = CoroutineName("Name 1")
withContext(name1) {
listOf("A").mapAsync {
ctx = currentCoroutineContext()
it
}
assertEquals(name1, ctx?.get(CoroutineName))
}
val name2 = CoroutineName("Name 2")
withContext(name2) {
listOf(1, 2, 3).mapAsync {
ctx = currentCoroutineContext()
it
}
assertEquals(name2, ctx?.get(CoroutineName))
}
}
위 코드는 자식 코루틴에서 부모 코루틴의 컨텍스트를 상속받았는지 테스트한다.
부모 코루틴에서 특정 컨텍스트(예를 들어 CoroutineName)를 명시하고, transformation 함수(자식 코루틴)에서도 특정 컨텍스트가 그대로인지 확인하면 된다.
@Test
fun `should support cancellation`() = runTest {
var job: Job? = null
val parentJob = launch {
listOf("A").mapAsync {
job = currentCoroutineContext().job
delay(Long.MAX_VALUE)
}
}
delay(1000)
parentJob.cancel()
assertEquals(true, job?.isCancelled)
}
위 코드는 부모 코루틴이 취소되면 자식 코루틴도 취소되었는지 테스트한다.
transformation 함수(자식 코루틴)의 잡을 job에 저장한다.
부모 코루틴을 취소한 후, 저장된 자식 코루틴의 job이 취소되었는지 확인한다.
suspend fun <T, R> Iterable<T>.mapAsync(
transformation: suspend (T) -> R,
): List<R> =
this@mapAsync
.map { GlobalScope.async { transformation(it) } }
.awaitAll()
외부 라이브러리가 구조화된 동시성을 지키고 있는지 테스트하기 위해 사용할 수 있다.
만약 mapAsync에서 async를 GlobalScope에서 시작했다면, 구조화된 동시성이 성립되지 않아 테스트가 실패했을 것이다.
출처
'kotlin > coroutines' 카테고리의 다른 글
2.10 코틀린 코루틴 라이브러리 - 코틀린 코루틴 테스트하기 (2) (1) | 2025.01.21 |
---|---|
2.9 코틀린 코루틴 라이브러리 - 공유 상태로 인한 문제 (0) | 2025.01.18 |
2.8 코틀린 코루틴 라이브러리 - 코루틴 스코프 만들기 (0) | 2025.01.17 |
2.7 코틀린 코루틴 라이브러리 - 디스패처 (2) | 2025.01.16 |
2.6 코틀린 코루틴 라이브러리 - 코루틴 스코프 함수 (0) | 2025.01.14 |