เขียน Test Kotlin Coroutine บน Android อย่างไรถึงจะโอเคนะ

Chatchawan Kotarasu
Life@LINE MAN Wongnai
14 min readMar 22, 2024

--

สวัสดีครับผมชื่อ มิตจัง เป็น Android Developer ที่ LINE MAN Wongnai ซึ่งผมเพิ่งเข้ามาทำที่นี่ช่วงกลางเดือน June 2023 ที่ผ่านมานี่เอง ผมก็ได้ Up skill ตัวเองในหลาย ๆ ด้าน 1 ในนั้นก็คือการเขียน Test นั่นเอง เนื่องจากที่นี่เรามีการนำความรู้มาแลกเปลี่ยนกัน หรือก็คือ Knowledge Sharing กันเองทุกสัปดาห์อยู่แล้ว วันนี้ก็เลยอยากจะมาแชร์สิ่งที่ผมได้แชร์ไปในที่บริษัทถึงวิธีการเขียน Test ของเจ้า Kotlin Coroutine ให้ทุกคนได้เข้าใจกัน

คิดว่า Android Developer หลาย ๆ ท่าน ที่เข้ามาอ่านน่าจะคงคุ้นเคยดีอยู่แล้วกับการใช้ Coroutine ที่เป็น Feature ของ Kotlin ที่ช่วยให้เราเขียน Code ให้มันทำงานแบบ Asynchronous รวมไปถึงการเขียน Test ทั้ง Unit Test และ Integration Test ของ Class ที่มีการเรียก suspend function อยู่แล้ว

เพราะเจ้า Code ที่ทำงานแบบ Asynchronous นี่อย่างไรหละที่ทำให้การเขียน Test เป็นเรื่องปวดหัว

Warning ⚠️ บทความนี้ยาวมาก (พยายามลงรายละเอียดให้ครบทุก Case) นี่คือ Outline คร่าว ๆ ของบทความนี้

  1. runTest vs runBlocking
  2. runTest มันทำงานอย่างไร?
  3. ทำไมเราถึงควรทำให้ Code ของเรา Inject Dispatcher เข้าไปได้
  4. เราควบคุมเวลาของการ Test ได้ผ่าน advanceTimeBy , advanceUntilIdle , runCurrent
  5. ระหว่าง StandardTestDispatcher กับ UnconfinedTestDispatcher เราควรเลือกใช้ตัวไหนกันนะ?
  6. เกร็ดเล็กเกร็ดน้อยในการ Test viewModelScope กับ StateFlow

มาเริ่มกันเลย

ถ้าเรามี Code แบบนี้

class Example {
suspend fun execute(): Boolean {
// Do something which consumes very long execution time
delay(10.seconds)
return true
}
}

กับ Test แบบนี้

@Test
fun executeShouldReturnTrue() = runBlocking {
val usedTime = TimeSource.Monotonic.measureTime {
val underTest = Example()
val result = underTest.execute()
result shouldBeEqualTo true
}
println("usedTime = $usedTime")
}

มันจะใช้เวลาในการ run test executeShouldReturnTrue เท่าไร?

คำตอบ: ≥ 10 seconds

ถ้าเรามีจำนวน Test ที่ใช้ runBlocking แบบนี้อยู่หลายที่หละ แน่นอนว่ามันทำให้เราไม่ productive แน่ ๆ เพราะมันใช้เวลาเยอะเกินไป แล้วเราต้องทำอย่างไรหละ

คำตอบ: ใช้ runTest ที่มากับ org.jetbrains.kotlinx:kotlinx-coroutines-test version 1.6 ขึ้นไป

Executes testBody as a test in a new coroutine, returning TestResult.
On JVM and Native, this function behaves similarly to runBlocking, with the difference that the code that it runs will skip delays. This allows to use delay in tests without causing them to take more time than necessary. On JS, this function creates a Promise that executes the test body with the delay-skipping behavior.

@Test
fun executeShouldReturnTrue() = runTest {
val usedTime = TimeSource.Monotonic.measureTime {
val underTest = Example()
val result = underTest.execute()
result shouldBeEqualTo true
}
println("usedTime = $usedTime")
}

เพียงแค่นี้ Test ของเราก็จะใช้เวลาเหลือเพียงแค่ไม่กี่ milliseconds เท่านั้น

runTest มันทำงานอย่างไรถึง Skip delay ให้เราหละ?

เมื่อเราเรียก runTest สิ่งที่เกิดขึ้นก็คือมันจะสร้าง Test Scope ซึ่งเป็น Coroutine Scope พิเศษที่ด้านในจะมี TestDispatcher

TestScope -> TestDispatcher -> TestScheduler

TestDispatcher ก็เหมือนกับ CoroutineDisptacher ตัวอื่น ๆ เช่น Dispatchers.Main , Dispatchers.IO และอื่น ๆ หน้าที่ของมันก็คือเลือกว่า Coroutine ตัวไหนจะไปทำงานอยู่บน Thread หรือ Thread Pool ตัวไหนนั่นเอง

