[Coroutine] ์ฝ”ํ‹€๋ฆฐ ์ฝ”๋ฃจํ‹ด ํ…Œ์ŠคํŠธ

 ๐Ÿ’ก ์ฝ”ํ‹€๋ฆฐ ์ฝ”๋ฃจํ‹ด์—์„œ ํ…Œ์ŠคํŠธํ•˜๋Š” ๋ฐฉ๋ฒ•์— ๋Œ€ํ•˜์—ฌ ํ•™์Šตํ•˜์˜€์Šต๋‹ˆ๋‹ค.

 

์ฝ”ํ‹€๋ฆฐ ์ฝ”ํˆฌ๋ฆฐ ํ…Œ์ŠคํŠธ

  • ๋Œ€๋ถ€๋ถ„์˜ ๊ฒฝ์šฐ ์ค‘๋‹จ ํ•จ์ˆ˜๋ฅผ ํ…Œ์ŠคํŠธํ•˜๋Š” ๊ฒƒ์€ ์ผ๋ฐ˜์ ์ธ ํ•จ์ˆ˜๋ฅผ ํ…Œ์ŠคํŠธํ•˜๋Š” ๊ฒƒ๊ณผ ๋‹ค๋ฅด์ง€ ์•Š์Šต๋‹ˆ๋‹ค.
class FetchUserUseCase(
    private val repo: UserDataRepository
) {
    suspend fun fetchUserData(): User = coroutineScope {
        val name = async { repo.getName() }
        val friends = ascycn { repo.getFriends() }
        val profile async { repo.getProfile() }
        User(
            name = name.await(),
            friends = friends.await(),
            profile profile.await()
        )
    }
}

@Test
class FetchUserDataTest{
    // given 
    val repo = FakeUserDataRepository()
    val useCase = FetchUserUseCase(repo)

    // when
    val result = useCase.fetchUserData()

    // then
    val expectedUser = User(
        name = "Ben",
        friends = listOf(Friend("friend-id-1:")),
        profile = Profile("description")
    )

    assertEquals(expectedUser, result)
}

class FakeUserDataRepository : UserDataRepository
  • ๊ฐ€์งœ ํด๋ž˜์Šค์™€ ๊ฐ„๋‹จํ•œ ์–ด์„ค์…˜์„ ์‚ฌ์šฉํ•ด ์›ํ•˜๋Š” ๋ฐ์ดํ„ฐ๊ฐ€ ๋“ค์–ด์™€์žˆ๋Š”์ง€ ํ™•์ธํ•ฉ๋‹ˆ๋‹ค.
  • ๋ชฉ ๊ฐ์ฒด ๋Œ€์‹  ๊ฐ€์งœ ํด๋ž˜์Šค๋ฅผ ์‚ฌ์šฉํ•˜๋Š” ๊ฒƒ์€ ์™ธ๋ถ€ ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ์— ์˜์กดํ•˜์ง€ ์•Š๊ธฐ ์œ„ํ•จ์ž…๋‹ˆ๋‹ค.

์‹œ๊ฐ„ ์˜์กด์„ฑ ํ…Œ์ŠคํŠธ (StandardTestDispatcher)

suspend fun produceCurrentUserSeq(): User {
    val profile = repo.getProfile()
    val friends = repo.getFriends()
    return User(profie, friends)
} // ์ˆœ์ฐจ์  ์ƒ์„ฑ 

suspend fun produceCurrentUserSym(): User = coroutineScope {
    val profile = async { repo.getProfile() }
    val friends = async { repo.getFriends() }
    User(profile.await(), firends.await())
} // ๋™์‹œ์— ์ƒ์„ฑ
  • ์ฒซ ๋ฒˆ์งธ ํ•จ์ˆ˜๋Š” 2์ดˆ, ๋‘๋ฒˆ์งธ ํ•จ์ˆ˜๋Š” 1์ดˆ๊ฐ€ ๊ฑธ๋ฆฌ๊ฒŒ ๋ฉ๋‹ˆ๋‹ค.
  • ๋‘ ํ•จ์ˆ˜์˜ ์ฐจ์ด๋ฅผ ์–ด๋–ป๊ฒŒ ํ…Œ์ŠคํŠธํ•  ์ง€ ์ƒ๊ฐํ•ด๋ณผ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.
class FakeDelayedUserDataRepository : UserDataRepository // ๋”œ๋ ˆ์ด๋ฅผ ์ฃผ๋Š” ํ•จ์ˆ˜
  • ์‹ค์ œ๋กœ ํ…Œ์ŠคํŠธ์— ๋”œ๋ ˆ์ด๋ฅผ ์ค˜์„œ ์‹œ๊ฐ„์„ ์ค„์ผ ์ˆ˜ ์žˆ์ง€๋งŒ, ํ…Œ์ŠคํŠธ ์ „์ฒด๋ฅผ ์ˆ˜ํ–‰ํ•˜๋Š” ์‹œ๊ฐ„์„ ์ฆ๊ฐ€์‹œํ‚ค๊ฒŒ ๋ฉ๋‹ˆ๋‹ค.
  • ์ด๋ฅผ ํ•ด๊ฒฐํ•˜๊ธฐ ์œ„ํ•ด์„œ kotlinx-coroutines-test ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ์—์„œ ์ œ๊ณตํ•˜๋Š” StandardTestDispatcher๋ฅผ ์‚ฌ์šฉํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

TestCoroutineScheduler์™€ StandardTestDispatcher

  • TestCoroutineScheduler๋Š” delay๋ฅผ ๊ฐ€์ƒ ์‹œ๊ฐ„ ๋™์•ˆ ์‹คํ–‰ํ•˜์—ฌ ์‹ค์ œ ์‹œ๊ฐ„์ด ํ˜๋Ÿฌ๊ฐ„ ์ƒํ™ฉ๊ณผ ๋™์ผํ•˜๊ฒŒ ์ž‘๋™ํ•˜๊ธฐ ๋•Œ๋ฌธ์— ์ •ํ•ด์ง„ ์‹œ๊ฐ„๋งŒํผ ๊ธฐ๋‹ค๋ฆฌ์ง€ ์•Š๋„๋ก ๋ณ€๊ฒฝํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.
  • StandardTestDispatcher๋Š” ๋‹ค๋ฅธ ๋””์ŠคํŒจ์ฒ˜์™€ ๋‹ฌ๋ฆฌ ์ฝ”๋ฃจํ‹ด์ด ์‹คํ–‰๋˜์–ด์•ผ ํ•  ์Šค๋ ˆ๋“œ๋ฅผ ๊ฒฐ์ •ํ•  ๋•Œ๋งŒ ์‚ฌ์šฉ๋˜๋Š” ๊ฒƒ์€ ์•„๋‹ˆ๋ฉฐ, ํ…Œ์ŠคํŠธ ๋””์ŠคํŒจ์ฒ˜๋กœ ์‹œ์ž‘๋œ ์ฝ”๋ฃจํ‹ด์€ ๊ฐ€์ƒ ์‹œ๊ฐ„๋งŒํผ ์ง„ํ–‰๋˜๊ธฐ ์ „๊นŒ์ง€ ์‹คํ–‰๋˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค.
  • ์‹ค์ œ ์‹œ๊ฐ„์ฒ˜๋Ÿผ ์ž‘๋™ํ•˜๋Š” ๊ฐ€์ƒ ์‹œ๊ฐ„์ด ํ๋ฅด๊ฒŒ ํ•˜์—ฌ, ๊ทธ ์‹œ๊ฐ„ ๋™์•ˆ ํ˜ธ์ถœ๋  ๋ชจ๋“  ์ž‘์—…์„ ์‹คํ–‰ํ•˜๋Š” advanceUntilIdle์„ ์‚ฌ์šฉํ•ฉ๋‹ˆ๋‹ค.
