Bab ini berfokus pada pengujian kode coroutine. Karena suspend function tidak bisa dipanggil langsung dari kode biasa, tes perlu dibungkus dengan runBlocking. Library kotlinx-coroutines-test menyediakan runTest dengan virtual time, tetapi ketergantungan tambahan tidak selalu diinginkan. Bab ini menunjukkan pola pengujian dengan runBlocking dan withTimeout saja — pola yang bekerja di environment apa pun dan tetap deterministik untuk delay kecil.
Komponen yang Akan Dites #
Bayangkan sebuah UserRepository dengan beberapa metode suspend: ambilPengguna(id), ambilBanyak(ids), dan berisiko() yang melempar exception. Komponen ini akan menjadi target tes kita sepanjang bab.
import kotlinx.coroutines.*
import kotlin.coroutines.cancellation.CancellationException
// ===== Komponen yang akan dites =====
class UserRepository(private val scope: CoroutineScope) {
private val cache = mutableMapOf<Int, String>()
suspend fun ambilPengguna(id: Int): String {
delay(30) // simulasi panggilan async
cache.get(id)?.let { return it }
val data = "pengguna-$id"
cache[id] = data
return data
}
suspend fun ambilBanyak(ids: List<Int>): List<String> = coroutineScope {
ids.map { id -> async { ambilPengguna(id) } }.awaitAll()
}
suspend fun berisiko(): String {
delay(20)
throw IllegalStateException("koneksinya gagal")
}
}
// ===== Helper tes =====
fun <T> testRunBlocking(blok: suspend CoroutineScope.() -> T): T =
runBlocking { blok() }
// ===== Kumpulan tes =====
fun main() = runBlocking {
println("=== 1. Tes suspend function dasar ===")
val repo = UserRepository(this)
val hasil = repo.ambilPengguna(1)
println("hasil = $hasil")
assert(hasil == "pengguna-1")
println("assert lolos: ambilPengguna(1) == pengguna-1")
println()
println("=== 2. Tes konkurensi dengan async ===")
val banyak = repo.ambilBanyak(listOf(10, 11, 12))
println("hasil = $banyak")
assert(banyak == listOf("pengguna-10", "pengguna-11", "pengguna-12"))
println("assert lolos: ambilBanyak mengembalikan urutan sama")
println()
println("=== 3. Tes exception dari suspend function ===")
var exceptionDitangkap: Throwable? = null
try {
repo.berisiko()
} catch (e: IllegalStateException) {
exceptionDitangkap = e
}
println("exception = ${exceptionDitangkap?.message}")
assert(exceptionDitangkap?.message == "koneksinya gagal")
println("assert lolos: exception dirambatkan dengan benar")
println()
println("=== 4. Tes timeout: dengan withTimeout ===")
val cepat = withTimeoutOrNull(50) { repo.ambilPengguna(2) }
println("cepat (30ms, timeout 50ms) = $cepat")
assert(cepat == "pengguna-2")
println("assert lolos: selesai sebelum timeout")
println()
println("=== 5. Tes timeout: operasi lebih lambat dari batas ===")
val lambat = withTimeoutOrNull(15) { repo.ambilPengguna(99) } // ambil butuh 30ms
println("lambat (30ms, timeout 15ms) = $lambat")
assert(lambat == null)
println("assert lolos: timeout mengembalikan null")
println()
println("=== 6. Tes pembatalan job ===")
var nilaiSaatDibatalkan: Int = -1
val job = launch {
var i = 0
try {
while (true) {
delay(20)
i++
nilaiSaatDibatalkan = i
}
} catch (e: CancellationException) {
println("[job] dibatalkan setelah i=$i")
throw e // wajib lempar ulang
}
}
delay(50)
job.cancelAndJoin()
println("nilai saat dibatalkan = $nilaiSaatDibatalkan")
assert(nilaiSaatDibatalkan >= 1)
println("assert lolos: job berjalan minimal 1 iterasi sebelum dibatalkan")
println()
println("=== 7. Tes struktur scope: parent membatalkan child ===")
var childDibatalkan = false
val parentJob = launch {
launch {
try {
delay(500)
} catch (e: CancellationException) {
childDibatalkan = true
println("[child] dibatalkan")
throw e
}
}
delay(50)
println("[parent] membatalkan diri sendiri")
throw CancellationException("sengaja")
}
parentJob.join()
println("childDibatalkan = $childDibatalkan")
assert(childDibatalkan)
println("assert lolos: pembatalan parent dirambatkan ke child")
println()
println("SEMUA TES LOLOS")
}Output yang Dihasilkan:
=== 1. Tes suspend function dasar ===
hasil = pengguna-1
assert lolos: ambilPengguna(1) == pengguna-1
=== 2. Tes konkurensi dengan async ===
hasil = [pengguna-10, pengguna-11, pengguna-12]
assert lolos: ambilBanyak mengembalikan urutan sama
=== 3. Tes exception dari suspend function ===
exception = koneksinya gagal
assert lolos: exception dirambatkan dengan benar
=== 4. Tes timeout: dengan withTimeout ===
cepat (30ms, timeout 50ms) = pengguna-2
assert lolos: selesai sebelum timeout
=== 5. Tes timeout: operasi lebih lambat dari batas ===
lambat (30ms, timeout 15ms) = null
assert lolos: timeout mengembalikan null
=== 6. Tes pembatalan job ===
[job] dibatalkan setelah i=2
nilai saat dibatalkan = 2
assert lolos: job berjalan minimal 1 iterasi sebelum dibatalkan
=== 7. Tes struktur scope: parent membatalkan child ===
[parent] membatalkan diri sendiri
[child] dibatalkan
childDibatalkan = true
assert lolos: pembatalan parent dirambatkan ke child
SEMUA TES LOLOSMari kita bedah setiap tes:
- Tes 1 (dasar): panggil
ambilPengguna(1)di dalamrunBlocking. Hasilnya"pengguna-1"diperiksa denganassert. Pola ini adalah dasar semua tes suspend — bungkus denganrunBlockingdan gunakan assertion standar. - Tes 2 (konkurensi
async):ambilBanyakmemakaiasyncdi dalamcoroutineScope. Urutan hasil[pengguna-10, pengguna-11, pengguna-12]sama dengan urutan input list — ini penting karenaasyncbisa selesai dalam urutan acak, tetapiawaitAllmempertahankan urutan. - Tes 3 (exception): suspend function yang melempar exception dapat ditangkap dengan
try/catchdi sekitar pemanggilan. Tidak ada boilerplate tambahan — exception dirambatkan seperti kode sinkron biasa. - Tes 4 (timeout sukses):
withTimeoutOrNull(50)membungkus pemanggilan yang selesai dalam 30ms. Karena 30ms < 50ms, hasil"pengguna-2"dikembalikan. - Tes 5 (timeout gagal): kebalikan dari Tes 4. Pemanggilan butuh 30ms, tetapi timeout 15ms. Hasil
nullmenandakan operasi dibatalkan sebelum selesai. - Tes 6 (pembatalan job):
launch { while(true) { delay(20); i++ } }dijalankan. Setelah 50ms,job.cancelAndJoin()membatalkan. Blokcatch (e: CancellationException)menangkap dan melempar ulang. Aturan: selalu lempar ulang setelah logging, atau structured concurrency rusak. - Tes 7 (parent-child): structured concurrency menyebarkan pembatalan dari parent ke child. Saat parent melempar
CancellationException, child yang sedangdelay(500)langsung dibatalkan — ditangkap olehtry/catchdi child. Output membuktikan[child] dibatalkanmuncul sebelumparentJob.join()kembali.
Aturan Penting dalam Tes Coroutine #
Beberapa aturan yang sering dilupakan saat menulis tes coroutine:
| Aturan | Konsekuensi bila dilanggar |
|---|---|
Bungkus suspend call dengan runBlocking | Kompilator menolak memanggil suspend dari non-suspend context |
withTimeout / withTimeoutOrNull mencegah tes menggantung | Tes bisa menggantung selamanya bila coroutine tidak pernah selesai |
Tangkap CancellationException lalu lempar ulang | Structured concurrency rusak; job tidak benar-benar dibatalkan |
Gunakan runBlocking { } di main(), BUKAN runBlocking dalam fungsi @Test | Pola idiomatik Kotlin/JVM adalah runTest { }, tapi runBlocking tetap berfungsi |
| Verifikasi state akhir, bukan timing internal | Tes yang mengandalkan jumlah iterasi rentan flaky |
Pola Helper Tes #
Helper testRunBlocking membungkus runBlocking agar tes lebih ringkas:
fun <T> testRunBlocking(blok: suspend CoroutineScope.() -> T): T =
runBlocking { blok() }
// Pemakaian:
@Test fun ambilPenggunaMengembalikanData() = testRunBlocking {
val repo = UserRepository(this)
assertEquals("pengguna-1", repo.ambilPengguna(1))
}Verifikasi Pembatalan: Tanpa runTest #
Pengujian pembatalan tanpa runTest membutuhkan strategi:
- Mulai job dengan
launch { }dan simpan referensinya. - Delay sebentar agar job sempat berjalan beberapa iterasi.
- Panggil
cancelAndJoin()untuk membatalkan dan menunggu job benar-benar berhenti. - Verifikasi state yang diatur oleh job — apakah
nilaiSaatDibatalkanmencerminkan jumlah iterasi yang diharapkan?
Tes 6 di atas mengikuti pola ini. Assert nilaiSaatDibatalkan >= 1 tidak terlalu ketat (== 2 pun mungkin valid), tetapi cukup membuktikan bahwa job sempat berjalan sebelum dibatalkan. Menghindari asertusi ketat pada timing membuat tes tidak flaky.
Referensi Lanjutan #
- Coroutine basics
— dasar
runTestdanrunBlockinguntuk pengujian - Composing suspending functions
— pola pengujian pemanggilan
suspendfunction
Selanjutnya #
Bab terakhir di section Coroutines, Coroutine Best Practices, akan merangkum aturan praktis yang harus selalu diingat saat menulis kode coroutine: hindari GlobalScope, gunakan withContext untuk pindah dispatcher, kembalikan Flow alih-alih Job, dan tulis kode yang cancellation-aware. Aturan-aturan ini menjaga kode tetap aman, mudah diuji, dan ringkas.