Menguji Flow berbeda dari menguji koleksi biasa karena Flow bersifat asynchronous dan lazy. Bab ini memperkenalkan pola dasar pengujian Flow tanpa dependensi tambahan: gunakan runBlocking untuk membungkus suspend call, dan flow.toList(...) untuk menangkap semua emit ke List. Setelah ditangkap, List dapat diverifikasi dengan assertion standar Kotlin. Pendekatan ini bekerja pada semua environment karena tidak membutuhkan library Turbine atau kotlinx-coroutines-test — hanya kotlinx-coroutines-core yang sudah familiar.

Pola Dasar: toList() #

Operator terminal toList() menangkap semua nilai yang dipancarkan Flow ke dalam List<T>. Setelah Flow selesai (akhir emit atau exception), toList mengembalikan List yang dapat diperiksa.

kotlin
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*

fun main() = runBlocking {
    println("=== 1. Test sederhana: toList() menangkap semua emit ===")
    val angka = flowOf(1, 2, 3, 4, 5)
    val hasil = angka.toList()
    println("hasil toList = $hasil")
    assert(hasil == listOf(1, 2, 3, 4, 5)) { "angka harus berisi 1..5" }
    println("assert lolos: angka == 1..5")

    println()
    println("=== 2. Test operator: map + filter ===")
    val ganjilKali10 = flowOf(1, 2, 3, 4, 5)
        .filter { it % 2 == 1 }
        .map { it * 10 }
        .toList()
    println("ganjil * 10 = $ganjilKali10")
    assert(ganjilKali10 == listOf(10, 30, 50))
    println("assert lolos: ganjilKali10 == 10, 30, 50")

    println()
    println("=== 3. Test dengan delay: menunggu real time ===")
    val flowDelay = flow {
        emit("mula")
        delay(80)
        emit("tengah")
        delay(80)
        emit("akhir")
    }
    val listDelay = withTimeoutOrNull(500) { flowDelay.toList() }
    println("flow delay -> $listDelay")
    assert(listDelay == listOf("mula", "tengah", "akhir"))
    println("assert lolos: semua emit tertangkap")

    println()
    println("=== 4. Test exception: flow { throw } ===")
    val flowGagal = flow<Int> {
        emit(1)
        emit(2)
        throw IllegalStateException("sengaja gagal di emit ke-3")
    }
    val hasilGagal = mutableListOf<Int>()
    var exceptionDitangkap: Throwable? = null
    try {
        flowGagal.collect {
            hasilGagal.add(it)
        }
    } catch (e: IllegalStateException) {
        exceptionDitangkap = e
    }
    println("nilai tertangkap sebelum exception = $hasilGagal")
    println("exception = ${exceptionDitangkap?.message}")
    assert(hasilGagal == listOf(1, 2))
    assert(exceptionDitangkap?.message == "sengaja gagal di emit ke-3")
    println("assert lolos: emit 1 dan 2 tertangkap, exception sesuai pesan")

    println()
    println("=== 5. Test StateFlow: emisi nilai terbaru ===")
    val state = MutableStateFlow(0)
    val tangkapan = mutableListOf<Int>()
    val job = launch {
        state.collect { tangkapan.add(it) }
    }
    delay(20)  // biarkan collector menerima nilai awal
    state.value = 1
    delay(20)
    state.value = 2
    delay(20)
    state.value = 2  // nilai sama, tidak memancarkan
    delay(20)
    state.value = 3
    delay(20)
    job.cancel()
    println("tangkapan StateFlow = $tangkapan")
    assert(tangkapan == listOf(0, 1, 2, 3))
    println("assert lolos: StateFlow memancarkan nilai terbaru dan membuang duplikat")

    println()
    println("=== 6. Test urutan emit dengan runBlocking ===")
    val urutan = mutableListOf<String>()
    val flowUrutan = flow {
        emit("satu"); urutan.add("emit:satu")
        emit("dua"); urutan.add("emit:dua")
        emit("tiga"); urutan.add("emit:tiga")
    }
    flowUrutan.collect { urutan.add("collect:$it") }
    println("urutan eksekusi = $urutan")
    // emit("satu") memicu collector -> "collect:satu" ditambah dulu,
    // lalu setelah emit kembali, "emit:satu" ditambah.
    val expected = listOf(
        "collect:satu", "emit:satu",
        "collect:dua", "emit:dua",
        "collect:tiga", "emit:tiga"
    )
    assert(urutan == expected)
    println("assert lolos: collect dipanggil sinkron saat emit, lalu emit return")

    println()
    println("SEMUA TES LOLOS")
}

Output yang Dihasilkan:

text
=== 1. Test sederhana: toList() menangkap semua emit ===
hasil toList = [1, 2, 3, 4, 5]
assert lolos: angka == 1..5

=== 2. Test operator: map + filter ===
ganjil * 10 = [10, 30, 50]
assert lolos: ganjilKali10 == 10, 30, 50

=== 3. Test dengan delay: menunggu real time ===
flow delay -> [mula, tengah, akhir]
assert lolos: semua emit tertangkap

=== 4. Test exception: flow { throw } ===
nilai tertangkap sebelum exception = [1, 2]
exception = sengaja gagal di emit ke-3
assert lolos: emit 1 dan 2 tertangkap, exception sesuai pesan