fun main() {
    val scheduler = TestCoroutineScheduler()
    val testDispatcher = StandardTestDispatcher(scheduler)

    CoroutineScope(testDispatcher).launch {
        println("task1")
        delay(1000)
        println("task2")
        delay(1000)
        println("done")
    }

    println() // scheduer.currentTime Before
    scheduler.advanceUntilIdle()
    println() // scheduer.currentTime After
}
// [0] Before
// task1
// task2
// done
// [2000] After
  • ๊ธฐ๋ณธ์ ์œผ๋กœ StandardTestDispatcher๋Š” TestCoroutineScheduler๋ฅผ ๋งŒ๋“ค๊ธฐ ๋•Œ๋ฌธ์— ๋ช…์‹œ์ ์œผ๋กœ ๋งŒ๋“ค์ง€ ์•Š์•„๋„ ๋ฉ๋‹ˆ๋‹ค.
  • StandardTestDispatcher๋Š” ์ง์ ‘ ์‹œ๊ฐ„์„ ํ๋ฅด๊ฒŒ ํ•˜์ง€ ์•Š์œผ๋ฉฐ, advanceUntilIdle๋ฅผ ์‹คํ–‰ํ•ด์„œ StandardTestDispatcher๋ฅผ ๋ช…์‹œํ•˜์ง€ ์•Š์œผ๋ฉด ์ฝ”๋ฃจํ‹ด์ด ์žฌ๊ฐœ๋˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค.
    • ์ด๋Š” ์ฝ”๋“œ๊ฐ€ ์˜์›ํžˆ ์‹คํ–‰๋จ์„ ์˜๋ฏธํ•ฉ๋‹ˆ๋‹ค.

advanceTimeBy

  • advanceUntilIdle์ฒ˜๋Ÿผ ์‹œ๊ฐ„์„ ํ๋ฅด๊ฒŒํ•˜๋Š” ๋˜๋‹ค๋ฅธ ๋ฐฉ๋ฒ•์ด๋ฉฐ, advanceTimeBy์— ์ผ์ • ๋ฐ€๋ฆฌ์ดˆ๋ฅผ ์ธ์ž๋กœ ๋„ฃ์–ด ํ™œ์šฉํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.
  • advanceTimeBy๋Š” ์‹œ๊ฐ„์„ ํ๋ฅด๊ฒŒ ํ•˜๊ณ  ๊ทธ๋™์•ˆ ์ผ์–ด๋‚ฌ์„ ๋ชจ๋“  ์—ฐ์‚ฐ์„ ์ˆ˜ํ–‰ํ•ฉ๋‹ˆ๋‹ค.
  • runCurrent ํ•จ์ˆ˜๋ฅผ ์ถ”๊ฐ€๋กœ ์‚ฌ์šฉํ•˜์—ฌ ์ •ํ™•ํžˆ ์ผ์น˜ํ•˜๋Š” ์‹œ๊ฐ„์— ์˜ˆ์ •๋œ ์—ฐ์‚ฐ์„ ์žฌ๊ฐœํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.
val testDispatcher = StandardTestDispatcher()

CoroutineScope(testDispatcher).launch // task1 
CoroutineScope(testDispatcher).launch // task2 

testDispatcher.scheduler.advanceTimeBy(2) // task1 ์ˆ˜ํ–‰ 
testDispatcher.scheduler.runCurrent() // task2 ์ˆ˜ํ–‰

๊ฐ€์ƒ ์‹œ๊ฐ„์ด ์ž‘๋™ํ•˜๋Š” ๋ฐฉ๋ฒ•

  • delay๊ฐ€ ํ˜ธ์ถœ๋˜๋ฉด ๋””์ŠคํŒจ์ฒ˜๊ฐ€ Delay ์ธํ„ฐํŽ˜์ด์Šค๋ฅผ ๊ตฌํ˜„ํ–ˆ๋Š”์ง€ ํ™•์ธํ•˜๊ณ , ์‹ค์ œ ์‹œ๊ฐ„๋งŒํผ ๊ธฐ๋‹ค๋ฆฌ๋Š” DefaultDelay ๋Œ€์‹  ๋””์ŠคํŒจ์ฒ˜๊ฐ€ ๊ฐ€์ง„ scheduleResumeAfterDelay ํ•จ์ˆ˜๋ฅผ ํ˜ธ์ถœํ•ฉ๋‹ˆ๋‹ค.
  • ๊ฐ€์ƒ ์‹œ๊ฐ„์€ ์‹ค์ œ ์‹œ๊ฐ„๊ณผ ๋ฌด๊ด€ํ•˜๊ฒŒ ๋™์ž‘ํ•˜๋ฉฐ, Thread.sleep์€ StandardTestDispatcher์˜ ์ฝ”๋ฃจํ‹ด์— ์˜ํ–ฅ์„ ์ฃผ์ง€ ์•Š์Šต๋‹ˆ๋‹ค.

advanceUntilIdle

  • ๋ช‡ ๋ฐ€๋ฆฌ์ดˆ ์ด๋‚ด์˜ ๊ฐ€์ƒ ์‹œ๊ฐ„๋งŒ ํ๋ฅด๊ฒŒํ•˜์—ฌ ์ฝ”๋ฃจํ‹ด ์—ฐ์‚ฐ์„ ์‹คํ–‰ ๊ฐ€๋Šฅํ•˜๊ฒŒ ํ•˜๋Š” ํ•จ์ˆ˜์ž…๋‹ˆ๋‹ค.
val dispatcher = StandardTestDispathcer()

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

Thread.sleep(Random.nextLong(2000))
// ์—ฌ๊ธฐ์„œ ์–ผ๋งˆ๋‚˜ ๊ธฐ๋‹ค๋ฆฌ๋Š”์ง€ ์ƒ๊ด€ ์—†์ด ๊ฒฐ๊ณผ์— ์˜ํ–ฅ์„ ์ฃผ์ง€ ์•Š์Šต๋‹ˆ๋‹ค.

println("${dispatcher.scheduler.currentTime} Before")
dispatcher.scheduler.advanceUntilIdle() // advanceUntilIdleํ™œ์šฉ
println("${dispatcher.scheduler.currentTime} After")

// [0] Before
// Done
// [1000] After