โดยปกติแล้ว Job หรือ Task ก็จะทำงานไปตาม Clock บนเครื่องจริง ๆ ทำให้เราไม่สามารถควบคุมเรื่องเวลาได้ แต่ว่าเจ้า TestDispatcher เนี่ยมันจะสร้าง Virtual Clock ขึ้นมาผ่าน TestCoroutineScheduler และ dispatch job ของเรามาให้ TestCoroutineScheduler แทน Thread หรือ Thread Pool นั่นเอง

หัวใจของ TestDispatcher ก็คือ TestCoroutineScheduler นั่นเอง

Unit test suspend fun เนี่ย เราก็แค่เอาไปเรียกใน runTest แต่ถ้าเราต้อง test function ที่ launch coroutine scope ขึ้นมาจะทำอย่างไร? เช่น

interface RemoteApi {
suspend fun fetch(): String
}

interface LocalDb {
suspend fun save(data: String)
}

class Repository(
private val remoteApi: RemoteApi,
private val localDb: LocalDb
) {
fun syncData() {
CoroutineScope(Job()).launch {
// Call remote API and save to local DB
localDb.save(remoteApi.fetch())
println("syncData successfully")
}
}
}

เราต้องการ test ว่า syncData จะเรียก LocalDb.save ด้วย value ที่ได้จาก RemoteApi.fetch

เราก็จะเขียน Test ประมาณนี้ (ใช้ MockK | mocking library for Kotlin ในการ mock)

class RepositoryTest {
@Test
fun testSyncData() {
// Given
val remoteApi: RemoteApi = mockk()
val localDb: LocalDb = mockk()
coEvery {
remoteApi.fetch()
} returns "data from remote"
coJustRun {
localDb.save(any())
}
val underTest = Repository(remoteApi, localDb)

// When
underTest.syncData()

// Then
coVerifyOrder {
remoteApi.fetch()
localDb.save("data from remote")
}
}
}

ซึ่งมันก็ run test ผ่านนั่นแหละ แต่ถ้าเราเปลี่ยนมาบอกว่า

class RepositoryTest {
@Test
fun testSyncData() {
// Given
val remoteApi: RemoteApi = mockk()
val localDb: LocalDb = mockk()
coEvery {
remoteApi.fetch()
} coAnswers {
delay(3.seconds)
"data from remote"
}
coJustRun {
localDb.save(any())
}
val underTest = Repository(remoteApi, localDb)

// When
underTest.syncData()

// Then
coVerifyOrder {
remoteApi.fetch()
localDb.save("data from remote")
}
}
}
Verification failed: fewer calls happened than demanded by order verification sequence. 

