Pengujian Unit LiveData & Coroutine dan Flow
Kita telah melihat menggunakan LiveData & Flow dan hanya Flow dalam arsitektur MVVM di Bagian I dan Bagian II . Dalam posting ini, kita akan melihat cara menguji LiveData & Coroutine dan Flow. Kami akan memecahkan masalah kondisi balapan umum antara LiveData dan Coroutines dengan TestCoroutineDispatcher . Setelah itu, kita akan melihat betapa mudahnya menguji Flow
Anda dapat mengakses proyek sampel dari https://github.com/fgiris/LiveDataWithFlowSample/tree/fgiris/android_turkiye_work_around
Pengujian Unit LiveData & Coroutine
Anda mungkin pernah melihat build cabang Anda secara lokal, tetapi setiap kali Anda mendorongnya ke jarak jauh, build Anda akan mendapatkan . Saat Anda memeriksa log, Anda melihat bahwa salah satu pengujian yang telah Anda tambahkan gagal karena LiveData tidak memberi Anda nilai yang benar. Tapi kenapa? Apakah karena LiveData atau Coroutine?
Salah satu masalah umum, saat Anda menguji Coroutine dengan LiveData, adalah kondisi balapan. Anda harus sangat berhati-hati dengan Dispatcher, Looper... Jika Anda tidak memiliki aturan pengujian yang tepat untuk mereka, pikiran Anda bisa meledak dan Anda dapat mengatakan "Ayo berkomentar, dan kita bisa melihatnya nanti.
Untuk mencegah hal ini terjadi, mari kita mulai dengan fungsi yang sangat sederhana di repositori.
/**
* This methods is used to make one shot request to get
* fake weather forecast data
*/
suspend fun fetchWeatherForecastSuspendCase(): Result<Int> {
// Fake api call
delay(1000)
// Send a random fake weather forecast data
return Result.Success((0..20).random())
}
Kami akan menguji apakah fungsi ini mengembalikan nilai sukses.
@Test
fun fetchWeatherForecastSuspendCase_ShouldReturnSuccess() {
runBlocking {
// Api call
val weatherForecast = weatherForecastRepository.fetchWeatherForecastSuspendCase()
// Check whether the result is successful
assert(weatherForecast is Result.Success)
}
}
Kita perlu memanggil fungsi suspend dari lingkup coroutine dan inilah mengapa runBlocking digunakan di sini. Fungsi tes ini berhasil lulus
Bagaimana dengan ViewModel? Mari kita lanjutkan dan lihat bagaimana fetchWeatherForecastSuspendCasefungsi digunakan di ViewModel.
class WeatherForecastOneShotViewModel @Inject constructor(
private val weatherForecastRepository: WeatherForecastRepository
) : ViewModel() {
private var _weatherForecast = MutableLiveData<Result<Int>>()
...
private fun fetchWeatherForecast() {
// Set value as loading
_weatherForecast.value = Result.Loading
// Uses Dispatchers.Main
// We will fix it later in the article
viewModelScope.launch {
// Fetch and update weather forecast LiveData
_weatherForecast.value = weatherForecastRepository.fetchWeatherForecastSuspendCase()
}
}
}
Yang penting di sini kita memulai coroutine baru pada fungsi yang akan kita uji. Kami akan menguji apakah fetchWeatherForecastfungsi pertama kembali Loadingdan kemudian Success.
@Test
fun weatherForecastLiveData_ShouldPostLoadingThenSuccess() {
runBlocking {
// Check whether the first value is loading
assert(viewModel.weatherForecast.value == Result.Loading)
// Wait for the response
delay(1000)
// Check whether the response is successful
assert(viewModel.weatherForecast.value is Result.Success)
}
}
runBlockingdigunakan karena kami memiliki fungsi penundaan tunda untuk menunggu respons yang sebenarnya. Saya tahu menggunakan penundaan bukanlah solusi yang baik, tetapi ini adalah contoh yang sangat mendasar dan kami akan memperbaikinya nanti, jangan khawatir 😊
Saat Anda menjalankan tes ini, Anda akan mendapatkan RunTimeException untuk mendapatkan Looper utama.

