Menguji coroutine Kotlin di Android

Kode pengujian unit yang menggunakan coroutine memerlukan perhatian tambahan, karena eksekusinya dapat terjadi secara asinkron dan terjadi di beberapa thread. Panduan ini membahas cara menguji fungsi penangguhan, konstruksi pengujian yang harus Anda pahami, dan cara membuat kode yang menggunakan coroutine dapat diuji.

API yang digunakan dalam panduan ini adalah bagian dari library kotlinx.coroutines.test. Pastikan untuk menambahkan artefak sebagai dependensi pengujian ke project Anda agar memiliki akses ke API ini.

dependencies {
    testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:$coroutines_version"
}

Memanggil fungsi penangguhan dalam pengujian

Untuk memanggil fungsi penangguhan dalam pengujian, Anda harus berada di coroutine. Karena fungsi pengujian JUnit itu sendiri bukan merupakan fungsi penangguhan, Anda perlu memanggil builder coroutine dalam pengujian untuk memulai coroutine baru.

runTest adalah builder coroutine yang dirancang untuk pengujian. Gunakan builder ini untuk menggabungkan pengujian yang menyertakan coroutine. Perhatikan bahwa coroutine dapat dimulai tidak hanya secara langsung di isi pengujian, tetapi juga dengan objek yang digunakan dalam pengujian.

suspend fun fetchData(): String {
    delay(1000L)
    return "Hello world"
}

@Test
fun dataShouldBeHelloWorld() = runTest {
    val data = fetchData()
    assertEquals("Hello world", data)
}

Secara umum, Anda harus memiliki satu panggilan runTest per pengujian, dan sebaiknya gunakan isi ekspresi.

Menggabungkan kode pengujian di runTest akan berfungsi untuk menguji fungsi penangguhan dasar, dan akan melewati semua penundaan secara otomatis di coroutine, sehingga pengujian di atas dapat diselesaikan lebih cepat dari satu detik.

Namun, ada pertimbangan tambahan yang harus dipikirkan, bergantung pada apa yang terjadi dalam kode Anda yang diuji:

  • Jika kode membuat coroutine baru selain coroutine pengujian level atas yang dibuat runTest, Anda harus mengontrol cara penjadwalan coroutine baru tersebut dengan memilih TestDispatcher yang sesuai.
  • Jika kode Anda memindahkan eksekusi coroutine ke dispatcher lain (misalnya, menggunakan withContext), runTest umumnya akan tetap berfungsi, tetapi penundaan tidak lagi dilewati, dan pengujian akan sedikit sulit diprediksi karena kode berjalan di beberapa thread. Oleh karena itu, dalam pengujian, Anda harus memasukkan dispatcher pengujian untuk menggantikan dispatcher yang sebenarnya.

TestDispatchers

TestDispatchers adalah implementasi CoroutineDispatcher untuk tujuan pengujian. Anda harus menggunakan TestDispatchers jika ada coroutine baru yang dibuat selama pengujian untuk memastikan eksekusi coroutine baru tersebut dapat diprediksi.

Ada dua implementasi TestDispatcher yang tersedia: StandardTestDispatcher dan UnconfinedTestDispatcher, yang melakukan penjadwalan berbeda untuk coroutine yang baru dimulai. Keduanya menggunakan TestCoroutineScheduler untuk mengontrol waktu virtual dan mengelola coroutine yang berjalan dalam pengujian.

Hanya boleh ada satu instance penjadwal yang digunakan dalam pengujian, yang digunakan bersama oleh semua TestDispatchers. Lihat Memasukkan TestDispatchers untuk mempelajari cara berbagi penjadwal.

Untuk memulai coroutine pengujian level teratas, runTest membuat TestScope, yang merupakan implementasi dari CoroutineScope yang akan selalu menggunakan TestDispatcher. Jika tidak ditentukan, TestScope akan membuat StandardTestDispatcher secara default, dan menggunakannya untuk menjalankan coroutine pengujian level teratas.

runTest melacak coroutine yang ada dalam antrean penjadwal yang digunakan oleh dispatcher TestScope, dan tidak akan ditampilkan selama ada tugas yang tertunda pada penjadwal tersebut.

StandardTestDispatcher