TestScope

  • ์Šค์ฝ”ํ”„๋งŒ์œผ๋กœ ํ•ด๋‹น ํ•จ์ˆ˜์™€ ํ”„๋กœํผํ„ฐ๋ฅผ ์‚ฌ์šฉํ•  ์ˆ˜ ์žˆ๋Š” ์Šค์ฝ”ํ”„์ž…๋‹ˆ๋‹ค.
  • StandardTestDispatcher์™€ ๊ฐ™์€ ์—ญํ• ์„ ์ˆ˜ํ–‰ํ•˜๋ฉฐ, CoroutineExceptionHandler๋กœ ๋ชจ๋“  ์˜ˆ์™ธ๋ฅผ ๋ชจ์„ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.
val scope = TestScope()
scope.launch {
    delay(1000)
}
scope.advanceTimeBy(1000)
scope.runCurrent()
scope.advanceUntilIdle()
// StandardTestDispatcher์˜ ํ•จ์ˆ˜๋ฅผ ๋˜‘๊ฐ™์ด ์‚ฌ์šฉํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค
  • ์•ˆ๋“œ๋กœ์ด๋“œ ๋ทฐ ๋ชจ๋ธ, ํ”„๋ ˆ์  ํ„ฐ, ํ”„๋ž˜๊ทธ๋จผํŠธ๋ฅผ ํ…Œ์ŠคํŠธํ•  ๋•Œ StandardTestDispatcher๋ฅผ ์ž์ฃผ ์‚ฌ์šฉํ•ฉ๋‹ˆ๋‹ค.
  • ํ…Œ์ŠคํŠธ ๋””์ŠคํŒจ์ฒ˜๋ฅผ ์‚ฌ์šฉํ•ด produceCurrentUseSeq์™€ produceCurrentUserSym ํ•จ์ˆ˜๋ฅผ ์ฝ”๋ฃจํ‹ด์—์„œ ์‹œ์ž‘ํ•˜๊ณ , ์œ ํœด ์ƒํƒœ๊ฐ€ ๋  ๋•Œ๊นŒ์ง€ ์‹œ๊ฐ„์„ ํ๋ผ๊ฒŒํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.
    • [์œ ํœด ์ƒํƒœ] : ์ปดํ“จํ„ฐ ์‹œ์Šคํ…œ์ด ์‚ฌ์šฉ ๊ฐ€๋Šฅํ•œ ์ƒํƒœ์ด๋‚˜ ์‹ค์ œ์ ์ธ ์ž‘์—…์ด ์—†๋Š” ์‹œ๊ฐ„
    • ์–ผ๋งˆ๋‚˜ ์‹œ๊ฐ„์ด ๋งŽ์ด ํ˜๋ €๋Š”์ง€ ํ™•์ธํ•˜๋Š” ๋ฐฉ์‹์œผ๋กœ ํ…Œ์ŠคํŠธํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.
    • ํ•˜์ง€๋งŒ ์ด๋Ÿฌํ•œ ์„ค๊ณ„๋Š” ๋ณต์žกํ•˜๊ธฐ ๋•Œ๋ฌธ์— runTest๋ฅผ ์ฃผ๋กœ ํ™œ์šฉํ•ฉ๋‹ˆ๋‹ค.

runTest

  • kotlinx-coroutines-test์˜ ํ•จ์ˆ˜๋“ค ์ค‘ ๊ฐ€์žฅ ํ”ํ•˜๊ฒŒ ์‚ฌ์šฉ๋ฉ๋‹ˆ๋‹ค.
  • TestScope์—์„œ ์ฝ”๋ฃจํ‹ด์„ ์‹œ์ž‘ํ•˜๊ณ  ์ฆ‰์‹œ ์œ ํœด ์ƒํƒœ๊ฐ€ ๋  ๋•Œ๊นŒ์ง€ ์‹œ๊ฐ„์„ ํ๋ฅด๊ฒŒ ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.
  • TestScope + advanceUntilIdle()๊ฐ€ ํ•ฉ์ณ์ง„ ๊ธฐ๋Šฅ์„ํ•˜๋ฉฐ, runBlocking()๊ณผ ๊ฐ™์€ ๋™์ž‘์„ ํ•ฉ๋‹ˆ๋‹ค.
  • ์ฝ”๋ฃจํ‹ด์—์„œ ์‹œ๊ฐ„์ด ์–ผ๋งˆ๋‚˜ ํ˜๋ €๋Š”์ง€ ํ™•์ธํ•  ์ˆ˜ ์žˆ์œผ๋ฉฐ, ํ…Œ์ŠคํŠธํ•˜๋Š” ๋ฐ ๋ช‡ ๋ฐ€๋ฆฌ์ดˆ๋งŒ ์†Œ๋ชจํ•ฉ๋‹ˆ๋‹ค.
class Test{
    @Test
    fun test1() = runTest {
        assertEquals(0, currentTime)
        coroutineScope {
            launch { delay(1000) }
            launch { delay(1500) }
            launch { delay(2000) }
        }
        assertEquals(2000, currentTime)
    }
}
  • runTest๋ฅผ ์‚ฌ์šฉํ•˜๋ฉด ๊ฐ€์งœ ์ €์žฅ์†Œ์˜ ๊ฐ ํ•จ์ˆ˜๋ฅผ ๋งค์šฐ ๋น ๋ฅด๊ฒŒ ์ฒ˜๋ฆฌํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.
  • ๋˜ํ•œ ๊ฐ€์ƒ ์‹œ๊ฐ„์„ ํ™œ์šฉํ•˜๊ธฐ ๋•Œ๋ฌธ์— ํ…Œ์ŠคํŠธ๋Š” ์ฆ‰์‹œ ๋๋‚˜๊ฒŒ ๋˜๋ฉฐ, ์ •ํ™•ํ•œ currentTime ๊ฐ’์„ ์–ป์„ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.
@Test
fun `Should produce user sequentially`() = runTest {
    // given 
    val userDataRepository = FakeDelayUserDataRepository()
    val userCase = ProduceUserUseCase(userDataRepository)

    // when
    useCase.produceCurrentUserSeq()

    //then
    assertEquals(2000, currentTime)
}
  • runTest์™€ ๋‹ค๋ฅธ ์‹œ๊ฐ„ ์˜์กด์„ฑ ํ…Œ์ŠคํŠธ์™€์˜ ๊ด€๊ณ„๋Š” ์•„๋ž˜์™€ ๊ฐ™์Šต๋‹ˆ๋‹ค.

๋ฐฑ๊ทธ๋ผ์šด๋“œ ์Šค์ฝ”ํ”„

  • runTest ํ•จ์ˆ˜๋Š” ๋‹ค๋ฅธ ํ•จ์ˆ˜์ฒ˜๋Ÿผ ์Šค์ฝ”ํ”„๋ฅผ ๋งŒ๋“ค๋ฉฐ, ์ž์‹ ์ฝ”๋ฃจํ‹ด์ด ๋๋‚  ๋•Œ๊นŒ์ง€ ๊ธฐ๋‹ค๋ฆฝ๋‹ˆ๋‹ค.
  • ์ ˆ๋Œ€ ๋๋‚˜์ง€ ์•Š๋Š” ํ”„๋กœ์„ธ์Šค๋ฅผ ์‹œ์ž‘ํ•œ๋‹ค๋ฉด ํ…Œ์ŠคํŠธ ๋˜ํ•œ ์ข…๋ฃŒ๋˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค.
