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

Dispatchers.IO 하드코딩된 코루틴 테스트: Kotlin 비동기 코드 검증하기

|

Introduction

문제 분석

1
2
3
4
5
6
7
package coroutines

class AService {
    fun nonSuspendingFunction() {
        println("AService nonSuspendingFunction")
    }
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
package coroutines

import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch

class BService(
    private val aService: AService,
) {
    fun nonSuspendingFunction() {
        println("BService nonSuspendingFunction")
        CoroutineScope(Dispatchers.IO).launch {
            aService.nonSuspendingFunction()
        }
    }
}

해결 방법 1: Static Mocking (추천)

1. 의존성 추가

1
2
3
// build.gradle.kts
testImplementation("io.mockk:mockk:1.13.17")
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.10.1")

2. 테스트 코드

 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
package coroutines

import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkStatic
import io.mockk.unmockkAll
import io.mockk.verify
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.BeforeEach
import kotlin.test.Test

class BServiceTest {
    @OptIn(ExperimentalCoroutinesApi::class)
    private val testDispatcher = UnconfinedTestDispatcher()
    private val aService = mockk<AService>(relaxed = true)
    private val bService = BService(aService)

    @BeforeEach
    fun setup() {
        // Dispatchers.IO 를 테스트 Dispatcher 로 대체
        mockkStatic(Dispatchers::class)
        every { Dispatchers.IO } returns testDispatcher
    }

    @AfterEach
    fun tearDown() {
        unmockkAll() // mocking 해제
    }

    @OptIn(ExperimentalCoroutinesApi::class)
    @Test
    fun testNonSuspendingFunction() =
        runTest(testDispatcher) {
            // When
            bService.nonSuspendingFunction()

            // Then: 모든 코루틴 작업 완료 보장
            advanceUntilIdle()
            verify(exactly = 1) { aService.nonSuspendingFunction() }
        }
}

해결 방법 2: CoroutineScope 재정의 (점진적 리팩토링)

1. 프로덕션 코드 수정

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
package coroutines

import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch

class RefactoredBService(
    private val aService: AService,
) {
    // 기본값은 Dispatchers.IO, 테스트에서 재정의 가능
    var coroutineScope = CoroutineScope(Dispatchers.IO)

    fun nonSuspendingFunction() {
        println("BService nonSuspendingFunction")
        coroutineScope.launch {
            aService.nonSuspendingFunction()
        }
    }
}

2. 테스트 코드

 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
package coroutines

import io.mockk.mockk
import io.mockk.verify
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import kotlin.test.Test

class RefactoredBServiceTest {
    @OptIn(ExperimentalCoroutinesApi::class)
    @Test
    fun testNonSuspendingFunction() =
        runTest {
            // Given
            val testScope = this
            val aService = mockk<AService>(relaxed = true)
            val bService =
                RefactoredBService(aService).apply {
                    coroutineScope = testScope // 테스트에서 재정의 가능
                }

            // When
            bService.nonSuspendingFunction()

            // Then: 모든 코루틴 작업 완료 보장
            advanceUntilIdle()
            verify(exactly = 1) { aService.nonSuspendingFunction() }
        }
}

실패 사례 vs 성공 사례

실패 사례 (주의해야 할 패턴)

1
2
3
4
5
6
7
@Test
fun `잘못된 테스트`() {
    // X runTest 블록 없음
    bService.nonSuspendFunction()
    // X advanceUntilIdle() 누락
    verify { aService.nonSuspendingFunction() } // 검증 실패
}

성공 사례

1
2
3
4
5
6
@Test
fun `올바른 테스트`() = runTest {
    // 1. Dispatchers.IO를 TestDispatcher로 교체
    // 2. advanceUntilIdle()로 모든 작업 완료
    verify { aService.nonSuspendingFunction() } // 성공
}

고급 주제: DI 방식으로 전환

1. Dispatcher 제공 클래스 생성

1
2
3
4
5
6
7
8
package coroutines

import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers

class CoroutineDispatchers(
    val io: CoroutineDispatcher = Dispatchers.IO,
)
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
package coroutines

import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch

class DependencyInjectedBService(
    private val aService: AService,
    private val dispatchers: CoroutineDispatchers = CoroutineDispatchers(),
) {
    fun nonSuspendingFunction() {
        println("BService nonSuspendingFunction")
        CoroutineScope(dispatchers.io).launch {
            aService.nonSuspendingFunction()
        }
    }
}

2. 테스트 코드

 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
package coroutines

import io.mockk.mockk
import io.mockk.verify
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import kotlin.test.Test

@OptIn(ExperimentalCoroutinesApi::class)
class DependencyInjectedBServiceTest {
    private val testDispatcher = UnconfinedTestDispatcher()
    private val aService = mockk<AService>(relaxed = true)
    private val bService = DependencyInjectedBService(aService, CoroutineDispatchers(testDispatcher))

    @Test
    fun testNonSuspendingFunction() =
        runTest {
            // When
            bService.nonSuspendingFunction()

            // Then: 모든 코루틴 작업 완료 보장
            advanceUntilIdle()
            verify(exactly = 1) { aService.nonSuspendingFunction() }
        }
}

결론

Categories:

Tags: