เขียน Test Kotlin Coroutine บน Android อย่างไรถึงจะโอเคนะ
สวัสดีครับผมชื่อ มิตจัง เป็น 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 คร่าว ๆ ของบทความนี้
runTest
vsrunBlocking
runTest
มันทำงานอย่างไร?- ทำไมเราถึงควรทำให้ Code ของเรา Inject Dispatcher เข้าไปได้
- เราควบคุมเวลาของการ Test ได้ผ่าน
advanceTimeBy
,advanceUntilIdle
,runCurrent
- ระหว่าง
StandardTestDispatcher
กับUnconfinedTestDispatcher
เราควรเลือกใช้ตัวไหนกันนะ? - เกร็ดเล็กเกร็ดน้อยในการ 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
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 + XrunCurrent
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
เหล่านี้
Dispatchers.Main
— ใน Android มันก็คือทำงานบน UI ThreadDispatchers.IO
— Task จะถูกโยนไปหา Threads Pool เหมาะกับ Task ที่ทำงานนาน ๆ เช่น I/O, network API callDispatchers.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
จะเห็นว่าใน 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
- ในเคสทั่วไป แนะนำให้ใช้
UnconfinedTestDispatcher
เพราะว่ามันจะ execute coroutine ของเราให้ทันที ไม่ต้องมาเรียกrunCurrent()
หรือadvanceUntilIdle()
- ถ้าลำดับ Order ของ Coroutine เป็นสิ่งสำคัญ เช่น การ Test
StateFlow
emission ที่ไม่มี delay คั้นกลาง ให้ใช้StandardTestDispatcher
- ในกรณีที่ต้อง 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
version1.6
ขึ้นไป - ใช้
runTest
อย่าใช้runBlocking
นะ เพราะrunTest
จะ skipdelay
ให้เรา - ปรับ code ของเราให้ Inject
CoroutineDispatcher
ได้ - พยายามเลือกใช้
UnconfinedTestDispatcher
มากกว่าStandardTestDispatcher
ถ้า Test ของเราไม่จำเป็นต้องสนใจถึงลำดับการทำงานของ Coroutine - ใช้
StandardTestDispatcher
ถ้าเราต้องเรียกsuspend fun
จาก Third-party library เช่น Room - Set main dispatcher ผ่าน
Dispatchers.setMain
ในกรณีที่ต้อง testviewModelScope
— จริง ๆ สร้าง 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/ เลยครับ 😄