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.

kotlin
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:

text
=== 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 LOLOS

Mari kita bedah setiap tes:

  1. Tes 1 (dasar): panggil ambilPengguna(1) di dalam runBlocking. Hasilnya "pengguna-1" diperiksa dengan assert. Pola ini adalah dasar semua tes suspend — bungkus dengan runBlocking dan gunakan assertion standar.
  2. Tes 2 (konkurensi async): ambilBanyak memakai async di dalam coroutineScope. Urutan hasil [pengguna-10, pengguna-11, pengguna-12] sama dengan urutan input list — ini penting karena async bisa selesai dalam urutan acak, tetapi awaitAll mempertahankan urutan.
  3. Tes 3 (exception): suspend function yang melempar exception dapat ditangkap dengan try/catch di sekitar pemanggilan. Tidak ada boilerplate tambahan — exception dirambatkan seperti kode sinkron biasa.
  4. Tes 4 (timeout sukses): withTimeoutOrNull(50) membungkus pemanggilan yang selesai dalam 30ms. Karena 30ms < 50ms, hasil "pengguna-2" dikembalikan.
  5. Tes 5 (timeout gagal): kebalikan dari Tes 4. Pemanggilan butuh 30ms, tetapi timeout 15ms. Hasil null menandakan operasi dibatalkan sebelum selesai.
  6. Tes 6 (pembatalan job): launch { while(true) { delay(20); i++ } } dijalankan. Setelah 50ms, job.cancelAndJoin() membatalkan. Blok catch (e: CancellationException) menangkap dan melempar ulang. Aturan: selalu lempar ulang setelah logging, atau structured concurrency rusak.
  7. Tes 7 (parent-child): structured concurrency menyebarkan pembatalan dari parent ke child. Saat parent melempar CancellationException, child yang sedang delay(500) langsung dibatalkan — ditangkap oleh try/catch di child. Output membuktikan [child] dibatalkan muncul sebelum parentJob.join() kembali.

Aturan Penting dalam Tes Coroutine #

Beberapa aturan yang sering dilupakan saat menulis tes coroutine:

AturanKonsekuensi bila dilanggar
Bungkus suspend call dengan runBlockingKompilator menolak memanggil suspend dari non-suspend context
withTimeout / withTimeoutOrNull mencegah tes menggantungTes bisa menggantung selamanya bila coroutine tidak pernah selesai
Tangkap CancellationException lalu lempar ulangStructured concurrency rusak; job tidak benar-benar dibatalkan
Gunakan runBlocking { } di main(), BUKAN runBlocking dalam fungsi @TestPola idiomatik Kotlin/JVM adalah runTest { }, tapi runBlocking tetap berfungsi
Verifikasi state akhir, bukan timing internalTes yang mengandalkan jumlah iterasi rentan flaky

Pola Helper Tes #

Helper testRunBlocking membungkus runBlocking agar tes lebih ringkas:

kotlin Helper tes reusable
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:

  1. Mulai job dengan launch { } dan simpan referensinya.
  2. Delay sebentar agar job sempat berjalan beberapa iterasi.
  3. Panggil cancelAndJoin() untuk membatalkan dan menunggu job benar-benar berhenti.
  4. Verifikasi state yang diatur oleh job — apakah nilaiSaatDibatalkan mencerminkan 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 #

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.