Saat Anda memulai coroutine baru pada StandardTestDispatcher, coroutine tersebut akan dimasukkan ke dalam antrean di penjadwal dasarnya, agar dijalankan setiap kali thread pengujian sedang kosong dan dapat digunakan. Agar coroutine baru ini dapat berjalan, Anda harus membuat thread pengujian (mengosongkannya untuk digunakan oleh coroutine lain). Perilaku pengantrean ini membuat Anda dapat secara presisi mengontrol cara coroutine baru dijalankan selama pengujian. Selain itu, ini mirip dengan penjadwalan coroutine dalam kode produksi.

Jika thread pengujian tidak pernah dihasilkan selama eksekusi coroutine pengujian tingkat teratas, setiap coroutine baru hanya akan dijalankan setelah coroutine pengujian selesai (tetapi sebelum runTest ditampilkan):

@Test
fun standardTest() = runTest {
    val userRepo = UserRepository()

    launch { userRepo.register("Alice") }
    launch { userRepo.register("Bob") }

    assertEquals(listOf("Alice", "Bob"), userRepo.getAllUsers()) // ❌ Fails
}

Ada beberapa cara untuk membuat coroutine pengujian agar coroutine yang diantrekan berjalan. Semua panggilan ini memungkinkan coroutine lain berjalan pada thread pengujian sebelum ditampilkan:

  • advanceUntilIdle: Menjalankan semua coroutine lain di penjadwal hingga tidak ada yang tersisa dalam antrean. Ini adalah pilihan default yang baik untuk memungkinkan semua coroutine yang tertunda berjalan, dan akan berfungsi di sebagian besar skenario pengujian.
  • advanceTimeBy: Memajukan waktu virtual sebanyak jumlah tertentu dan membuat setiap coroutine yang dijadwalkan berjalan sebelum waktu virtual tersebut.
  • runCurrent: Menjalankan coroutine yang dijadwalkan pada waktu virtual saat ini.

Untuk memperbaiki pengujian sebelumnya, advanceUntilIdle dapat digunakan untuk memungkinkan kedua coroutine yang tertunda melakukan tugasnya sebelum melanjutkan ke pernyataan:

@Test
fun standardTest() = runTest {
    val userRepo = UserRepository()

    launch { userRepo.register("Alice") }
    launch { userRepo.register("Bob") }
    advanceUntilIdle() // Yields to perform the registrations

    assertEquals(listOf("Alice", "Bob"), userRepo.getAllUsers()) // ✅ Passes
}

UnconfinedTestDispatcher

Saat dimulai di UnconfinedTestDispatcher, coroutine baru akan dimulai dengan segera di thread saat ini. Artinya, coroutine tersebut akan segera mulai berjalan tanpa menunggu builder coroutine ditampilkan. Perilaku dispatch ini sering kali menghasilkan kode pengujian yang lebih sederhana, karena Anda tidak perlu membuat thread pengujian secara manual untuk memungkinkan coroutine baru dijalankan.

Namun, perilaku ini berbeda dari yang akan Anda lihat dalam produksi dengan dispatcher non-pengujian. Jika pengujian Anda berfokus pada konkurensi, lebih baik gunakan StandardTestDispatcher.

Untuk menggunakan dispatcher ini bagi coroutine pengujian level teratas di runTest, bukan dispatcher default, buat instance dan teruskan sebagai parameter. Dengan tindakan ini, coroutine baru yang dibuat di dalam runTest akan dijalankan dengan segera karena coroutine ini mewarisi dispatcher dari TestScope.

@Test
fun unconfinedTest() = runTest(UnconfinedTestDispatcher()) {
    val userRepo = UserRepository()

    launch { userRepo.register("Alice") }
    launch { userRepo.register("Bob") }

    assertEquals(listOf("Alice", "Bob"), userRepo.getAllUsers()) // ✅ Passes
}

Dalam contoh ini, panggilan peluncuran akan memulai coroutine baru dengan segera pada UnconfinedTestDispatcher sehingga setiap panggilan peluncuran hanya akan ditampilkan setelah pendaftaran selesai.