@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.cancelChilderen()์„ ์ถ”๊ฐ€ํ•˜๋ฉด ํ…Œ์ŠคํŠธ๊ฐ€ ํ†ต๊ณผํ•ฉ๋‹ˆ๋‹ค.
    // ํ˜„์žฌ ์ฝ”๋“œ๋กœ๋Š” ํ…Œ์ŠคํŠธ๊ฐ€ ์ข…๋ฃŒ๋˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค...
}

backgroundScope

  • ์ด๋Ÿฐ ๊ฒฝ์šฐ๋ฅผ ํ•ด๊ฒฐํ•˜๊ธฐ ์œ„ํ•ด์„œ runTest์—์„œ๋Š” backgroundScope๋ฅผ ์ œ๊ณตํ•ฉ๋‹ˆ๋‹ค.
  • ์ด ๋˜ํ•œ ๊ฐ€์ƒ ์‹œ๊ฐ„์„ ์ง€์›ํ•˜์ง€๋งŒ, runTest๊ฐ€ ์Šค์ฝ”ํ”„ ์ข…๋ฃŒ๋ฅผ ๊ธฐ๋‹ค๋ฆฌ์ง€ ์•Š์Šต๋‹ˆ๋‹ค.
backgroundScope.launch {
    // ์œ„ ์ฝ”๋“œ์™€ ๋™์ผํ•œ ๋‚ด์šฉ์ž…๋‹ˆ๋‹ค
}

์ทจ์†Œ์™€ ์ปจํ…์ŠคํŠธ ์ „๋‹ฌ ํ…Œ์ŠคํŠธ

  • ํŠน์ • ํ•จ์ˆ˜๊ฐ€ ๊ตฌ์กฐํ™”๋œ ๋™์‹œ์„ฑ์„ ์ง€ํ‚ค๋Š”์ง€ ํ…Œ์ŠคํŠธํ•˜๋ ค๋ฉด ์•„๋ž˜์™€ ๊ฐ™์€ ๊ฐ€์žฅ ์‰ฌ์šด ๋ฐฉ๋ฒ•์ด ์žˆ์Šต๋‹ˆ๋‹ค.
    • ์ค‘๋‹จ ํ•จ์ˆ˜๋กœ๋ถ€ํ„ฐ ์ปจํ…์ŠคํŠธ๋ฅผ ๋ฐ›์€ ๋’ค ์ปจํ…์ŠคํŠธ๊ฐ€ ๊ธฐ๋Œ€ํ•œ ๊ฐ’์„ ๊ฐ€์ง€๊ณ  ์žˆ๋Š”์ง€ ํ™•์ธ
    • ์ค‘๋‹จ ํ•จ์ˆ˜๋กœ๋ถ€ํ„ฐ ์ปจํ…์ŠคํŠธ๋ฅผ ๋ฐ›์€ ๋’ค ์žก์ด ์ ์ ˆํ•œ ์ƒํƒœ์ธ์ง€ ํ™•์ธ
suspend fun <T, R> Iterable<T>.mapAsync(
    transformation: suspend (T) -> R
): List<R> = coroutineScope {
    this@mapAsync.map { async { transformation(it) } }
        .awaitAll()
}
  • ์œ„ ํ•จ์ˆ˜๋Š” ์ˆœ์„œ๋ฅผ ๋ณด์žฅํ•˜๋ฉด์„œ ๋น„๋™๊ธฐ์ ์œผ๋กœ ์›์†Œ๋ฅผ ๋งคํ•‘ํ•ฉ๋‹ˆ๋‹ค.
  • ์•„๋ž˜์™€ ๊ฐ™์ด ํ…Œ์ŠคํŠธํ•ด๋ณผ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.