=== 5. Test StateFlow: emisi nilai terbaru ===
tangkapan StateFlow = [0, 1, 2, 3]
assert lolos: StateFlow memancarkan nilai terbaru dan membuang duplikat

=== 6. Test urutan emit dengan runBlocking ===
urutan eksekusi = [collect:satu, emit:satu, collect:dua, emit:dua, collect:tiga, emit:tiga]
assert lolos: collect dipanggil sinkron saat emit, lalu emit return

SEMUA TES LOLOS

Mari kita bedah tiap tes di atas:

  1. Tes 1 (toList()): pola paling sederhana. flowOf(1,2,3,4,5).toList() menghasilkan List<Int> berisi semua nilai emit. Assertion membandingkan langsung dengan listOf(1,2,3,4,5). Pendekatan ini cepat dan deterministik karena flowOf tidak memanggil delay.
  2. Tes 2 (operator): pipeline filter { } + map { } ditest sebagai satu kesatuan. Hasil 10, 30, 50 berasal dari (1,3,5) * 10. Pola ini cocok untuk verifikasi rantai transformasi.
  3. Tes 3 (delay real time): Flow dengan delay(80) tidak bisa di-skip tanpa runTest dari library test. Karena kita tidak memakai library itu, kita tunggu real time dengan withTimeoutOrNull(500) — batas 500ms cukup untuk total delay 160ms, dan tes lolos dengan cepat.
  4. Tes 4 (exception): exception yang dilempar dari blok flow { } dirambatkan ke collect. Kita tangkap dengan try/catch di sekitar collect. List hasilGagal hanya berisi nilai yang sempat dipancarkan sebelum exception — [1, 2]. Pesan exception bisa diverifikasi.
  5. Tes 5 (StateFlow): karena StateFlow adalah hot stream, kita tidak bisa langsung pakai toList() (ia tidak pernah selesai). Solusi: jalankan collect di launch terpisah, beri delay antar update agar collector sempat menerima, lalu cancel() job. Hasil [0, 1, 2, 3] membuktikan StateFlow memancarkan nilai awal (0) dan setiap perubahan, tetapi membuang nilai sama berturut-turut (2 di-set dua kali hanya muncul sekali).
  6. Tes 6 (urutan emit-collect): pola ini memverifikasi sifat sinkron emit-collect. Saat emit("satu") dipanggil, collector dipanggil dulu (menambah "collect:satu"), baru setelah emit kembali, "emit:satu" ditambah. Urutan List membuktikan: collect:satu, emit:satu, collect:dua, emit:dua, ....

Catatan tentang Virtual Time dan runTest #

Library kotlinx-coroutines-test menyediakan runTest yang memakai virtual time — delay(80) di dalam runTest tidak menunggu nyata, melainkan dilompati. Hal ini mempercepat tes yang banyak delay. Sayangnya library tersebut tidak selalu tersedia, dan integrasinya memerlukan konfigurasi dependensi.

Tanpa runTest, ada dua pilihan:

  • Tunggu real time seperti Tes 3 di atas. Cocok untuk tes singkat dengan delay kecil (< 1 detik total).
  • Hindari delay di kode produksi yang akan dites. Pisahkan logika dari waktu — ekstrak delay sebagai parameter, atau gunakan flow { emit(...); emit(...) } tanpa delay untuk keperluan tes.

Strategi Testing Berdasarkan Jenis Flow #

Jenis FlowStrategiContoh
Cold Flow deterministiktoList() lalu assertflowOf(1,2,3).toList()
Cold Flow dengan delaywithTimeoutOrNull { toList() }Tes 3 di atas
Cold Flow yang melempar exceptiontry/catch di sekitar collectTes 4 di atas
StateFlow (hot)launch { collect }, delay, cancel()Tes 5 di atas
SharedFlow (hot)Sama dengan StateFlow, atau replay di MutableSharedFlow untuk inspectSama polanya
Flow dengan urutan pentingmutableListOf + add di onEach dan collectTes 6 di atas

Pola Helper untuk Tes StateFlow #

Untuk tes StateFlow yang sering, pola berikut bisa dijadikan helper:

kotlin Helper: tangkap StateFlow selama window waktu
suspend fun <T> StateFlow<T>.tangkapSelama(windowMs: Long): List<T> = coroutineScope {
    val tangkapan = mutableListOf<T>()
    val job = launch { collect { tangkapan.add(it) } }
    delay(windowMs)
    job.cancel()
    tangkapan
}

Helper ini mengkapsulasi pola launch → delay → cancel agar tes StateFlow lebih ringkas:

kotlin Penggunaan helper
val state = MutableStateFlow(0)
state.value = 1
state.value = 2
val hasil = state.tangkapSelama(50)
assert(hasil.last() == 2)

Referensi Lanjutan #

Selanjutnya #

Pengujian Flow selesai. Bab berikutnya, Callback to Coroutine, akan membahas teknik mengubah API berbasis callback menjadi suspend function menggunakan suspendCoroutine dan suspendCancellableCoroutine. Topik ini penting untuk mengintegrasikan library lama (yang masih berbasis callback) ke dalam kode coroutine modern.