Perlu diingat bahwa UnconfinedTestDispatcher memulai coroutine baru dengan segera, tetapi bukan berarti coroutine tersebut juga akan menjalankannya dengan segera. Jika coroutine baru ditangguhkan, coroutine lain akan melanjutkan eksekusi.

Misalnya, coroutine baru yang diluncurkan dalam pengujian ini akan mendaftarkan Alice, tetapi kemudian ditangguhkan saat delay dipanggil. Tindakan ini memungkinkan coroutine tingkat teratas melanjutkan eksekusi pernyataan, dan pengujian gagal karena Bob belum terdaftar:

@Test
fun yieldingTest() = runTest(UnconfinedTestDispatcher()) {
    val userRepo = UserRepository()

    launch {
        userRepo.register("Alice")
        delay(10L)
        userRepo.register("Bob")
    }

    assertEquals(listOf("Alice", "Bob"), userRepo.getAllUsers()) // ❌ Fails
}

Memasukkan dispatcher pengujian

Kode yang sedang diuji mungkin menggunakan dispatcher untuk beralih thread (menggunakan withContext) atau untuk memulai coroutine baru. Jika kode dieksekusi pada beberapa thread secara paralel, pengujian dapat menjadi tidak stabil. Hal ini akan mempersulit eksekusi pernyataan pada waktu yang tepat atau menunggu tugas selesai jika dijalankan di thread latar belakang yang tidak Anda kontrol.

Dalam pengujian, ganti dispatcher ini dengan instance TestDispatchers. Hal ini memiliki beberapa manfaat:

  • Kode akan berjalan di satu thread pengujian, sehingga pengujian menjadi lebih deterministik
  • Anda dapat mengontrol cara penjadwalan dan eksekusi coroutine baru
  • TestDispatchers menggunakan penjadwal untuk waktu virtual, yang melewati penundaan secara otomatis dan memungkinkan Anda memajukan waktu secara manual

Menggunakan injeksi dependensi untuk memberikan dispatcher ke class akan memudahkan penggantian dispatcher yang sebenarnya dalam pengujian. Dalam contoh ini, kita akan memasukkan CoroutineDispatcher, tetapi Anda juga dapat memasukkan jenis CoroutineContext yang lebih luas, yang memungkinkan lebih banyak fleksibilitas selama pengujian.

Untuk class yang memulai coroutine, Anda juga dapat memasukkan CoroutineScope, bukan dispatcher, seperti yang dijelaskan di bagian Memasukkan cakupan.

TestDispatchers akan membuat penjadwal baru secara default saat instance-nya dibuat. Di dalam runTest, Anda dapat mengakses properti testScheduler dari TestScope dan meneruskannya ke TestDispatchers yang baru dibuat. Tindakan ini akan membagikan pemahaman tentang waktu virtual, dan metode seperti advanceUntilIdle akan menjalankan coroutine pada semua dispatcher pengujian hingga selesai.

Pada contoh berikut, Anda dapat melihat class Repository yang membuat coroutine baru menggunakan dispatcher IO dalam metode initialize dan mengalihkan pemanggil ke dispatcher IO di metode fetchData:

// Example class demonstrating dispatcher use cases
class Repository(private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO) {
    private val scope = CoroutineScope(ioDispatcher)
    val initialized = AtomicBoolean(false)

    // A function that starts a new coroutine on the IO dispatcher
    fun initialize() {
        scope.launch {
            initialized.set(true)
        }
    }

    // A suspending function that switches to the IO dispatcher
    suspend fun fetchData(): String = withContext(ioDispatcher) {
        require(initialized.get()) { "Repository should be initialized first" }
        delay(500L)
        "Hello world"
    }
}

Dalam pengujian, Anda dapat memasukkan implementasi TestDispatcher untuk menggantikan dispatcher IO.

Pada contoh di bawah, kita memasukkan StandardTestDispatcher ke dalam repositori, dan menggunakan advanceUntilIdle untuk memastikan bahwa coroutine baru yang dimulai di initialize sudah selesai sebelum melanjutkan.

fetchData juga akan mendapatkan manfaat jika dijalankan di TestDispatcher, karena elemen ini akan berjalan di thread pengujian dan melewati penundaan yang ada selama pengujian.