@Test
fun `sould 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)
}
  • ์œ„ ํ…Œ์ŠคํŠธ๋Š” ๊ตฌ์กฐํ™”๋œ ๋™์‹œ์„ฑ์„ ์ง€ํ‚ค๋Š” ์ค‘๋‹จ ํ•จ์ˆ˜๊ฐ€ ์ •ํ™•ํ•˜๊ฒŒ ๊ตฌํ˜„๋˜์—ˆ๋‹ค๊ณ  ๋ณด๊ธฐ๋Š” ์–ด๋ ค์šฐ๋ฉฐ ์ด์œ ๋Š” ์•„๋ž˜์™€ ๊ฐ™์Šต๋‹ˆ๋‹ค.
    • ๋ถ€๋ชจ ์ฝ”๋ฃจํ‹ด๊ณผ ๊ฐ™์€ ์ปจํ…์ŠคํŠธ๋ฅผ ์‚ฌ์šฉํ•˜๋Š”์ง€ ํ™•์ธํ•˜์ง€ ์•Š์Œ
    • ์ทจ์†Œ ์ƒํ™ฉ์„ ํ™•์ธํ•˜์ง€ ์•Š์Œ

๋ถ€๋ชจ ์ฝ”๋ฃจํ‹ด๊ณผ ๊ฐ™์€ ์ปจํ…์ŠคํŠธ๋ฅผ ์‚ฌ์šฉํ•˜๋Š”์ง€ ํ…Œ์ŠคํŠธ

  • ๋ถ€๋ชจ ์ฝ”๋ฃจํ‹ด์—์„œ CoroutineName๊ณผ ๊ฐ™์€ ํŠน์ • ์ปจํ…์ŠคํŠธ๋ฅผ ๋ช…์‹œํ•˜์—ฌ transformation ํ•จ์ˆ˜์—์„œ ๊ทธ๋Œ€๋กœ์ธ์ง€ ํ™•์ธํ•˜๋Š” ๋ฐฉ๋ฒ•์„ ์‚ฌ์šฉํ•ฉ๋‹ˆ๋‹ค.
  • ์ค‘๋‹จ ํ•จ์ˆ˜์—์„œ ์ปจํ…์ŠคํŠธ๋ฅผ ํ™•์ธํ•˜๋ ค๋ฉด currentCoroutineContext ํ•จ์ˆ˜๋‚˜ coroutineContext ํ”„๋กœํผํ‹ฐ๋ฅผ ์‚ฌ์šฉํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.
// in runTest
val ctx: CoroutineContext? = null
val name = CoroutineName("Name")
withContext(name) {
    listOf("A").mapAsync {
        ctx = currentCoroutineContext()
        it
    }
    assertEquals(name, ctx?.get(CoroutineName))
}

์ทจ์†Œ ์ƒํ™ฉ์„ ํ™•์ธ

  • ์ทจ์†Œ๋ฅผ ํ™•์ธํ•˜๋Š” ๋ฐฉ๋ฒ•์€ ์•„๋ž˜์˜ ์ˆœ์„œ๋กœ ์ง„ํ–‰๋ฉ๋‹ˆ๋‹ค.
    • ๋‚ด๋ถ€ ํ•จ์ˆ˜์—์„œ ์žก์„ ์ฐธ์กฐํ•ฉ๋‹ˆ๋‹ค.
    • ์™ธ๋ถ€ ์ฝ”๋ฃจํ‹ด์—์„œ ์ฝ”๋ฃจํ‹ด์„ ์ทจ์†Œํ•ฉ๋‹ˆ๋‹ค.
    • ์ฐธ์กฐ๋œ ์žก์ด ์ทจ์†Œ๋œ ๊ฒƒ์„ ํ™•์ธํ•ฉ๋‹ˆ๋‹ค.
// in runTest
val job: Job? = null
val parentJob = launch { // ๋‚ด๋ถ€ ํ•จ์ˆ˜์—์„œ ์žก ์ฐธ์กฐ 
    listOf("A").mapAsync {
        job = currentCoroutineContext().job
        delay(Long.MAX_VALUE)
    }
}
delay(1000)
parentJob.cancel() // ์™ธ๋ถ€ ์ฝ”๋ฃจํ‹ด ์ทจ์†Œ 
assertEquals(true, job?.isCancelled) // ์ฐธ์กฐ๋œ ์žก์ด ์ทจ์†Œ๋œ ๊ฒƒ์„ ํ™•์ธ

ํ…Œ์ŠคํŠธ์— ํ†ต๊ณผํ•˜์ง€ ๋ชปํ•˜๋Š” ๊ฒฝ์šฐ

  • ๋Œ€๋ถ€๋ถ„์˜ ์• ํ”Œ๋ฆฌ์ผ€์ด์…˜์—์„œ๋Š” ์œ„์™€ ๊ฐ™์€ ํ…Œ์ŠคํŠธ๊ฐ€ ํ•„์š”ํ•˜์ง€ ์•Š์ง€๋งŒ, ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ์—์„œ๋Š” ํ™œ์šฉ๋ฉ๋‹ˆ๋‹ค.
  • ๋งŒ์•ฝ async๋ฅผ ์™ธ๋ถ€ ์Šค์ฝ”ํ”„์—์„œ ์‹คํ–‰ํ–ˆ๋‹ค๋ฉด ์œ„ ํ…Œ์ŠคํŠธ์—์„œ๋Š” ์‹คํŒจํ•ฉ๋‹ˆ๋‹ค.
    • ๊ตฌ์กฐํ™”๋œ ๋™์‹œ์„ฑ์„ ์ง€ํ‚ค์ง€ ๋ชปํ•˜๋Š” ๊ฒฝ์šฐ
suspend fun <T,R> Iterable<T>.mapAsync(
    transformation: suspend (T) -> R
): List<R> =
    this@mapAsync
        .map { GlobalScope.async { transformation(it) } }
        .awaitAll()

UnconfinedTestDisptcher

  • StandardTestDispatcher์™€ ๋น„์Šทํ•œ ๊ธฐ๋Šฅ์„ ํ•˜๋Š” UnconfinedTestDisptcher ๋””์ŠคํŒจ์ฒ˜๊ฐ€ ์žˆ์Šต๋‹ˆ๋‹ค.
  • StandardTestDispatcher์™€์˜ ์ฐจ์ด์ ์€ StandardTestDispatcher๋Š” ์Šค์ผ€์ฅด๋Ÿฌ๋ฅผ ์‚ฌ์šฉํ•˜๊ธฐ ์ „๊นŒ์ง€ ์–ด๋–ค ์—ฐ์‚ฐ๋„ ์ˆ˜ํ–‰ํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค.
  • UnconfinedTestDisptcher๋Š” ์ฝ”๋ฃจํ‹ด์„ ์‹œ์ž‘ํ–ˆ์„ ๋•Œ ์ฒซ ๋ฒˆ์งธ ์ง€์—ฐ์ด ์ผ์–ด๋‚˜๊ธฐ ์ „๊นŒ์ง€ ๋ชจ๋“  ์—ฐ์‚ฐ์„ ์ฆ‰์‹œ ์ˆ˜ํ–‰ํ•ฉ๋‹ˆ๋‹ค.
fun main() {
    CoroutineScope(StandardTestDispatcher()).launch {
        print("A")
        delay(1)
        print("B")
    }
    CoroutineScope(UnconfinedTestDisptcher()).launch {
        print("C")
        delay(1)
        print("D")
    }
}
// ์ฆ‰์‹œ ์ˆ˜ํ–‰ํ•˜๋ฏ€๋กœ, C๋ฅผ ๊ฐ€์žฅ ๋จผ์ € ์ถœ๋ ฅ 
// C
  • ์•„๋ž˜์™€ ๊ฐ™์ด runTest์— ์ด์‹ํ•ด์„œ ์‚ฌ์šฉํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.
@Test
fun testName() = runTest(UnconfinedTestDisptcher())

๋ชฉ(mock) ์‚ฌ์šฉํ•˜๊ธฐ

  • ๋ชจํ‚น ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ๋ฅผ ํ™œ์šฉํ•˜๋ฉด ๋ชจ๋“  ํ…Œ์ŠคํŠธ๊ฐ€ ์‚ฌ์šฉํ•˜๋Š” ์ €์žฅ์†Œ์˜ ์ธํ„ฐํŽ˜์ด์Šค๋ฅผ ๋ฐ”๊พธ๋Š” ์ƒํ™ฉ์„ ํ”ผํ•  ์ˆ˜ ์žˆ๊ณ , ์ผ๋ถ€ ํด๋ž˜์Šค๋งŒ ์ˆ˜์ •ํ•˜์—ฌ ์ด๋ฅผ ํ•ด๊ฒฐํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.
@Test
fun `should load data concurrently`() = runTest {
    //given
    val userRepo = mockk<UserDataRepository>()
    coEvery { userRepo.getName() } coAnswers {
        delay(600)
        someFriends
    }
    val useCase = FetchUserUseCase(userRepo)

    // when
    useCase.fetchUserData()

    // then
    assertEquals(800, currentTime)
}

๋””์ŠคํŒจ์ฒ˜๋ฅผ ๋ฐ”๊พธ๋Š” ํ•จ์ˆ˜ ํ…Œ์ŠคํŠธ

  • ์ผ๋ฐ˜์ ์œผ๋กœ ๋ธ”๋กœํ‚น ํ˜ธ์ถœ์ด ๋งŽ๋‹ค๋ฉด Dispatchers.IO(or ์ปค์Šคํ…€ ๋””์ŠคํŒจ์ฒ˜)๋ฅผ ํ˜ธ์ถœํ•˜๊ณ , CPU ์žก์•ฝ์ ์ธ ํ˜ธ์ถœ์ด ๋งŽ๋‹ค๋ฉด Dispatchers.Default๋ฅผ ํ˜ธ์ถœํ•ฉ๋‹ˆ๋‹ค.
  • ๋‘ ๊ฒฝ์šฐ ๋ชจ๋‘ ๋™์‹œ์— ์‹คํ–‰๋  ํ•„์š”๊ฐ€ ๊ฑฐ์˜ ์—†๊ณ  runBlocking์„ ์‚ฌ์šฉํ•ด ํ…Œ์ŠคํŠธํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.
  • ํ•˜์ง€๋งŒ ํ•จ์ˆ˜๊ฐ€ ์‹ค์ œ๋กœ ๋””์ŠคํŒจ์ฒ˜๋ฅผ ๋ฐ”๊พธ๋Š” ๊ฒฝ์šฐ๋Š” ์–ด๋–ป๊ฒŒ ํ™•์ธํ•  ์ˆ˜ ์žˆ์„์ง€ ์ƒ๊ฐํ•ด๋ด์•ผ ํ•ฉ๋‹ˆ๋‹ค.
    • ํ˜ธ์ถœํ•˜๋Š” ํ•จ์ˆ˜๋ฅผ ๋ชจํ‚นํ•˜์—ฌ, ์‚ฌ์šฉํ•œ ์Šค๋ ˆ๋“œ์˜ ์ด๋ฆ„์„ ๊ฐ€์ง€๊ณ  ์˜ค๋Š” ๋ฐฉ๋ฒ•์œผ๋กœ ํ™•์ธํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.
@Test
fun `should change dispatcher`() = runBlocking {
    // given
    val csvReader = mockk<CsvReader>()
    val startThreadName = "MyName"
    val userThreadName: String? = null
    every {
        scvReader.readCsvBlocking(
            aFileName,
            GameState::class.java
        )
    } coAnswers {
        usedThreadName = Thread.currentThread().name
        aGameState
    }

    val saveReader = SaveReader(scvReader)

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

    //then
    assertNotNull(usedThreadName)
    val expectedPrefix = "DefaultDispatcher-worker-"
    assert(usedThreadName!!.startsWith(expetedPrefix))
}

ํ•จ์ˆ˜ ์‹คํ–‰ ์ค‘์— ์ผ์–ด๋‚˜๋Š” ์ผ ํ…Œ์ŠคํŠธ

  • ์•„๋ž˜์™€ ๊ฐ™์ด ์‹คํ–‰ ์ค‘์— ํ”„๋กœ๊ทธ๋ ˆ์Šค ๋ฐ”๋ฅผ ๋ณด์—ฌ์ฃผ๊ณ  ์ˆจ๊ธฐ๋Š” ํ•จ์ˆ˜๋ฅผ ์ž‘์„ฑํ•˜์˜€์Šต๋‹ˆ๋‹ค.
suspend fun sendUserData() {
    val userData = database.getUserData()
    progressBarVisible.value = true
    userRepository.sendUserData(userData)
    progressBarVisible.value = false
}
  • ์ตœ์ข… ๊ฒฐ๊ณผ๋Š” ๊ฒฐ๊ตญ false ๋กœ ์ „ํ™˜๋˜๋ฏ€๋กœ, ์‹ค์ œ ํ”„๋กœ๊ทธ๋ ˆ์Šค ๋ฐ”์˜ ์ƒํƒœ๊ฐ€ ๋ณ€๊ฒฝ๋˜์—ˆ๋Š”์ง€ ํ™•์ธํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.
  • ์ด๋Ÿฐ ๊ฒฝ์šฐ ํ•จ์ˆ˜๋ฅผ ์ƒˆ๋กœ์šด ์ฝ”๋ฃจํ‹ด์—์„œ ์‹œ์ž‘ํ•˜๊ณ  ๋ฐ”๊นฅ์—์„œ ๊ฐ€์ƒ ์‹œ๊ฐ„์„ ์กฐ์ ˆํ•˜๋Š” ๋ฐฉ๋ฒ•์„ ์‚ฌ์šฉํ•ฉ๋‹ˆ๋‹ค.

runTest ํ™œ์šฉ

  • runTest๋Š” ์ฝ”๋ฃจํ‹ด์˜ ๋””์ŠคํŒจ์ฒ˜๋กœ StandardTestDispatcher๋ฅผ ์ง€์ •ํ•˜๋ฉฐ ๋Œ€๊ธฐ ์ƒํƒœ๊ฐ€ ๋  ๋•Œ๊นŒ์ง€ ์‹œ๊ฐ„์ด ํ๋ฅด๊ฒŒ ํ•ฉ๋‹ˆ๋‹ค.
    • advanceUntilIdle ํ•จ์ˆ˜๋ฅผ ์‚ฌ์šฉ
  • ์ž์‹ ์ฝ”๋ฃจํ‹ด์˜ ์‹œ๊ฐ„์€ ๋ถ€๋ชจ๊ฐ€ ์ž์‹์„ ๊ธฐ๋‹ค๋ฆฌ๊ธฐ ์‹œ์ž‘ํ–ˆ์„ ๋•Œ (ํ•จ์ˆ˜ ๋ณธ์ฒด์˜ ์‹คํ–‰์„ ๋๋ƒˆ์„ ๋•Œ) ํ๋ฅด๊ฒŒ ๋ฉ๋‹ˆ๋‹ค.
    • ๊ทธ ์ด์ „์—๋Š” ๊ฐ€์ƒ ์‹œ๊ฐ„์„ ์กฐ์ ˆํ•  ์ˆ˜ ์žˆ์Œ
@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.valule)
        delay(1000)
        assertEquals(true, vm.progressBarVisible.valule)
        delay(1000)
        assertEquals(false, vm.progressBarVisible.valule)
    }
  • ์ด ๋•Œ delay๋ณด๋‹ค๋Š” advanceTimeBy์™€ ๊ฐ™์€ ๋ช…์‹œ์ ์ธ ํ•จ์ˆ˜๋ฅผ ์‚ฌ์šฉํ•˜๋Š” ๋ฐฉ๋ฒ•์ด ๊ฐ€๋…์„ฑ์ด ์ข‹์„ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

์ƒˆ๋กœ์šด ์ฝ”๋ฃจํ‹ด์„ ์‹œ์ž‘ํ•˜๋Š” ํ•จ์ˆ˜ ํ…Œ์ŠคํŠธ

  • ๋ฐฑ์—”๋“œ์—์„œ๋Š” ํ”„๋ ˆ์ž„์›Œํฌ์—์„œ ์ฃผ๋กœ ์ฝ”๋ฃจํ‹ด์ด ์‹œ์ž‘๋˜์ง€๋งŒ, ์ง์ ‘ ๋งŒ๋“  ์Šค์ฝ”ํ”„ ๋‚ด๋ถ€์—์„œ ์ฝ”๋ฃจํ‹ด์„ ์‹œ์ž‘ํ•  ๋•Œ๋„ ์žˆ์Šต๋‹ˆ๋‹ค.
@Scheduled(fixedRate = 5000)
fun sendNotifications() {
    notificationsScope.launch {
        val notifications = notificationsRepository
            .notificationsToSend()
        for (notification in notifications) {
            launch {
                notificationService.send(notification)
                notificationRepository
                    .markAsSent(notification.id)
            }
        }
    }
  • sendNotifications() ํ•จ์ˆ˜์—์„œ ์•Œ๋ฆผ์ด ์‹ค์ œ๋กœ ๋™์‹œ์— ์ „์†กํ•˜๋Š”์ง€ ํ…Œ์ŠคํŠธํ•˜๊ณ ์ž ํ•ฉ๋‹ˆ๋‹ค.
  • ๋‹จ์œ„ ํ…Œ์ŠคํŠธ์—์„œ ์Šค์ฝ”ํ”„์˜ ์ผ๋ถ€๋กœ StandardTestDispatcher๋ฅผ ์‚ฌ์šฉํ•  ์ˆ˜ ์žˆ์œผ๋ฉฐ, send์™€ markAsSent ์‚ฌ์ด์— ์•ฝ๊ฐ„์˜ ์ง€์—ฐ์ด ์ƒ๊ธฐ๋„๋ก ์ˆ˜ํ–‰ํ•˜์˜€์Šต๋‹ˆ๋‹ค.
    • advanceUntilIdle, sendNotifications์€ ์ผ๋ฐ˜ ํ•จ์ˆ˜์ด๊ธฐ ๋•Œ๋ฌธ์— runBlocking์„ ์‚ฌ์šฉํ•˜์ง€ ์•Š์•˜์Šต๋‹ˆ๋‹ค!
@Test
fun testSendNotifications() {
    // given
    val notifications = List(100) { Notification(it) }
    val repo = FakeNotificationsRepository(
        delayMillis = 200,
        notifications = notifications
    )
    val service = FakeNotifictaionsService(
        delayMillis = 300,
    )
    val testScope = TestScope()
    val sender = NotificationSender(
        notificationsRepository = repo,
        notificationService = service,
        notificationScope = testScope
    )

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

    // ๋ชจ๋“  ์•Œ๋ฆผ์ด ๋ณด๋‚ด์ง€๊ณ  ์ „์†ก ์™„๋ฃŒ ํ‘œ์‹œ
    assertEquals(
        notifications.toSet(),
        service.notificationsSent.toSet()
    )

    assertEquals(
        notifications.map { it.id }.toSet(),
        repo.notificationsMarkedAsSent.toSet()
    )

    // ์•Œ๋ฆผ์ด ๋ณ‘๋ ฌ๋กœ ์ „์†ก
    assertEquals(700, testScope.currentTime)
}

๋ฉ”์ธ ๋””์ŠคํŒจ์ฒ˜ ๊ต์ฒด

  • ๋‹จ์œ„ ํ…Œ์ŠคํŠธ์—๋Š” ๋ฉ”์ธ ํ•จ์ˆ˜๊ฐ€ ์—†์œผ๋ฏ€๋กœ ๋ฉ”์ธ ํ•จ์ˆ˜๋ฅผ ์‚ฌ์šฉํ•˜๋ ค ํ•˜๋ฉด, ํ…Œ์ŠคํŠธ์—์„œ ๋ฉ”์ธ ๋””์ŠคํŒจ์ฒ˜๋ฅผ ๊ฐ€์ง„ ๋ชจ๋“ˆ์ด ์—†๋‹ค๋Š” ์˜ˆ์™ธ๋ฅผ ๋˜์ง‘๋‹ˆ๋‹ค.
  • ๋งค๋ฒˆ ๋ฉ”์ธ ์Šค๋ ˆ๋“œ๋ฅผ ์ฃผ์ž…ํ•˜๋Š” ๊ฑด ๋น„์šฉ์ด ๋งŽ์ด ๋“ค๊ธฐ ๋•Œ๋ฌธ์— kotlinx-coroutines-test ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ์—์„œ Dispatchers์— setMain ํ™•์žฅ ํ•จ์ˆ˜๋ฅผ ์ œ๊ณตํ•ฉ๋‹ˆ๋‹ค.
  • ๋ชจ๋“  ๋‹จ์œ„ ํ…Œ์ŠคํŠธ์— ์˜ํ•ด ํ™•์žฅ๋˜๋Š” ๊ธฐ๋ณธ ํด๋ž˜์Šค์˜ setup ํ•จ์ˆ˜(@Before or @BeforEach๊ฐ€ ๋ถ™์€ ํ•จ์ˆ˜)์—์„œ ๋ฉ”์ธ ํ•จ์ˆ˜๋ฅผ ์„ค์ •ํ•ด์•ผํ•˜๋Š” ๊ฒฝ์šฐ๊ฐ€ ๋งŽ์Šต๋‹ˆ๋‹ค.
  • ์ด๋Ÿฌํ•œ ๊ฒฝ์šฐ ์ฝ”๋ฃจํ‹ด์ด Dispatchers.Main์—์„œ ํ•ญ์ƒ ์‹คํ–‰๋œ๋‹ค๋Š” ๊ฒƒ์ด ๋ณด์žฅ๋ฉ๋‹ˆ๋‹ค.
@Test
fun settingMainDispatcher() = runTest {
    val testDispatcher = UnconfinedTestDispatcher(testScheduler)
    Dispatchers.setMain(testDispatcher)
}

์ฝ”๋ฃจํ‹ด์„ ์‹œ์ž‘ํ•˜๋Š” ์•ˆ๋“œ๋กœ์ด๋“œ ํ•จ์ˆ˜ ํ…Œ์ŠคํŠธ

  • ์•ˆ๋“œ๋กœ์ด๋“œ์˜ ๊ฒฝ์šฐ ์ฝ”๋ฃจํ‹ด์ด ViewModel, Presenters, Fragments, Activities์—์„œ ์ฃผ๋กœ ์‹œ์ž‘๋ฉ๋‹ˆ๋‹ค.
  • ์ด ํด๋ž˜์Šค๋“ค์€ ๋งค์šฐ ์ค‘์š”ํ•˜๋ฏ€๋กœ ํ…Œ์ŠคํŠธ๋ฅผ ๋ฐ˜๋“œ์‹œ ์ˆ˜ํ–‰ํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค.
    • ์–ด๋–ค ์Šค์ฝ”ํ”„๋ฅผ ์‚ฌ์šฉํ–ˆ๋Š”์ง€๋Š” ์ค‘์š”ํ•˜์ง€ ์•Š์œผ๋ฉฐ, ์ฝ”๋ฃจํ‹ด์„ ์‹œ์ž‘ํ•˜๋Š” ๋ชจ๋“  ํด๋ž˜์Šค์—์„œ ํ–ˆ๋˜ ๊ฒƒ์ฒ˜๋Ÿผ ์Šค์ฝ”ํ”„๋ฅผ StandardTestDispathcer๋กœ ์‚ฌ์šฉํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.
    • ์˜์กด์„ฑ์„ ์ฃผ์ž…ํ•  ํ•„์š” ์—†์ด, Dispatchers.setMain ํ•จ์ˆ˜๋ฅผ ์‚ฌ์šฉํ•ด์„œ ํŽธํ•˜๊ฒŒ ๊ตฌํ˜„ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

private val testDispatcher = StandardTestDispathcer()

@Before
fun setUp() {
    testDispatcher = StandardTestDispathcer()
    Dispatchers.setMain(testDispatcher)
}

@After
fun down() {
    DiDispatchers.resetMain()
}
  • ์œ„์™€ ๊ฐ™์ด ๋””์ŠคํŒจ์ฒ˜๋ฅผ ์ƒ์„ฑํ•˜๋ฉด onCreate ์ฝ”๋ฃจํ‹ด์€ testDispatcher์—์„œ ์‹คํ–‰๋˜๋ฏ€๋กœ ์‹œ๊ฐ„์„ ์กฐ์ž‘ํ•˜๋Š” ๊ฒƒ์ด ๊ฐ€๋Šฅํ•ฉ๋‹ˆ๋‹ค.
    • ์ด ๋•Œ  advanceUntilIdle()๋ฅผ ์‚ฌ์šฉํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.
private lateinit var scsheduler : TestCorouitneScheduler

@BeforeEach
fun setUp() {
    scheduler = TestCoroutineScheduler()
}

@Test
fun `should show user name and sorteed news`() {
    //when
    viewModel.onCreate()
    scheduler.advanceUntilIdle()
}

๋ฃฐ์ด ์กด์žฌํ•˜๋Š” ํ…Œ์ŠคํŠธ ๋””์ŠคํŒจ์ฒ˜ ์„ค์ •

  • JUnit4๋Š” ๋ฃฐ ํด๋ž˜์Šค์˜ ์‚ฌ์šฉ์„ ํ—ˆ์šฉํ•ฉ๋‹ˆ๋‹ค.

๋ฃฐ

  • ํ…Œ์ŠคํŠธ ํด๋ž˜์Šค์˜ ์ˆ˜๋ช… ๋™์•ˆ ๋ฐ˜๋“œ์‹œ ์‹คํ–‰๋˜์–ด์•ผ ํ•  ๋กœ์ง์„ ํฌํ•จํ•˜๋Š” ํด๋ž˜์Šค
  • ๋ชจ๋“  ํ…Œ์ŠคํŠธ๊ฐ€ ์‹œ์ž‘๋˜๊ธฐ ์ „๊ณผ ๋๋‚œ ๋’ค์— ์‹คํ–‰ํ•ด์•ผ ํ•  ๊ฒƒ๋“ค์„ ์ •์˜ํ•  ์ˆ˜ ์žˆ์”๋‹ˆ๋‹ค.
  • ํ…Œ์ŠคํŠธ ๋””์ŠคํŒจ์ฒ˜๋ฅผ ์„ค์ •ํ•˜๊ณ  ๋‚˜์ค‘์— ์ด๋ฅผ ํ•ด์ œํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค.

๋ฃฐ ๊ตฌํ˜„

  • starting()๊ณผ finished()์™€ ๊ฐ™์€ ์ˆ˜๋ช… ์ฃผ๊ธฐ ๋ฉ”์„œ๋“œ๋ฅผ ์ œ๊ณตํ•˜๋Š” TestWatcher๋ฅผ ํ™•์žฅํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.
  • ๋ฃฐ์€ TestCoroutineScheduler์™€ TestDispatcher๋กœ ๊ตฌ์„ฑ๋ฉ๋‹ˆ๋‹ค.
  • ๋ฃฐ์„ ์‚ฌ์šฉํ•œ ํด๋ž˜์Šค์˜ ๊ฐ ํ…Œ์ŠคํŠธ๊ฐ€ ์‹œ์ž‘๋˜๊ธฐ ์ „์— TestDispatcher๊ฐ€ ๋ฉ”์ธ ๋””์ŠคํŒจ์ฒ˜๋กœ ์„ค์ •๋˜๋ฉฐ, ๊ฐ ํ…Œ์ŠคํŠธ๊ฐ€ ๋๋‚œ ๋’ค์— ๋ฉ”์ธ ๋””์ŠคํŒจ์ฒ˜๋Š” ์›๋ž˜ ์ƒํƒœ๋กœ ๋Œ์•„์˜ค๊ฒŒ ๋ฉ๋‹ˆ๋‹ค.
  • ์Šค์ผ€์ค„๋Ÿฌ๋Š” ์œ„ ๋ฃฐ์˜ scheduler ํ”„๋กœํผํ‹ฐ๋กœ ์ ‘๊ทผํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

Junit4์˜ ๋ฃฐ

class MainCoroutineRule : TestWathcer() {
    lateinit var scheduler: TestCoroutineScheduler
        private set
    lateinit var dispatcher: TestDispatcher
        private set

    override fun starting(description: Description) {
        scheduler = TestCoroutineScheduler()
        dispatcher = StandardTestDispatcher(scheduler)
        Dispatchers.setMain(dispatcher)
    }

    override fun finished(description: Description) {
        Dispatchers.resetMain()
    }
}

๋ฃฐ ์‚ฌ์šฉ

class MainViewModelTests {
    @get: Rule
    var mainCorotineRule = MainCoroutineRule()

    // ...

    @Test
    fun `should show user name and sorted news`() {
        // when
        viewModel.onCreate()
        mainCorotuineRule.scheduler.advanceUntilIdle()


        // then
        assertEquals(aName, viewModel.userName.value)
        val someNewsSorted =
            listOf(News(data1), News(data2), News(data3))
        assertEquals(someNewSorted, viewModel,news.value)
    }
}
  • MainCoroutineRule์—์„œ advanceUntilIdle, advanceTimeBy, runCurrent, currentTime์„ ์ง์ ‘ ํ˜ธ์ถœํ•˜๊ณ  ์‹ถ๋‹ค๋ฉด, ํ™•์žฅ ํ•จ์ˆ˜์™€ ํ”„๋กœํ„ฐํ‹ฐ๋กœ ์ •์˜ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.
  • ์ด๋Ÿฌํ•œ ๋ฐฉ์‹์€ ์•ˆ๋“œ๋กœ์ด๋“œ์—์„œ ์ฝ”ํ‹€๋ฆฐ ์ฝ”๋ฃจํ‹ด์„ ํ…Œ์ŠคํŠธํ•  ๋•Œ ์ž์ฃผ ์‚ฌ์šฉํ•˜๋ฉฐ, JUnit5์˜ ๋ฐฉ์‹์—์„œ๋„ ํ™•์žฅ ํด๋ž˜์Šค๋ฅผ ์ •์˜ํ•œ๋‹ค๋Š” ์ ์—์„œ ํฐ ์ฐจ์ด๋Š” ์—†์Šต๋‹ˆ๋‹ค.

Junit5์˜ ๋ฃฐ

@ExperimentalCoroutinesApi
class MainCoroutineExtension:
    BeforEachCallback, AfterEachCallback {

    lateinit var scheduler: TestCoroutineScheduler
        private set
    lateinit var dispatcher: TestDispatcher
        private set

    override fun beforEach(context: ExtensionContext?) {
        scheduler = TestCoroutineScheduler()
        dispatcher = StandardTestDispatcher(scheduler)
        Dispatchers.setMain(dispatcher)
    }

    override fun afterEach(context: ExtensionContext?) {
        Dispatchers.resetMain()
    }
}
  • MainCoroutineExtension์˜ ์‚ฌ์šฉ๋ฒ•์€ Junit4์˜ MainCoroutineRule๊ณผ ๊ฑฐ์˜ ๋™์ผํ•˜๋ฉฐ, ์ฐจ์ด์ ์€ ๋‹ค๋ฅธ ์–ด๋…ธํ…Œ์ด์…˜์„ ์‚ฌ์šฉํ•œ๋‹ค๋Š” ๊ฒƒ์ž…๋‹ˆ๋‹ค.
@JvmField
@RegisterExtension
var mainCoroutineExtension = MainCoroutineExtension()