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 memilihTestDispatcher
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 bisa
memasukkan atribut
CoroutineContext
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.