class RepositoryTest {
    @Test
    fun repoInitWorksAndDataIsHelloWorld() = runTest {
        val dispatcher = StandardTestDispatcher(testScheduler)
        val repository = Repository(dispatcher)

        repository.initialize()
        advanceUntilIdle() // Runs the new coroutine
        assertEquals(true, repository.initialized.get())

        val data = repository.fetchData() // No thread switch, delay is skipped
        assertEquals("Hello world", data)
    }
}

Coroutine baru yang dimulai di TestDispatcher dapat dilanjutkan secara manual seperti yang ditunjukkan di atas dengan initialize. Namun, perhatikan bahwa hal ini tidak dapat dilakukan atau tidak diinginkan dalam kode produksi. Sebagai gantinya, metode ini harus didesain ulang agar menangguhkan (untuk eksekusi berurutan), atau menampilkan nilai Deferred (untuk eksekusi serentak).

Misalnya, Anda dapat menggunakan async untuk memulai coroutine baru dan membuat Deferred:

class BetterRepository(private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO) {
    private val scope = CoroutineScope(ioDispatcher)

    fun initialize() = scope.async {
        // ...
    }
}

Dengan begitu, Anda dapat await (menunggu) penyelesaian kode ini dengan aman baik di pengujian maupun kode produksi:

@Test
fun repoInitWorks() = runTest {
    val dispatcher = StandardTestDispatcher(testScheduler)
    val repository = BetterRepository(dispatcher)

    repository.initialize().await() // Suspends until the new coroutine is done
    assertEquals(true, repository.initialized.get())
    // ...
}

runTest akan menunggu penyelesaian coroutine yang tertunda sebelum ditampilkan jika coroutine berada di TestDispatcher yang menggunakan penjadwal yang sama. Elemen tersebut juga akan menunggu coroutine yang merupakan turunan dari coroutine pengujian level teratas, meskipun ada di dispatcher lainnya (hingga waktu tunggu yang ditentukan oleh parameter dispatchTimeoutMs, yaitu 60 detik secara default).

Menetapkan dispatcher Main

Dalam pengujian unit lokal, dispatcher Main yang menggabungkan UI thread Android tidak tersedia, karena pengujian ini dijalankan di JVM lokal, bukan di perangkat Android. Jika kode Anda yang sedang diuji merujuk pada thread utama, pengecualian akan ditampilkan selama pengujian unit.

Di beberapa kasus, Anda dapat memasukkan dispatcher Main dengan cara yang sama seperti dispatcher lainnya, sebagaimana dijelaskan di bagian sebelumnya, yang memungkinkan Anda menggantinya dengan TestDispatcher dalam pengujian. Namun, beberapa API seperti viewModelScope menggunakan dispatcher Main yang di-hardcode di balik layar.

Berikut adalah contoh implementasi ViewModel yang menggunakan viewModelScope untuk meluncurkan coroutine yang memuat data:

class HomeViewModel : ViewModel() {
    private val _message = MutableStateFlow("")
    val message: StateFlow<String> get() = _message

    fun loadMessage() {
        viewModelScope.launch {
            _message.value = "Greetings!"
        }
    }
}

Untuk mengganti dispatcher Main dengan TestDispatcher di semua kasus, gunakan fungsi Dispatchers.setMain dan Dispatchers.resetMain.

class HomeViewModelTest {
    @Test
    fun settingMainDispatcher() = runTest {
        val testDispatcher = UnconfinedTestDispatcher(testScheduler)
        Dispatchers.setMain(testDispatcher)

        try {
            val viewModel = HomeViewModel()
            viewModel.loadMessage() // Uses testDispatcher, runs its coroutine eagerly
            assertEquals("Greetings!", viewModel.message.value)
        } finally {
            Dispatchers.resetMain()
        }
    }
}

Jika dispatcher Main telah diganti dengan TestDispatcher, setiap TestDispatchers yang baru dibuat akan otomatis menggunakan penjadwal dari dispatcher Main, termasuk StandardTestDispatcher yang dibuat oleh runTest jika tidak ada dispatcher lain yang diteruskan.

Dengan begitu, akan jadi lebih mudah untuk memastikan bahwa hanya ada satu penjadwal yang digunakan selama pengujian. Agar ini berfungsi, pastikan untuk membuat semua instance TestDispatcher lainnya setelah memanggil Dispatchers.setMain.