Matchers:
+RemoteApi(#1).fetch(any()))
LocalDb(#2).save(eq(data from remote), any()))

Calls:
1) +RemoteApi(#1).fetch(continuation {})

Test ของเราก็จะพังทันที ถ้าอย่างงั้นเราก็แค่ครอบ Test นี้ด้วย runTest สิ เพราะ runTest จะ skip delay ให้เราไง

class RepositoryTest {
@Test
fun testSyncData() = runTest { // runTest here
// the rest of test
}
}
Verification failed: fewer calls happened than demanded by order verification sequence. 

Matchers:
+RemoteApi(#1).fetch(any()))
LocalDb(#2).save(eq(data from remote), any()))

Calls:
1) +RemoteApi(#1).fetch(continuation {})

Test นี้ก็พังอยู่ดี (ได้ error เดิมเลย) ทำไมกันนะ?

เพราะเราเขียน Test ผิดอย่างไรหละ

ถ้าสังเกตุดี ๆ เราไม่ได้รอให้มันผ่านไป 3 วิก่อนถึงจะ verify calls

class RepositoryTest {
@Test
fun testSyncData() = runTest {
// Given
...
coEvery {
remoteApi.fetch()
} coAnswers {
delay(3.seconds) // Mock fetch to delay 3 seconds
"data from remote"
}
...

// When
underTest.syncData()
delay(3.seconds)

// Then
...
}
}
Verification failed: fewer calls happened than demanded by order verification sequence. 

Matchers:
+RemoteApi(#1).fetch(any()))
LocalDb(#2).save(eq(data from remote), any()))

Calls:
1) +RemoteApi(#1).fetch(continuation {})

เอ๊ะ เราก็ delay 3 วินาที ก่อน verify แล้วนี่หน่า ถ้าอย่างงั้นลองเพิ่มเป็น 4 วินาที ดูดีกว่า

class RepositoryTest {
@Test
fun testSyncData() = runTest {
// Given
...
coEvery {
remoteApi.fetch()
} coAnswers {
delay(3.seconds) // Mock fetch to delay 3 seconds
"data from remote"
}
...

// When
underTest.syncData()
delay(4.seconds) // update from 3 seconds to 4 seconds

// Then
...
}
}
Verification failed: fewer calls happened than demanded by order verification sequence. 

Matchers:
+RemoteApi(#1).fetch(any()))
LocalDb(#2).save(eq(data from remote), any()))

Calls:
1) +RemoteApi(#1).fetch(continuation {})

พังเหมือนเดิมแหะ (ไม่ว่าจะ delay เท่าไหร่ก็พัง)

เพราะ runTest มัน skip delay ให้เราแล้วไง ลืมได้ไงเนี่ย แล้วทำไมมันถึงไม่เรียก LocalDb(#2).save กันนะ?
ก็เพราะว่า CoroutineScope ที่ใช้สำหรับ launch coroutine ใน syncData นั้นไม่ได้ใช้ TestDispatcher ยังไงหละ

Inject Dispatchers

ถ้าเราเปิดเข้าไปอ่าน Best practices for coroutines in Android | Kotlin | Android Developers เราจะพบว่าหัวข้อแรกของ Guide เลย ก็คือ Inject Dispatchers เป็นสิ่งที่พวกเราควรจะ follow ตามอย่างยิ่ง ถ้าอย่างงั้นเรามาปรับ code ของเรากันหน่อย

class Repository(
private val remoteApi: RemoteApi,
private val localDb: LocalDb,
private val coroutineDispatcher: CoroutineDispatcher
) {
fun syncData() {
CoroutineScope(Job() + coroutineDispatcher).launch {
// Call remote API and save to local DB
localDb.save(remoteApi.fetch())
println("syncData successfully")
}
}
}
class RepositoryTest {
@Test
fun testSyncData() = runTest {
// Given
...
val testDispatcher = StandardTestDispatcher()
val underTest = Repository(remoteApi, localDb, testDispatcher) // Inject testDispatcher

// When
...

// Then
...
}
}

เมื่อเราลอง run test อีกรอบก็จะเจอกับ

Verification failed: fewer calls happened than demanded by order verification sequence. 

Matchers:
RemoteApi(#1).fetch(any()))
LocalDb(#2).save(eq(data from remote), any()))

Calls:

กลายเป็นว่า RemoteApi(#1).fetch(any()) ไม่ถูกเรียกเลย

ใช้ TestScheduler ตัวเดียวกันสำหรับ Test

อย่างที่ได้อธิบายไปก่อนหน้าว่าเมื่อเราเรียก runTest สิ่งที่เกิดขึ้นก็คือมันจะสร้าง TestScope และ TestDispatcher ขึ้นมา ถ้าเราไม่ระบุประเภท TestDispatcherให้กับ runTest มันจะสร้าง StandardTestDispatcher และใช้ใน TestScope ของ Test นั้น

เมื่อเราย้อนกลับมาดู Test ของเรา ก็จะพบว่า

class RepositoryTest {
@Test
fun testSyncData() = runTest {
// Given
...
val testDispatcher = StandardTestDispatcher()
val underTest = Repository(remoteApi, localDb, testDispatcher)

เราสร้าง TestDispatcher ขึ้นมาใหม่อีก 1 ตัว ทีนี้ถ้าเรามาดู Source Code ของ StandardTestDispatcher()

@ExperimentalCoroutinesApi
@Suppress("FunctionName")
public fun StandardTestDispatcher(
scheduler: TestCoroutineScheduler? = null,
name: String? = null
): TestDispatcher = StandardTestDispatcherImpl(
scheduler ?: TestMainDispatcher.currentTestScheduler ?: TestCoroutineScheduler(), name)

จะพบว่าถ้าเราไม่ได้ระบุ argument scheduler มันจะใช้ Test Scheduler จาก TestMainDispatcher.currentTestScheduler หรือไม่ก็สร้าง TestCoroutineScheduler() ขึ้นมาใหม่

แล้วเราจะเอา TestCoroutineScheduler จากไหนหละ?
คำตอบก็คือ TestScope จาก runTest

@ExperimentalCoroutinesApi
public sealed interface TestScope : CoroutineScope {
/**
* The delay-skipping scheduler used by the test dispatchers running the code in this scope.
*/
@ExperimentalCoroutinesApi
public val testScheduler: TestCoroutineScheduler

โค้ด Test ของเราก็จะเป็นแบบนี้

class RepositoryTest {
@Test
fun testSyncData() = runTest {
// Given
...
val testDispatcher = StandardTestDispatcher(testScheduler) // this@runTest.testScheduler
val underTest = Repository(remoteApi, localDb, testDispatcher)

// When
underTest.syncData()
delay(3.seconds)

// Then
coVerifyOrder {
remoteApi.fetch()
localDb.save("data from remote")
}
}
}

เมื่อเรา run test ก็จะเจอกับ

Verification failed: fewer calls happened than demanded by order verification sequence. 

Matchers:
+RemoteApi(#1).fetch(any()))
LocalDb(#2).save(eq(data from remote), any()))

Calls:
1) +RemoteApi(#1).fetch(continuation {})

ถ้างั้น เราลองปรับ Test ของเราให้ delay 3.001 วินาทีแทนดูซิ

class RepositoryTest {
@Test
fun testSyncData() = runTest {
// Given
...

// When
underTest.syncData()
delay(3.001.seconds) // from 3 seconds to 3.001 seconds

// Then
...
}
}

Test ของเราก็ run ผ่านแล้ว

ทำไม delay(3.seconds) ไม่ผ่านแต่ delay(3.001.seconds) ถึงผ่าน????
เหตุผลง่าย ๆ ก็คือในโลกของเวลา 3.001 วินาทีมันเกิดทีหลัง 3 วินาที
เหตุผลจริง ๆ มันจะไปเกี่ยวข้องกับประเภทของ TestDispatcher ที่จะอธิบายต่อไปหลังจากนี้

แต่ก่อนอื่นเรามารู้จักกับ 3 functions ต่อไปนี้กันก่อนนะ

advanceTimeBy, advanceUntilIdle, runCurrent

แน่นอนว่าถ้าเราต้องมาบวก 1 millisecond ใน code ของเราตลอด มันก็จะดูน่าเกลียด ทาง Library Coroutine Test ก็เลยมี function เหล่านี้ให้ใช้

  • advanceTimeBy ขยับ Virtual Clock ไปเป็นเวลา X milliseconds และ execute events ที่อยู่ในช่วงเวลา [currentTime, currentTime + X)
    ไม่ execute events ที่ scheduled at currentTime + X
  • runCurrent execute events ที่อยู่ ณ ช่วงเวลาปัจจุบัน ใช้ร่วมกับ advanceTimeBy (เรียก advanceTimeBy ตามด้วย runCurrent )
  • advanceUntilIdle run ทุก tasks จนไม่มี task อยู่ใน schedule queue

แนะนำให้ไปอ่านต่อที่ TestDispatcher: Become the Clock Master | by Michał Klimczak | ProAndroidDev (ในนี้จะมี diagram อธิบายการทำงานของ 3 functions นี้)

ทีนี้เราลองปรับ Test ของเราอีกรอบ ด้วยการใช้ advanceTimeBy และ runCurrent

class RepositoryTest {
@Test
fun testSyncData() = runTest {
// Given
...
val testDispatcher = StandardTestDispatcher(testScheduler)
val underTest = Repository(remoteApi, localDb, testDispatcher)

// When
underTest.syncData()
advanceTimeBy(3000)
runCurrent()
// or we can use advanceUntilIdle() instead of advanceTimeBy + runCurrent

// Then
coVerifyOrder {
remoteApi.fetch()
localDb.save("data from remote")
}
}
}

Test ของเราก็จะผ่านแล้ว

StandardTestDispatcher กับ UnconfinedTestDispatcher

เจ้า Library Coroutines Test เนี่ย มี TestDispatcher อยู่ 2 แบบ StandardTestDispatcher (Default เวลาเรียก runTest) กับ UnconfinedTestDispatcher

ถ้าเราลองเอา Test ด้านบนที่เราใช้ delay(3.seconds) (ก่อนเปลี่ยนมาใช้ advanceTimeBy กับ runCurrent ) แต่เปลี่ยน TestDispatcher เป็น UnconfinedTestDispatcher ดู

class RepositoryTest {
@Test
fun testSyncData() = runTest {
// Given
...
val testDispatcher = UnconfinedTestDispatcher(testScheduler)
val underTest = Repository(remoteApi, localDb, testDispatcher)

// When
underTest.syncData()
delay(3.seconds)

// Then
...
}
}

ผลลัพธ์ก็คือมัน run test ผ่าน โดยที่เราไม่ต้องเพิ่ม 1 millisecond เลย

ถ้างั้นเราก็ใช้ UnconfinedTestDispatcher แทน StandardTestDispatcher ไม่ดีกว่าหรอ?

งั้นมาดูกันว่า UnconfinedTestDispatcher มันแตกต่างกับ StandardTestDispatcher อย่างไร

#1 UnconfinedTestDispatcher จะ execute coroutine ทันที แต่ StandardTestDispatcher ไม่

ตัวอย่าง
ถ้าเราเพิ่ม suspend fun get(): String เข้าไปใน LocalDb และเพิ่ม suspend fun getData(): String เข้าไปใน Repository ตามนี้

interface RemoteApi {
suspend fun fetch(): String
}

interface LocalDb {
suspend fun save(data: String)
suspend fun get(): String
}

class Repository(
private val remoteApi: RemoteApi,
private val localDb: LocalDb,
private val coroutineDispatcher: CoroutineDispatcher
) {
fun syncData() {
CoroutineScope(Job() + coroutineDispatcher).launch {
// Call remote API and save to local DB
localDb.save(remoteApi.fetch())
println("syncData successfully") // print here to check if syncData is run
}
}

suspend fun getData(): String {
return localDb.get()
}
}
class RepositoryTest {
@Test
fun testSyncData() = runTest {
// Given
val remoteApi: RemoteApi = mockk()
val localDb: LocalDb = mockk()
coEvery {
remoteApi.fetch()
} coAnswers {
"data from remote"
}
val data = slot<String>()
coJustRun {
localDb.save(capture(data))
}
coEvery {
localDb.get()
} coAnswers {
data.captured
}
val testDispatcher = StandardTestDispatcher(testScheduler)
val underTest = Repository(remoteApi, localDb, testDispatcher)

// When
underTest.syncData()
val result: String = underTest.getData()

// Then
Assert.assertEquals("data from remote", result)
}
}

ถ้าเราเอา Test ด้านบนนี้ไป run ก็จะเจอกับ

syncData successfully

Value not yet captured.
java.lang.IllegalStateException: Value not yet captured.

อ้าวมันก็ print “syncData successfully” จาก syncData นี่ แต่ทำไม captured value ใน data ถึงยังไม่ถูก captured

นั่นก็เป็นเพราะว่า เมื่อเราสร้าง Coroutine ขึ้นมาผ่าน launch (ใน syncData ) โดยที่ใช้ StandardTestDispatcher เจ้า Coroutine ตัวนี้จะยังไม่ถูก execute ทันที (ไป execute หลังสุดใน Test) ทำให้เมื่อเราเรียก val result: String = underTest.getData() ก็จะเจอว่า slot ของเรายังไม่ถูก captured นั่นเอง

แต่ถ้าเราเปลี่ยน Test ของเราไปใช้ UnconfinedTestDispatcher แทนหละ

class RepositoryTest {
@Test
fun testSyncData() = runTest {
// Given
...
val testDispatcher = UnconfinedTestDispatcher(testScheduler)
val underTest = Repository(remoteApi, localDb, testDispatcher)

// When
...

// Then
...
}
}

Test ของเราก็จะผ่าน เพราะว่า UnconfinedTestDispatcher จะ execute Coroutine (launch) ใน function syncData ของเราทันที
เราสามารถตรวจสอบได้ด้วยการ verify ว่า localDb.save ถูกเรียก

class RepositoryTest {
@Test
fun testSyncData() = runTest {
// Given
...
val testDispatcher = UnconfinedTestDispatcher(testScheduler)
val underTest = Repository(remoteApi, localDb, testDispatcher)

// When
underTest.syncData() // launch coroutine which it's executed immediately
coVerify { // verify that save is called if the coroutine is executed immediately
localDb.save("data from remote")
}
val result: String = underTest.getData()

// Then
Assert.assertEquals("data from remote", result)
}
}

แต่ไม่ได้หมายความว่ามันจะทำงานจนเสร็จ Coroutine นั้นทั้งหมดนะ เช่น ถ้ามันถูก suspend โดย delay หมายความว่าเราก็มีโอกาสที่จะต้องใช้ advanceTimeBy
, advanceUntilIdleและ runCurrent อยู่ดี

แล้วถ้าเราอยากจะใช้ StandardTestDispatcher ใน Test นี้แทนหละ? เราก็แค่เรียก advanceUntilIdle หรือ runCurrent ก่อน verify นั่นเอง

class RepositoryTest {
@Test
fun testSyncData() = runTest {
// Given
...
val testDispatcher = StandardTestDispatcher(testScheduler)
val underTest = Repository(remoteApi, localDb, testDispatcher)

// When
underTest.syncData() // Coroutine is launched, but it's not executed yet
advanceUntilIdle() // << here to execute the launched coroutine until it's finished.
coVerify {
localDb.save("data from remote")
}
val result: String = underTest.getData()

// Then
Assert.assertEquals("data from remote", result)
}
}

#2 UnconfinedTestDispatcher ไม่การันตีเรื่อง Execution Order แต่ StandardTestDispatcher การันตี

เพื่อให้เห็นภาพ เราจะปรับ Code ของ Repository ของเรากันหน่อยตามนี้

class Repository(
private val remoteApi: RemoteApi,
private val localDb: LocalDb,
private val coroutineDispatcher: CoroutineDispatcher
) {

val isSyncingStateFlow: MutableStateFlow<Boolean?> = MutableStateFlow(null)

fun syncData() {
CoroutineScope(Job() + coroutineDispatcher).launch {
// Call remote API and save to local DB
isSyncingStateFlow.emit(true)
localDb.save(remoteApi.fetch())
isSyncingStateFlow.emit(false)
println("syncData successfully")
}
}

suspend fun getData(): String {
return localDb.get()
}

}

นั่นก็คือเราเพิ่ม isSyncingStateFlow: MutableStateFlow<Boolean?> เข้ามานั่นเอง ความหมายก็คือ ถ้าเรากำลัง syncData อยู่ isSyncingStateFlow ก้จะ emit(true) ออกไป (เราอาจจะ collect เพื่อแสดงหน้า loading) และเมื่อ syncData เสร็จ ก็จะ emit(false) บอกว่า sync เสร็จแล้วนะ

ทีนี้เราก็จะมาเขียน Test เพื่อ verify ว่า เมื่อเราเรียก syncData คนที่ collect isSyncingStateFlow จะต้องได้รับ true กับ false มา

class RepositoryTest {
@Test
fun testSyncData() = runTest {
// Given
val remoteApi: RemoteApi = mockk()
val localDb: LocalDb = mockk()
coEvery {
remoteApi.fetch()
} coAnswers {
"data from remote"
}
val data = slot<String>()
coJustRun {
localDb.save(capture(data))
}

val testDispatcher = UnconfinedTestDispatcher(testScheduler)
val underTest = Repository(remoteApi, localDb, testDispatcher)
val result: MutableList<Boolean> = mutableListOf()

// When
val job = launch(UnconfinedTestDispatcher(testScheduler)) {
underTest.isSyncingStateFlow.filterNotNull().collectLatest { isSync: Boolean ->
result += isSync
}
}
underTest.syncData()
runCurrent()
job.cancel()

// Then
Assert.assertEquals(listOf(true, false), result)
}
}

อธิบาย Test ข้างบน: เรา launch coroutine อีก 1 ตัวที่ใช้ UnconfinedTestDispatcher เพื่อให้มัน collect isSyncingStateFlow ทันที และเมื่อ collect แล้วเราก็จะนำ isSync: Boolean ไป append/add เข้าไปใน result: MutableList<Boolean> ของเรา
เราก็จะ expect ว่า result ควรจะเป็น ["true", "false"] เมื่อเราเรียก underTest.syncData

แต่เมื่อเรา run test ก็จะเจอกับ

syncData successfully

expected:<[true, false]> but was:<[false]>
Expected :[true, false]
Actual :[false]

ทำไมมันออกมาแค่ false ???

นั่นก็เป็นเพราะว่า UnconfinedTestDispatcher ที่ inject ไปใน syncData() ไม่ได้การันตี execution order โดยเฉพาะอย่างยิ่งเมื่อเราใช้กับ StateFlow

แต่ถ้าเราใช้ StandardTestDispatcher แทน

class RepositoryTest {
@Test
fun testSyncData() = runTest {
// Given
...
val testDispatcher = StandardTestDispatcher(testScheduler) // << here
val underTest = Repository(remoteApi, localDb, testDispatcher)
val result: MutableList<Boolean> = mutableListOf()

// When
val job = launch(UnconfinedTestDispatcher(testScheduler)) {
underTest.isSyncingStateFlow.filterNotNull().toCollection(result)
}
underTest.syncData()
runCurrent() // execute coroutine launched by syncData()
job.cancel()

// Then
Assert.assertEquals(listOf(true, false), result)

}
}

Test ของเราก็จะ run ผ่านเรียบร้อย แต่อย่าลืมเรียก runCurrent หรือ advanceUntilIdle หลังเรียก syncData() หละ

#3 UnconfinedTestDispatcher มันคือ Dispatcher.Unconfined ที่ Skip delay ให้

ผมเชื่อว่าหลาย ๆ คนที่เข้ามาอ่านก็น่าจะคุ้นเคย และเคยใช้ CoroutineDispatcher เหล่านี้

  1. Dispatchers.Main — ใน Android มันก็คือทำงานบน UI Thread
  2. Dispatchers.IO — Task จะถูกโยนไปหา Threads Pool เหมาะกับ Task ที่ทำงานนาน ๆ เช่น I/O, network API call
  3. Dispatchers.Default — คล้าย ๆ กับ Dispatchers.IO แต่จะเหมาะกับ Task ที่ต้องการการคำนวณหนัก ๆ

แนะนำคำตอบใน Stack Overflow อันนี้ https://stackoverflow.com/a/59040920 ที่ได้อธิบายข้อแตกต่างระหว่าง Dispatchers.IO และ Dispatchers.Default

แต่ว่า Dispatchers.Unconfined เราแทบจะไม่ได้ใช้กันเลยใช่ไหมครับ แล้วเจ้า Dispatchers.Unconfined มัน dispatch work ไปให้ Thread หรือ Thread Pool ตัวไหนกันนะ?

คำตอบก็อยู่ในชื่อมันแล้วแหละครับ

ก็คือไม่จำกัดอยู่บน Thread หรือ Thread Pool ตัวไหน แต่มันจะย้ายไปตาม Context ที่อยู่ใน suspend fun ที่ถูกเรียกใน Unconfined scope เช่น

import kotlinx.coroutines.*

suspend fun example(tag: String) {
println("example($tag): I'm working in thread ${Thread.currentThread().name}")
}

fun main() = runBlocking<Unit> {

launch(Dispatchers.Unconfined) { // not confined -- will work with main thread
println("Unconfined : I'm working in thread ${Thread.currentThread().name}")
delay(500)
example("Unconfined")
println("Unconfined : After delay in thread ${Thread.currentThread().name}")
}
launch { // context of the parent, main runBlocking coroutine
println("main runBlocking: I'm working in thread ${Thread.currentThread().name}")
delay(1000)
example("main")
println("main runBlocking: After delay in thread ${Thread.currentThread().name}")
}

}

เราจะได้ผลลัพธ์ออกมาแบบนี้

Unconfined       : I'm working in thread main @coroutine#2
main runBlocking : I'm working in thread main @coroutine#3
example(Unconfined) : I'm working in thread kotlinx.coroutines.DefaultExecutor @coroutine#2
Unconfined : After delay in thread kotlinx.coroutines.DefaultExecutor @coroutine#2
example(main) : I'm working in thread main @coroutine#3
main runBlocking : After delay in thread main @coroutine#3
Unconfined Dispatcher (Coroutine#2)

จะเห็นว่าใน Coroutine#2 (Dispatchers.Unconfined) ตอนแรกจะถูกทำงานอยู่บน Main Thread แต่เมื่อเรียก suspend fun example() function นี้จะถูก execute บน DefaultExecutorเนื่องจาก delay function ที่ถูก execute บน DefaultExecutor และเมื่อออกมาจาก example() Coroutine#2 ก็จะทำงานบน DefaultExecutor ต่อ

Initially run on Main Thread -> encounter delay which switches to DefaultExecutor -> continue to use DefaultExecutor

แล้วทีนี้เราจะเจอปัญหาอะไรอีกไหม ถ้าเราใช้UnconfinedTestDispatcher

แน่นอนว่าถ้าเราต้องใช้ Third-party library อย่างเช่น Room ที่เมื่อเราเขียนให้ Query return ออกมาเป็น suspend fun เบื้องหลังของ Room จะมีการ dispatch coroutine ของ function นั้นไปอยู่ใน background executor ให้

ถ้าสมมติว่าเราต้องใช้ ReentrantReadWriteLock | Android Developers เพื่อ read lock และ/หรือ write lock แบบนี้

class Repository(
private val remoteApi: RemoteApi,
private val localDb: LocalDb,
private val coroutineDispatcher: CoroutineDispatcher
) {

val isSyncingStateFlow: MutableStateFlow<Boolean?> = MutableStateFlow(null)
val readWriteLock: ReentrantReadWriteLock = ReentrantReadWriteLock()

fun syncData() {
CoroutineScope(Job() + coroutineDispatcher).launch {
val result = readWriteLock.write {
isSyncingStateFlow.emit(true)
remoteApi.fetch()
}
localDb.save(result)
isSyncingStateFlow.emit(false)
println("syncData successfully")
}
}

suspend fun getData(): String {
return readWriteLock.read {
localDb.get()
}
}

}

และเมื่อ Test ของเราใช้ UnconfinedTestDispatcher แบบนี้

class RepositoryTest {
@Test
fun testSyncData() = runTest {
// Given
...
coEvery {
remoteApi.fetch()
} coAnswers {
withContext(Dispatchers.IO) { // hardcode IO like Room
"data from remote"
}
}
val testDispatcher = UnconfinedTestDispatcher(testScheduler)
val underTest = Repository(remoteApi, localDb, testDispatcher)

// When
underTest.syncData()
advanceUntilIdle()
val result = underTest.getData()

// Then
Assert.assertEquals("data from remote", result)

}
}

ใน Test ของเราทำการ mock remoteApi.fetch() ให้ใช้ Dispatchers.IO เมื่อเรา run test เราก็จะเจอกับ

Exception in thread "DefaultDispatcher-worker-1 @coroutine#8" java.lang.IllegalMonitorStateException

นั่นก็เป็นเพราะว่า ReentrantReadWriteLock ของ Java นั้นอธิบาย unlock function ไว้ว่า

Attempts to release this lock.
If the current thread is the holder of this lock then the hold count is decremented.
If the hold count is now zero then the lock is released.
If the current thread is not the holder of this lock then IllegalMonitorStateException is thrown.

หมายความว่าถ้า Thread ที่ unlock เป็นคนละตัวกับ Thread ที่ใช้ lock จะ throw IllegalMonitorStateException ออกมา

แต่ถ้าเราใช้ StandardTestDispatcher Test ของเราก็จะผ่านตามปกติ

ปล. จริง ๆ เราไม่ควรใช้ ReentrantReadWriteLock ของ Java ใน Coroutine นะ เพราะว่า Concept ของ Coroutine คือ Abstract Concurrency Pattern หมายความว่าเราไม่รู้หรอกว่า Coroutine ของเราจะไปถูก execute อยู่บน Thread หรือ Threads Pool ตัวไหนนั่นเอง
แต่ว่าปัจจุบัน ReadWriteLock Coroutine version ก็ยังไม่คลอดออกมาซักที ติดตามได้ที่ Read-write Mutex · Issue #94 · Kotlin/kotlinx.coroutines (github.com)

สรุประหว่าง StandardTestDispatcher กับ UnconfinedTestDispatcher

  1. ในเคสทั่วไป แนะนำให้ใช้ UnconfinedTestDispatcher เพราะว่ามันจะ execute coroutine ของเราให้ทันที ไม่ต้องมาเรียก runCurrent() หรือ advanceUntilIdle()
  2. ถ้าลำดับ Order ของ Coroutine เป็นสิ่งสำคัญ เช่น การ Test StateFlow emission ที่ไม่มี delay คั้นกลาง ให้ใช้ StandardTestDispatcher
  3. ในกรณีที่ต้อง test third-party library ที่เราไม่รู้เบื้องหลังว่ามีการ Hardcode Dispatcher นอกเหนือจาก Dispatchers.Main ก็ให้ใช้ StandardTestDispatcher

เกร็ดเล็ก เกร็ดน้อย

ก่อนจะจากกันก็อยากจะเสริมเกร็ดเล็ก เกร็ดน้อย ที่ให้ชีวิตเราสบายขึ้นเมื่อ Test Coroutine

#1 Set Main Dispatcher

อย่างที่เราทราบกันว่าเราต้องใช้ TestScheduler ตัวเดียวกันทั้ง Test เราก็ควรจะมี Reusable Component ซักตัวที่ทำเรียก Dispatchers.setMain ไว้ เพราะเมื่อเราเรียก runTest มันจะไปใช้ TestScheduler จาก TestMainDispatcher.currentTestScheduler เช่น สร้าง JUnit4 Rule แบบนี้

class TestMainDispatcherRule(
val testDispatcher: TestDispatcher = UnconfinedTestDispatcher()
) : TestWatcher() {

override fun starting(description: Description?) {
super.starting(description)
Dispatchers.setMain(testDispatcher)
}

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

เอาไปใช้แบบนี้

class TestClass {
@get:Rule
val testMainDispatcherRule = TestMainDispatcherRule()

@Test
fun test() = runTest { // StandardTestDispatcher ที่ใช้ TestScheduler จาก rule
val anotherStandardTestDispatcher = StandardTestDispatcher() // ใช้ TestScheduler จาก rule เช่นเดียวกัน
}
}

การที่เราเรียก Dispatchers.setMain ยังดีต่อการ Test ViewModel ที่เรียก viewModelScope อีกด้วย เนื่องจากตอนที่ viewModelScope ถูกสร้างขึ้นมาจะใช้ Main Dispatcher

#2 ใช้ TestScope.backgroundScope ในการ launch coroutine ที่ต้องการ cancel เมื่อ test เสร็จ

ถ้าเรามีการ launch coroutine เพื่อ collect non-terminated flow เช่น StateFlow, SharedFlow หรือ Channel ตาม Test ด้านบนก่อนหน้านี้ แบบนี้

class RepositoryTest {
@Test
fun testSyncData() = runTest {
// Given
val remoteApi: RemoteApi = mockk()
val localDb: LocalDb = mockk()
val testDispatcher = StandardTestDispatcher(testScheduler)
coEvery {
remoteApi.fetch()
} coAnswers {
"data from remote"
}
val data = slot<String>()
coJustRun {
localDb.save(capture(data))
}
val result: MutableList<Boolean> = mutableListOf()
val underTest = Repository(remoteApi, localDb, testDispatcher)

// When
val job = launch(UnconfinedTestDispatcher(testScheduler)) {
underTest.isSyncingStateFlow.filterNotNull().toCollection(result)
}
underTest.syncData()
runCurrent()
job.cancel() // must call cancel to let the test finishes

// Then
Assert.assertEquals(listOf(true, false), result)

}
}

จะเห็นว่าเราต้องเรียก job.cancel() เพื่อให้ Coroutine ตัวนี้เพื่อให้ Test ของเราจบ แต่ถ้าเราไม่เรียก job.cancel() Test ของเราก็จะ run ต่อไปเรื่อย ๆ จน timeout ซึ่ง Default Timeout จะอยู่ที่ 10 วินาที หรือ 1 นาที (แล้วแต่ version)

After waiting for 60000 ms, the test coroutine is not completing, there were active child jobs: ["coroutine#6":StandaloneCoroutine{Active}@34a2d6e0]

ทาง Library ก็เลยมี backgroundScope ซึ่งเป็น field ที่อยู่ใน TestScope

A scope for background work.
This scope is automatically cancelled when the test finishes. Additionally, while the coroutines in this scope are run as usual when using advanceTimeBy and runCurrent, advanceUntilIdle will stop advancing the virtual time once only the coroutines in this scope are left unprocessed.
Failures in coroutines in this scope do not terminate the test. Instead, they are reported at the end of the test. Likewise, failure in the TestScope itself will not affect its backgroundScope, because there’s no parent-child relationship between them.

class RepositoryTest {
@Test
fun testSyncData() = runTest {
// Given
val remoteApi: RemoteApi = mockk()
val localDb: LocalDb = mockk()
val testDispatcher = StandardTestDispatcher(testScheduler)
coEvery {
remoteApi.fetch()
} coAnswers {
"data from remote"
}
val data = slot<String>()
coJustRun {
localDb.save(capture(data))
}
val result: MutableList<Boolean> = mutableListOf()
val underTest = Repository(remoteApi, localDb, testDispatcher)

// When
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
// Coroutine in this scope will be cancelled when runTest is done.
underTest.isSyncingStateFlow.filterNotNull().toCollection(result)
}
underTest.syncData()
runCurrent()

// Then
Assert.assertEquals(listOf(true, false), result)

}
}

สรุป

  • ใช้ kotlinx-coroutines-test version 1.6 ขึ้นไป
  • ใช้ runTest อย่าใช้ runBlocking นะ เพราะ runTest จะ skip delay ให้เรา
  • ปรับ code ของเราให้ Inject CoroutineDispatcher ได้
  • พยายามเลือกใช้ UnconfinedTestDispatcher มากกว่า StandardTestDispatcher ถ้า Test ของเราไม่จำเป็นต้องสนใจถึงลำดับการทำงานของ Coroutine
  • ใช้ StandardTestDispatcher ถ้าเราต้องเรียก suspend fun จาก Third-party library เช่น Room
  • Set main dispatcher ผ่าน Dispatchers.setMain ในกรณีที่ต้อง test viewModelScope — จริง ๆ สร้าง Rule แล้ว reuse ในทุก Test Class ไปเลย
  • ใช้ TestScope.backgroundScope เพื่อ launch coroutine ที่ต้องการถูก cancel หลัง test จบ แม้ว่า coroutine นั้นจะยังไม่เสร็จ เช่น กรณีที่เราต้อง collect non-terminated flow
  • ใช้ UnconfinedTestDispatcher เมื่อ collectStateFlow , SharedFlow หรือ Channel เพื่อให้มันเริ่ม collect ทันที
บทความจะยาวไปหนายยยยยย

ตอนนี้เรายังเปิดรับสมัคร พนักงาน ตำแหน่ง Software Engineer, Android อยู่นะครับ ลองเข้าไปเช็ครายละเอียดเพิ่มเติมได้ที่ https://careers.lmwn.com/ เลยครับ 😄

--

--