=================
== The Archive ==
=================

[Kotlin Coroutines] 15장. 코틀린 코루틴 테스트하기

introduction

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
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()
        )
    }
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
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")
    }
}

시간 의존성 테스트하기

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
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())
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
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"))
    }
}

TestCoroutineScheduler 와 StandardTestDispatcher

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
@OptIn(ExperimentalCoroutinesApi::class)
class TestCoroutineSchedulerTest {
    @Test
    fun `TestCoroutineScheduler example`() {
        val scheduler = TestCoroutineScheduler()

        assertEquals(0, scheduler.currentTime)

        scheduler.advanceTimeBy(1_000)
        assertEquals(1_000, scheduler.currentTime)

        scheduler.advanceTimeBy(1_000)
        assertEquals(2_000, scheduler.currentTime)
    }
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
@OptIn(ExperimentalCoroutinesApi::class)
class StandardTestDispatcherTest {
    @Test
    fun `StandardTestDispatcher example`() {
        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")
        }

        assertEquals("[0] Before", "[${scheduler.currentTime}] Before")
        scheduler.advanceUntilIdle()
        assertEquals("[2000] After", "[${scheduler.currentTime}] After")
    }
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
@Suppress("FunctionName")
public fun StandardTestDispatcher(
    scheduler: TestCoroutineScheduler? = null,
    name: String? = null
): TestDispatcher = StandardTestDispatcherImpl(
    scheduler ?: TestMainDispatcher.currentTestScheduler ?: TestCoroutineScheduler(), name)

private class StandardTestDispatcherImpl(
    override val scheduler: TestCoroutineScheduler = TestCoroutineScheduler(),
    private val name: String? = null
) : TestDispatcher() {

    override fun dispatch(context: CoroutineContext, block: Runnable) {
        scheduler.registerEvent(this, 0, block, context) { false }
    }

    override fun toString(): String = "${name ?: "StandardTestDispatcher"}[scheduler=$scheduler]"
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
@Test
fun `StandardTestDispatcher can access TestCoroutineScheduler with the scheduler property`() {
    val dispatcher = StandardTestDispatcher()

    CoroutineScope(dispatcher).launch {
        println("Some work 1")
        delay(1000)
        println("Some work 2")
        delay(1000)
        println("Coroutine done")
    }

    assertEquals("[0] Before", "[${dispatcher.scheduler.currentTime}] Before")
    dispatcher.scheduler.advanceUntilIdle()
    assertEquals("[2000] After", "[${dispatcher.scheduler.currentTime}] After")
}
1
2
3
4
5
6
7
8
9
fun main() {
    val testDispatcher = StandardTestDispatcher()

    runBlocking(testDispatcher) {
        delay(1)
        println("Coroutine done")
    }
}
// (code runs forever)
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
@Test
fun `advanceTimeBy and runCurrent example`() {
    val testDispatcher = StandardTestDispatcher()

    CoroutineScope(testDispatcher).launch {
        delay(1)
        println("Done1")
    }
    CoroutineScope(testDispatcher).launch {
        delay(2)
        println("Done2")
    }

    testDispatcher.scheduler.advanceTimeBy(2) // Done
    testDispatcher.scheduler.runCurrent() // Done2
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
@Test
fun `advanceTimeBy and runCurrent complex example`() {
    val testDispatcher = StandardTestDispatcher()

    CoroutineScope(testDispatcher).launch {
        delay(2)
        print("Done")
    }

    CoroutineScope(testDispatcher).launch {
        delay(4)
        print("Done2")
    }

    CoroutineScope(testDispatcher).launch {
        delay(6)
        print("Done3")
    }

    for (i in 1..5) {
        print(".")
        testDispatcher.scheduler.advanceTimeBy(1)
        testDispatcher.scheduler.runCurrent()
    } // ..Done..Done2.
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
@Test
fun `advanceUntilIdle example`() {
    val dispatcher = StandardTestDispatcher()

    CoroutineScope(dispatcher).launch {
        delay(1000)
        println("Coroutine done")
    }

    Thread.sleep(Random.nextLong(2000)) // Does not matter
    // how much time we wait here, it will not influence
    // the result

    val time =
        measureTimeMillis {
            println("[${dispatcher.scheduler.currentTime}] Before")
            dispatcher.scheduler.advanceUntilIdle()
            println("[${dispatcher.scheduler.currentTime}] After")
        }

    assertThat(time).isLessThan(50)
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
@OptIn(ExperimentalCoroutinesApi::class)
class TestScopeTest {
    @Test
    fun `TestScope example`() {
        val scope = TestScope()

        scope.launch {
            delay(1000)
            println("First done")
            delay(1000)
            println("Coroutine done")
        }

        println("[${scope.currentTime}] Before") // [0] Before
        assertEquals(0, scope.currentTime)

        scope.advanceTimeBy(1000)
        scope.runCurrent() // First done
        println("[${scope.currentTime}] Middle") // [1000] Middle
        assertEquals(1000, scope.currentTime)

        scope.advanceUntilIdle() // Coroutine done
        println("[${scope.currentTime}] After") // [2000] After
        assertEquals(2000, scope.currentTime)
    }
}

runTest

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
@OptIn(ExperimentalCoroutinesApi::class)
class RunTestTest {
    @Test
    fun `runTest example`() =
        runTest {
            assertEquals(0, currentTime)
            delay(1000)
            assertEquals(1000, currentTime)
        }

    @Test
    fun `runTest example 2`() =
        runTest {
            assertEquals(0, currentTime)
            coroutineScope {
                launch { delay(1000) }
                launch { delay(1500) }
                launch { delay(2000) }
            }
            assertEquals(2000, currentTime)
        }
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
@OptIn(ExperimentalCoroutinesApi::class)
class FetchUserDataTest {
    @Test
    fun `should load data concurrently`() =
        runTest {
            // given
            val userRepo = FakeUserDataRepository()
            val useCase = FetchUserUseCase(userRepo)

            // when
            useCase.fetchUserData()

            // then
            assertEquals(1000, currentTime)
        }

    @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 {
            delay(1000)
            return "Ben"
        }

        override suspend fun getFriends(): List<Friend> {
            delay(1000)
            return listOf(Friend("some-friend-id-1"))
        }

        override suspend fun getProfile(): Profile {
            delay(1000)
            return Profile("Example description")
        }
    }
}

백그라운드 스코프

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
@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)

    // Test would pass if we added
    // coroutineContext.job.cancelChildren()
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
class BackgroundScopeTest {
    @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)
        }
}
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
internal class TestScopeImpl(context: CoroutineContext) :
    AbstractCoroutine<Unit>(context, initParentJob = true, active = true), TestScope {

    override val testScheduler get() = context[TestCoroutineScheduler]!!

    private var entered = false
    private var finished = false
    private val uncaughtExceptions = mutableListOf<Throwable>()
    private val lock = SynchronizedObject()

    **override val backgroundScope: CoroutineScope =
        CoroutineScope(coroutineContext + BackgroundWork + ReportingSupervisorJob {
            if (it !is CancellationException) reportException(it)
        })**

    /** Called upon entry to [runTest]. Will throw if called more than once. */
    fun enter() {
        val exceptions = synchronized(lock) {
            if (entered)
                throw IllegalStateException("Only a single call to `runTest` can be performed during one test.")
            entered = true
            check(!finished)
            /** the order is important: [reportException] is only guaranteed not to throw if [entered] is `true` but
             * [finished] is `false`.
             * However, we also want [uncaughtExceptions] to be queried after the callback is registered,
             * because the exception collector will be able to report the exceptions that arrived before this test but
             * after the previous one, and learning about such exceptions as soon is possible is nice. */
            @Suppress("INVISIBLE_REFERENCE", "INVISIBLE_MEMBER")
            run { ensurePlatformExceptionHandlerLoaded(ExceptionCollector) }
            if (catchNonTestRelatedExceptions) {
                ExceptionCollector.addOnExceptionCallback(lock, this::reportException)
            }
            uncaughtExceptions
        }
        if (exceptions.isNotEmpty()) {
            throw UncaughtExceptionsBeforeTest().apply {
                for (e in exceptions)
                    addSuppressed(e)
            }
        }
    }

    /** Called at the end of the test. May only be called once. Returns the list of caught unhandled exceptions. */
    fun leave(): List<Throwable> = synchronized(lock) {
        check(entered && !finished)
        /** After [finished] becomes `true`, it is no longer valid to have [reportException] as the callback. */
        ExceptionCollector.removeOnExceptionCallback(lock)
        finished = true
        uncaughtExceptions
    }

    /** Called at the end of the test. May only be called once. */
    fun legacyLeave(): List<Throwable> {
        val exceptions = synchronized(lock) {
            check(entered && !finished)
            /** After [finished] becomes `true`, it is no longer valid to have [reportException] as the callback. */
            ExceptionCollector.removeOnExceptionCallback(lock)
            finished = true
            uncaughtExceptions
        }
        val activeJobs = children.filter { it.isActive }.toList() // only non-empty if used with `runBlockingTest`
        if (exceptions.isEmpty()) {
            if (activeJobs.isNotEmpty())
                throw UncompletedCoroutinesError(
                    "Active jobs found during the tear-down. " +
                        "Ensure that all coroutines are completed or cancelled by your test. " +
                        "The active jobs: $activeJobs"
                )
            if (!testScheduler.isIdle())
                throw UncompletedCoroutinesError(
                    "Unfinished coroutines found during the tear-down. " +
                        "Ensure that all coroutines are completed or cancelled by your test."
                )
        }
        return exceptions
    }

    /** Stores an exception to report after [runTest], or rethrows it if not inside [runTest]. */
    fun reportException(throwable: Throwable) {
        synchronized(lock) {
            if (finished) {
                throw throwable
            } else {
                @Suppress("INVISIBLE_MEMBER")
                for (existingThrowable in uncaughtExceptions) {
                    // avoid reporting exceptions that already were reported.
                    if (unwrap(throwable) == unwrap(existingThrowable))
                        return
                }
                uncaughtExceptions.add(throwable)
                if (!entered)
                    throw UncaughtExceptionsBeforeTest().apply { addSuppressed(throwable) }
            }
        }
    }

    /** Throws an exception if the coroutine is not completing. */
    fun tryGetCompletionCause(): Throwable? = completionCause

    override fun toString(): String =
        "TestScope[" + (if (finished) "test ended" else if (entered) "test started" else "test not started") + "]"
}

취소와 컨텍스트 전달 테스트하기

1
2
3
4
5
private suspend fun <T, R> Iterable<T>.mapAsync(transformation: suspend (T) -> R): List<R> =
    coroutineScope {
        this@mapAsync.map { async { transformation(it) } }
            .awaitAll()
    }
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
@Test
fun `should map async and keep elements order`() =
    runTest {
        val transforms =
            listOf(
                suspend {
                    delay(3000)
                    "A"
                },
                suspend {
                    delay(2000)
                    "B"
                },
                suspend {
                    delay(4000)
                    "C"
                },
                suspend {
                    delay(1000)
                    "D"
                },
            )

        val res = transforms.mapAsync { it() }
        assertEquals(listOf("A", "B", "C", "D"), res)
        assertEquals(4000, currentTime)
    }
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
@Test
fun `should support context propagation`() =
    runTest {
        var ctx: CoroutineContext? = null

        val name1 = CoroutineName("Name 1")
        withContext(name1) {
            listOf("A").mapAsync {
                // in transformation
                ctx = currentCoroutineContext() // should be name1
                it
            }
            assertEquals(name1, ctx?.get(CoroutineName))
        }

        val name2 = CoroutineName("Some name 2")
        withContext(name2) {
            listOf(1, 2, 3).mapAsync {
                // in transformation
                ctx = currentCoroutineContext() // should be name2
                it
            }
            assertEquals(name2, ctx?.get(CoroutineName))
        }
    }
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
@Test
fun `should support cancellation`() =
    runTest {
        var job: Job? = null

        val parentJob =
            launch {
                listOf("A").mapAsync {
                    job = currentCoroutineContext().job // refer to a job
                    delay(Long.MAX_VALUE)
                }
            }

        delay(1000)
        parentJob.cancel()
        assertEquals(true, job?.isCancelled) // referred job should be cancelled
    }
1
2
3
4
5
6
7
// Incorrect implementation, that would make above tests fail
suspend fun <T, R> Iterable<T>.mapAsync(
    transformation: suspend (T) -> R
): List<R> =
    this@mapAsync
        .map { GlobalScope.async { transformation(it) } }
        .awaitAll()

UnconfinedTestDispatcher

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
@Test
fun `StandardTestDispatcher vs UnconfinedTestDispatcher`() {
    CoroutineScope(StandardTestDispatcher()).launch {
        print("A")
        delay(1)
        print("B")
    }
    CoroutineScope(UnconfinedTestDispatcher()).launch {
        print("C")
        delay(1)
        print("D")
    }
    // only C will be printed
}
1
2
3
4
@Test
fun testName() = runTest(UnconfinedTestDispatcher()) {
    //...
}

목(mock) 사용하기

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
@OptIn(ExperimentalCoroutinesApi::class)
class MockTest {
    private fun generateRandomString() = UUID.randomUUID().toString()

    @Test
    fun `should load data concurrently`() =
        runTest {
            // given
            val userRepo = mockk<UserDataRepository>()
            val aName = generateRandomString()
            val someFriends =
                listOf(
                    Friend(generateRandomString()),
                    Friend(generateRandomString()),
                    Friend(generateRandomString()),
                )
            val aProfile = Profile(generateRandomString())
            coEvery { userRepo.getName() } coAnswers {
                delay(600)
                aName
            }
            coEvery { userRepo.getFriends() } coAnswers {
                delay(700)
                someFriends
            }
            coEvery { userRepo.getProfile() } coAnswers {
                delay(800)
                aProfile
            }
            val useCase = FetchUserUseCase(userRepo)

            // when
            useCase.fetchUserData()

            // then
            assertEquals(800, currentTime)
        }
}

디스패처를 바꾸는 함수 테스트하기

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
@Test
fun `should change dispatcher`() =
    runBlocking {
        // given
        val csvReader = mockk<CsvReader>()
        val startThreadName = "MyName"
        var usedThreadName: String? = null
        val aFileName = generateRandomString()
        val aGameState = GameState()
        every {
            csvReader.readCsvBlocking(
                aFileName,
                GameState::class.java,
            )
        } coAnswers {
            usedThreadName = Thread.currentThread().name
            aGameState
        }
        val saveReader = SaveReader(csvReader)

        // when
        withContext(newSingleThreadContext(startThreadName)) {
            saveReader.readSave(aFileName)
        }

        // then
        assertNotNull(usedThreadName)
        val expectedPrefix = "DefaultDispatcher-worker-"
        assert(usedThreadName!!.startsWith(expectedPrefix))
    }
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
suspend fun fetchUserData() = withContext(Dispatchers.IO) {
    val name = async { userRepo.getName() }
    val friends = async { userRepo.getFriends() }
    val profile = async { userRepo.getProfile() }
    User(
        name = name.await(),
        friends = friends.await(),
        profile = profile.await()
    )
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
class FetchUserUseCase(
    private val userRepo: UserDataRepository,
    private val ioDispatcher: CoroutineDispatcher = // <-- 생성자를 통해 주입
        Dispatchers.IO
) {

    suspend fun fetchUserData() = withContext(ioDispatcher) {
        val name = async { userRepo.getName() }
        val friends = async { userRepo.getFriends() }
        val profile = async { userRepo.getProfile() }
        User(
            name = name.await(),
            friends = friends.await(),
            profile = profile.await()
        )
    }
}
1
2
3
4
5
6
7
8
val testDispatcher = this
    .coroutineContext[ContinuationInterceptor]
    as CoroutineDispatcher

val useCase = FetchUserUseCase(
    userRepo = userRepo,
    ioDispatcher = testDispatcher,
)
1
2
3
4
val useCase = FetchUserUseCase(
    userRepo = userRepo,
    ioDispatcher = EmptyCoroutineContext,
)

함수 실행 중에 일어나는 일 테스트하기

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
@Test
fun `should show progress bar when sending data`() = runTest {
    // given
    val database = FakeDatabase()
    val vm = UserViewModel(database)

    // when
    launch {
        vm.sendUserData()
    }

    // then
    assertEquals(false, vm.progressBarVisible.value)

    // when
    advanceTimeBy(1000)

    // then
    assertEquals(false, vm.progressBarVisible.value)

    // when
    runCurrent()

    // then
    assertEquals(true, vm.progressBarVisible.value)

    // when
    advanceUntilIdle()

    // then
    assertEquals(false, vm.progressBarVisible.value)
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
@Test
fun `should show progress bar when sending data`() =
    runTest {
        val database = FakeDatabase()
        val vm = UserViewModel(database)
        launch {
            vm.showUserData()
        }

        // then
        assertEquals(false, vm.progressBarVisible.value)
        delay(1000)
        assertEquals(true, vm.progressBarVisible.value)
        delay(1000)
        assertEquals(false, vm.progressBarVisible.value)
    }

새로운 코루틴을 시작하는 함수 테스트하기

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
@Scheduled(fixedRate = 5000) // why?
fun sendNotifications() {
    notificationsScope.launch {
        val notifications = notificationsRepository
            .notificationsToSend()
        for (notification in notifications) {
            launch {
                notificationsService.send(notification)
                notificationsRepository
                    .markAsSent(notification.id)
            }
        }
    }
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
@Test
fun testSendNotifications() {
    // given
    val notifications = List(100) { Notification(it) }
    val repo = FakeNotificationsRepository(
        delayMillis = 200,
        notifications = notifications,
    )
    val service = FakeNotificationsService(
        delayMillis = 300,
    )
    val testScope = TestScope()
    val sender = NotificationsSender(
        notificationsRepository = repo,
        notificationsService = service,
        notificationsScope = testScope
    )

    // when
    sender.sendNotifications()
    testScope.advanceUntilIdle()

    // then all notifications are sent and marked
    assertEquals(
        notifications.toSet(),
        service.notificationsSent.toSet()
    )
    assertEquals(
        notifications.map { it.id }.toSet(),
        repo.notificationsMarkedAsSent.toSet()
    )

    // and notifications are sent concurrently
    assertEquals(700, testScope.currentTime) // why?
}

메인 디스패처 교체하기

코루틴을 시작하는 안드로이드 함수 테스트하기

룰이 있는 테스트 디스패처 설정하기

요약