Pola umum untuk menghindari duplikasi kode yang menggantikan dispatcher Main dalam setiap pengujian adalah dengan mengekstraknya ke dalam aturan pengujian JUnit:

// Reusable JUnit4 TestRule to override the Main dispatcher
class MainDispatcherRule(
    val testDispatcher: TestDispatcher = UnconfinedTestDispatcher(),
) : TestWatcher() {
    override fun starting(description: Description) {
        Dispatchers.setMain(testDispatcher)
    }

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

class HomeViewModelTestUsingRule {
    @get:Rule
    val mainDispatcherRule = MainDispatcherRule()

    @Test
    fun settingMainDispatcher() = runTest { // Uses Main’s scheduler
        val viewModel = HomeViewModel()
        viewModel.loadMessage()
        assertEquals("Greetings!", viewModel.message.value)
    }
}

Implementasi aturan ini menggunakan UnconfinedTestDispatcher secara default, tetapi StandardTestDispatcher dapat diteruskan sebagai parameter jika dispatcher Main tidak boleh dieksekusi dengan segera di class pengujian tertentu.

Jika memerlukan instance TestDispatcher dalam isi pengujian, Anda dapat menggunakan kembali testDispatcher dari aturan tersebut, selama itu adalah jenis yang diinginkan. Jika Anda ingin menjelaskan jenis TestDispatcher yang digunakan dalam pengujian, atau jika memerlukan TestDispatcher yang berbeda jenis dari yang digunakan untuk Main, Anda dapat membuat TestDispatcher baru dalam runTest. Saat dispatcher Main ditetapkan ke TestDispatcher, setiap TestDispatchers yang baru dibuat akan berbagi penjadwalnya secara otomatis.

class DispatcherTypesTest {
    @get:Rule
    val mainDispatcherRule = MainDispatcherRule()

    @Test
    fun injectingTestDispatchers() = runTest { // Uses Main’s scheduler
        // Use the UnconfinedTestDispatcher from the Main dispatcher
        val unconfinedRepo = Repository(mainDispatcherRule.testDispatcher)

        // Create a new StandardTestDispatcher (uses Main’s scheduler)
        val standardRepo = Repository(StandardTestDispatcher())
    }
}

Membuat dispatcher di luar pengujian

Dalam beberapa kasus, Anda mungkin memerlukan TestDispatcher tersedia di luar metode pengujian. Misalnya, selama inisialisasi properti di class pengujian:

class Repository(private val ioDispatcher: CoroutineDispatcher) { /* ... */ }

class RepositoryTestWithRule {
    private val repository = Repository(/* What TestDispatcher? */)

    @get:Rule
    val mainDispatcherRule = MainDispatcherRule()

    @Test
    fun someRepositoryTest() = runTest {
        // Test the repository...
        // ...
    }
}

Jika Anda mengganti dispatcher Main seperti yang ditunjukkan di bagian sebelumnya, TestDispatchers yang dibuat setelah dispatcher Main diganti akan otomatis mengambil penjadwalnya.

Namun, hal ini tidak berlaku untuk TestDispatchers yang dibuat sebagai properti class pengujian atau TestDispatchers yang dibuat selama inisialisasi properti di class pengujian. Metode ini diinisialisasi sebelum dispatcher Main diganti. Oleh karena itu, metode tersebut akan membuat penjadwal baru.

Untuk memastikan bahwa hanya ada satu penjadwal dalam pengujian Anda, buat properti MainDispatcherRule terlebih dahulu. Kemudian, gunakan kembali dispatcher (atau penjadwalnya, jika Anda memerlukan TestDispatcher dari jenis yang berbeda) di penginisialisasi properti level class lainnya sesuai kebutuhan.

class RepositoryTestWithRule {
    @get:Rule
    val mainDispatcherRule = MainDispatcherRule()

    private val repository = Repository(mainDispatcherRule.testDispatcher)

    @Test
    fun someRepositoryTest() = runTest { // Takes scheduler from Main
        // Any TestDispatcher created here also takes the scheduler from Main
        val newTestDispatcher = StandardTestDispatcher()

        // Test the repository...
    }
}

Perlu diingat bahwa runTest dan TestDispatchers yang dibuat dalam pengujian masih akan otomatis mengambil penjadwal dispatcher Main.

Jika Anda tidak mengganti dispatcher Main, buat TestDispatcher pertama (yang membuat penjadwal baru) sebagai properti class. Kemudian, teruskan penjadwal tersebut secara manual ke setiap panggilan runTest dan setiap TestDispatcher baru yang dibuat, baik sebagai properti maupun dalam pengujian:

class RepositoryTest {
    // Creates the single test scheduler
    private val testDispatcher = UnconfinedTestDispatcher()
    private val repository = Repository(testDispatcher)

    @Test
    fun someRepositoryTest() = runTest(testDispatcher.scheduler) {
        // Take the scheduler from the TestScope
        val newTestDispatcher = UnconfinedTestDispatcher(this.testScheduler)
        // Or take the scheduler from the first dispatcher, they’re the same
        val anotherTestDispatcher = UnconfinedTestDispatcher(testDispatcher.scheduler)

        // Test the repository...
    }
}

Dalam contoh ini, penjadwal dari dispatcher pertama diteruskan ke runTest. Tindakan ini akan membuat StandardTestDispatcher baru untuk TestScope yang menggunakan penjadwal tersebut. Anda juga dapat meneruskan dispatcher ke runTest secara langsung untuk menjalankan coroutine pengujian pada dispatcher tersebut.

Membuat TestScope Anda sendiri

Seperti halnya TestDispatchers, Anda mungkin perlu mengakses TestScope di luar isi pengujian. Meskipun runTest membuat TestScope di balik layar secara otomatis, Anda juga dapat membuat TestScope sendiri untuk digunakan dengan runTest.

Saat melakukannya, pastikan untuk memanggil runTest di TestScope yang Anda buat:

class SimpleExampleTest {
    val testScope = TestScope() // Creates a StandardTestDispatcher

    @Test
    fun someTest() = testScope.runTest {
        // ...
    }
}

Kode di atas membuat StandardTestDispatcher untuk TestScope secara implisit, serta penjadwal baru. Semua objek ini juga dapat dibuat secara eksplisit. Hal ini dapat berguna jika Anda perlu mengintegrasikannya dengan konfigurasi injeksi dependensi.

class ExampleTest {
    val testScheduler = TestCoroutineScheduler()
    val testDispatcher = StandardTestDispatcher(testScheduler)
    val testScope = TestScope(testDispatcher)

    @Test
    fun someTest() = testScope.runTest {
        // ...
    }
}

Memasukkan cakupan

Jika Anda memiliki class yang membuat coroutine yang perlu dikontrol selama pengujian, Anda dapat memasukkan cakupan coroutine ke class tersebut, menggantinya dengan TestScope dalam pengujian.

Pada contoh berikut, class UserState bergantung pada UserRepository untuk mendaftarkan pengguna baru dan mengambil data pengguna yang sudah terdaftar. Karena panggilan ke UserRepository ini menangguhkan panggilan fungsi, UserState akan menggunakan CoroutineScope yang dimasukkan untuk memulai coroutine baru dalam fungsi registerUser.

class UserState(
    private val userRepository: UserRepository,
    private val scope: CoroutineScope,
) {
    private val _users = MutableStateFlow(emptyList<String>())
    val users: StateFlow<List<String>> = _users.asStateFlow()

    fun registerUser(name: String) {
        scope.launch {
            userRepository.register(name)
            _users.update { userRepository.getAllUsers() }
        }
    }
}

Untuk menguji class ini, Anda dapat meneruskan TestScope dari runTest saat membuat objek UserState:

class UserStateTest {
    @Test
    fun addUserTest() = runTest { // this: TestScope
        val repository = FakeUserRepository()
        val userState = UserState(repository, scope = this)

        userState.registerUser("Mona")
        advanceUntilIdle() // Let the coroutine complete and changes propagate

        assertEquals(listOf("Mona"), userState.users.value)
    }
}

Untuk memasukkan cakupan di luar fungsi pengujian, misalnya ke dalam objek yang sedang diuji, yang dibuat sebagai properti dalam class pengujian, lihat Membuat TestScope Anda sendiri.

Referensi lainnya