Alasan untuk ini dari dokumen resmi;
File android.jar yang digunakan untuk menjalankan pengujian unit tidak berisi kode aktual apa pun — yang disediakan oleh citra sistem Android pada perangkat nyata. Sebagai gantinya, semua metode melempar pengecualian (secara default). Ini untuk memastikan pengujian unit Anda hanya menguji kode Anda dan tidak bergantung pada perilaku tertentu dari platform Android (yang belum Anda ejek secara eksplisit, misalnya menggunakan Mockito).
Dikatakan bahwa tidak ada akses ke citra sistem Android (yang mencakup Looper utama untuk aplikasi Anda) dalam pengujian unit. Untuk memperbaikinya, kami memiliki 2 opsi.
- Mengaktifkan nilai default untuk pengujian unit dalam file gradle. ( Tidak disarankan )
- Menambahkan InstantTaskExecutorRule yang menggantikan isMainThreadmetode yang akan dipanggil untuk mendapatkan looper utama. Itu juga mengubah pelaksana latar belakang untuk Komponen Arsitektur, untuk menjalankannya secara sinkron dalam pengujian.
@get:Rule
var instantTaskExecutorRule = InstantTaskExecutorRule()
@Test
fun weatherForecastLiveData_ShouldPostLoadingThenSuccess() {
runBlocking {
// Check whether the first value is loading
assert(viewModel.weatherForecast.value == Result.Loading)
// Wait for the response
delay(1000)
// Check whether the response is successful
assert(viewModel.weatherForecast.value is Result.Success)
}
}
Mari jalankan pengujian lagi setelah menambahkan aturan InstantTaskExecutorRule .
Itu tidak membiarkannya berlalu lagi 🚯 🚳 …

Sekarang mengeluh karena operator utama tidak diinisialisasi. Alasan untuk ini adalah Dispatchers.Main membutuhkan Looper utama yang tidak tersedia di unit test. Jadi solusinya adalah mengganti Dispatchers.Main dengan TestCoroutineDispatcher .
Mari kita tulis aturan umum untuk menggunakan TestCoroutineDispatcher.
@ExperimentalCoroutinesApi
class MainCoroutineRule(
val testDispatcher: TestCoroutineDispatcher = TestCoroutineDispatcher()
) : TestWatcher() {
override fun starting(description: Description?) {
super.starting(description)
Dispatchers.setMain(testDispatcher)
}
override fun finished(description: Description?) {
super.finished(description)
Dispatchers.resetMain()
testDispatcher.cleanupTestCoroutines()
}
}
Anda dapat menemukan seluruh penerapan aturan ini dari sini. Kami hanya mengatur operator utama ke TestCoroutineDispatcher setiap kali pengujian dimulai. Mari tambahkan aturan ini dalam pengujian kita juga dan jalankan lagi.
@get:Rule
var mainCoroutineRule = MainCoroutineRule()
@get:Rule
var instantTaskExecutorRule = InstantTaskExecutorRule()
@Test
fun weatherForecastLiveData_ShouldPostLoadingThenSuccess() {
mainCoroutineRule.testDispatcher.runBlockingTest {
// Check whether the first value is loading
assert(viewModel.weatherForecast.value == Result.Loading)
// Wait for the response
delay(1000)
// Check whether the response is successful
assert(viewModel.weatherForecast.value is Result.Success)
}
}
Voilà Tes kami lulus setelah semua proses ini.
TestCoroutineDispatcher memiliki runBlockingTest yang akan segera melanjutkan penundaan dengan jam virtual. Ini dapat digunakan untuk tes yang lebih cepat daripada runBlocking .
Tapi tunggu, kami menggunakan Dispatcher.Main, yang terutama cocok untuk operasi UI, untuk membuat permintaan jaringan di ViewModel. Mari kita kembali dan menggantinya dengan Dispatchers.IO yang pada dasarnya merupakan pilihan yang lebih baik untuk permintaan jaringan.
class WeatherForecastOneShotViewModel @Inject constructor(
private val weatherForecastRepository: WeatherForecastRepository
) : ViewModel() {
private var _weatherForecast = MutableLiveData<Result<Int>>()
...
private fun fetchWeatherForecast() {
// Set value as loading
_weatherForecast.value = Result.Loading
// Changed dispatcher to Dispatchers.IO
viewModelScope.launch(Dispatchers.IO) {
// Fetch and update weather forecast LiveData
_weatherForecast.value = weatherForecastRepository.fetchWeatherForecastSuspendCase()
}
}
}
Setelah mengganti operator dengan Dispatchers.IO , tes gagal lagi . Alasannya adalah kami hanya menimpa operator utama. Namun, saat Anda memulai coroutine baru dengan operator lain, coroutine tersebut akan dieksekusi di utas yang berbeda.

Bisakah kita juga menimpa operator lain dengan fungsi seperti Dispatchers.setMain? Sayangnya tidak. Inilah sebabnya mengapa kita perlu menyuntikkan dispatcher.
Selalu menyuntikkan dispatcher untuk testability yang lebih baik.
class WeatherForecastOneShotViewModel @Inject constructor(
private val weatherForecastRepository: WeatherForecastRepository,
private val dispatcher: CoroutineDispatcher
) : ViewModel() {
private var _weatherForecast = MutableLiveData<Result<Int>>()
...
private fun fetchWeatherForecast() {
// Set value as loading
_weatherForecast.value = Result.Loading
// Use the injected dispatcher
viewModelScope.launch(dispatcher) {
// Fetch and update weather forecast LiveData
_weatherForecast.value = weatherForecastRepository.fetchWeatherForecastSuspendCase()
}
}
}
Kita perlu memberikan operator pengujian yang sama ke ViewModel saat dibuat dalam pengujian.
@Before
fun setup() {
...
viewModel = WeatherForecastOneShotViewModel(
weatherForecastRepository,
// Use the same test dispatcher
// which is used to run the tests
mainCoroutineRule.testDispatcher
)
}
Setelah itu pengujian kami lulus meskipun kami menggunakan operator yang berbeda.
Saatnya untuk menghapus penundaan untuk menunggu panggilan api. Karena kami menggunakan runBlockingTest dan semua fungsi penundaan akan segera diproses, Anda dapat memilih untuk menulis penundaan 100 detik seperti delay(100000), yang diharapkan lebih besar dari setiap waktu respons panggilan api Anda dan menguji panggilan api setelah penundaan itu. Tapi itu adalah solusi yang sangat buruk
Untungnya, Anda dapat melanjutkan dan menjeda petugas operator agar tidak memulai coroutine baru saat melakukan beberapa pengujian. Ini memungkinkan kita mengontrol coroutine baru yang dimulai dalam fungsi pengujian.
@Test
fun weatherForecastLiveData_ShouldPostLoadingThenSuccessWithoutDelay() {
mainCoroutineRule.testDispatcher.runBlockingTest {
// Pause dispatcher so that no new coroutines will be started
mainCoroutineRule.testDispatcher.pauseDispatcher()
// Initialization of view model includes api call with creating
// new coroutine but it will not be invoked since we paused dispatcher
viewModel = WeatherForecastOneShotViewModel(
weatherForecastRepository,
mainCoroutineRule.testDispatcher
)
// Check whether the first value is loading
assert(viewModel.weatherForecast.value == Result.Loading)
// Resume dispatcher
// This will resume from the part which starts a new coroutine
// to make the api call in view model
mainCoroutineRule.testDispatcher.resumeDispatcher()
// Check whether the response is successful
assert(viewModel.weatherForecast.value is Result.Success)
}
}
Kami baru saja mengontrol eksekusi coroutine dan pengujian kami lulus .
Aliran Pengujian
Menguji Alur jauh lebih mudah daripada menguji LiveData & Coroutine. Karena Flow menyediakan semua data dalam konteks coroutine yang sama di mana Flow dikumpulkan, tidak ada kondisi balapan di sisi kolektor. Jadi, ini memungkinkan kami menguji data secara berurutan tanpa menjeda atau melanjutkan apa pun.
Mari kita gunakan fungsi sederhana ini yang mengembalikan aliran data dalam repositori.
/**
* This methods is used to make one shot request to get
* fake weather forecast data
*/
fun fetchWeatherForecast() = flow {
emit(Result.Loading)
// Fake api call
delay(1000)
// Send a random fake weather forecast data
emit(Result.Success((0..20).random()))
}
Kami akan menguji apakah fungsi ini memuat terlebih dahulu dan kemudian memberikan respons yang berhasil. Kita dapat menggunakan operator terminal pengumpulan untuk memulai pengumpulan data dalam pengujian.
@Test
fun fetchWeatherForecast_ShouldReturnLoadingThenResult_collect() {
mainCoroutineRule.testDispatcher.runBlockingTest {
// Make api call
val weatherForecastFlow = weatherForecastRepository.fetchWeatherForecast()
weatherForecastFlow.collect {
// Check whether first data is loading
// Check whether second data is Success
}
}
}
Karena kita tidak memiliki indeks data, mari kita gunakan operator collectIndexed.
@Test
fun fetchWeatherForecast_ShouldReturnLoadingThenResult_collectIndexed() {
mainCoroutineRule.testDispatcher.runBlockingTest {
// Make api call
val weatherForecastFlow = weatherForecastRepository.fetchWeatherForecast()
weatherForecastFlow.collectIndexed { index, value ->
// Check whether first data is loading
if (index == 0) assert(value == Result.Loading)
// Check whether second data is Success
if (index == 1) assert(value is Result.Success)
}
}
}
Atau, Anda dapat mengonversi Flow ke daftar dengan operator toList dan menguji daftar.
@Test
fun fetchWeatherForecast_ShouldReturnLoadingThenResult_toList() {
mainCoroutineRule.testDispatcher.runBlockingTest {
// List to keep weather forecast values
val weatherForecastList = mutableListOf<Result<Int>>()
// Make api call
val weatherForecastFlow = weatherForecastRepository.fetchWeatherForecast()
// Convert flow to a list
weatherForecastFlow.toList(weatherForecastList)
// Check whether first data is loading
assert(weatherForecastList.first() == Result.Loading)
// Check whether second (last) data is Success
assert(weatherForecastList.last() is Result.Success)
}
}
Mengontrol dan menguji aliran data menjadi sangat mudah dengan operator Flow.
Catatan: Anda dapat mengonversi LiveData ke Flow dengan menggunakan ekstensi asFlow dan menguji Flow, bukan LiveData. Alur yang dibuat hanya akan berisi nilai terbaru dan kemudian mengamati pembaruan dari LiveData. Karena pengambilan respons & pembaruan LiveData terjadi di inisialisasi ViewModel kami, itu tidak cocok untuk kasus pengujian kami.
Jika Anda menguji LiveData & Coroutines, pastikan untuk menginjeksi semua dispatcher dan gunakan TestCoroutineDispatcher . Juga, jangan lupa untuk menggunakan runBlockingTest untuk melewati penundaan dalam pengujian Anda. Jika Anda memulai coroutine baru di dalam fungsi pengujian, gunakan fungsi jeda dan lanjutkan di operator pengujian untuk menguji nilai LiveData dengan benar. Selain itu, Anda dapat memilih menggunakan ekstensi asFlow dan menguji Flow daripada LiveData.
Sumber
https://proandroiddev.com/using-livedata-flow-in-mvvm-part-iii-8703d305ca73