Menguji flow Kotlin di Android

Cara Anda menguji unit atau modul yang berkomunikasi dengan flow bergantung pada apakah subjek yang sedang diuji tersebut menggunakan flow sebagai input atau output.

  • Jika subjek yang sedang diuji mengamati flow, Anda dapat membuat flow dalam dependensi palsu yang dapat Anda kontrol dari pengujian.
  • Jika unit atau modul menampilkan flow, Anda dapat membaca dan memverifikasi satu atau beberapa item yang dimunculkan oleh flow dalam pengujian.

Membuat produser palsu

Jika subjek yang sedang diuji adalah konsumen flow, satu cara umum untuk mengujinya adalah dengan mengganti produser dengan implementasi palsu. Misalnya, dengan mempertimbangkan class yang mengamati repositori yang mengambil data dari dua sumber data dalam produksi:

subjek yang sedang diuji dan lapisan data
Gambar 1. Subjek yang sedang diuji dan lapisan data.

Untuk membuat pengujian menjadi deterministik, Anda dapat mengganti repositori dan dependensinya dengan repositori palsu yang selalu memunculkan data palsu yang sama:

dependensi diganti dengan implementasi palsu
Gambar 2. Dependensi diganti dengan implementasi palsu.

Untuk memunculkan rangkaian nilai yang telah ditentukan dalam flow, gunakan builder flow:

class MyFakeRepository : MyRepository {
    fun observeCount() = flow {
        emit(ITEM_1)
    }
}

Dalam pengujian, repositori palsu ini dimasukkan untuk menggantikan implementasi yang sebenarnya:

@Test
fun myTest() {
    // Given a class with fake dependencies:
    val sut = MyUnitUnderTest(MyFakeRepository())
    // Trigger and verify
    ...
}

Setelah memiliki kontrol atas output subjek yang sedang diuji, Anda dapat memverifikasi bahwa kontrol tersebut berfungsi sebagaimana mestinya dengan memeriksa outputnya.

Menyatakan kemunculan flow dalam pengujian

Jika subjek yang sedang diuji menampilkan flow, pengujian harus membuat pernyataan tentang elemen aliran data.

Anggaplah repositori contoh sebelumnya menampilkan flow:

repositori dengan dependensi palsu yang menampilkan flow
Gambar 3. Repositori (subjek yang sedang diuji) dengan dependensi palsu yang menampilkan flow.

Dengan pengujian tertentu, Anda hanya perlu memeriksa kemunculan pertama atau jumlah item terbatas yang berasal dari flow.

Anda dapat menggunakan kemunculan pertama ke flow dengan memanggil first(). Fungsi ini akan menunggu hingga item pertama diterima, lalu mengirim sinyal pembatalan ke produser.

@Test
fun myRepositoryTest() = runTest {
    // Given a repository that combines values from two data sources:
    val repository = MyRepository(fakeSource1, fakeSource2)

    // When the repository emits a value
    val firstItem = repository.counter.first() // Returns the first item in the flow

    // Then check it's the expected item
    assertEquals(ITEM_1, firstItem)
}

Jika pengujian perlu memeriksa beberapa nilai, pemanggilan toList() akan menyebabkan flow menunggu sumber untuk memunculkan semua nilainya, lalu menampilkan nilai tersebut sebagai daftar. Ini hanya berfungsi untuk aliran data terbatas.

@Test
fun myRepositoryTest() = runTest {
    // Given a repository with a fake data source that emits ALL_MESSAGES
    val messages = repository.observeChatMessages().toList()

    // When all messages are emitted then they should be ALL_MESSAGES
    assertEquals(ALL_MESSAGES, messages)
}

Untuk aliran data yang memerlukan pengumpulan item yang lebih rumit atau yang tidak menampilkan jumlah item terbatas, Anda dapat menggunakan Flow API untuk memilih dan mengubah item. Berikut beberapa contohnya:

// Take the second item
outputFlow.drop(1).first()

// Take the first 5 items
outputFlow.take(5).toList()

// Takes the first item verifying that the flow is closed after that
outputFlow.single()

// Finite data streams
// Verify that the flow emits exactly N elements (optional predicate)
outputFlow.count()
outputFlow.count(predicate)

Pengumpulan berkelanjutan selama pengujian

Mengumpulkan flow menggunakan toList() seperti yang terlihat dalam contoh sebelumnya menggunakan collect() secara internal, dan menangguhkan sampai seluruh daftar hasil siap untuk ditampilkan.

Untuk menyisipkan tindakan yang menyebabkan flow memunculkan nilai dan pernyataan pada nilai yang ditampilkan, Anda dapat terus mengumpulkan nilai dari flow selama pengujian.

Misalnya, ambil class Repository berikut untuk diuji, dan implementasi sumber data palsu yang disertai metode emit untuk menghasilkan nilai secara dinamis selama pengujian:

class Repository(private val dataSource: DataSource) {
    fun scores(): Flow<Int> {
        return dataSource.counts().map { it * 10 }
    }
}

class FakeDataSource : DataSource {
    private val flow = MutableSharedFlow<Int>()
    suspend fun emit(value: Int) = flow.emit(value)
    override fun counts(): Flow<Int> = flow
}

Saat menggunakan sumber data palsu ini dalam pengujian, Anda dapat membuat coroutine pengumpulan yang akan terus menerima nilai dari Repository. Dalam contoh ini, kita mengumpulkannya ke dalam daftar lalu membuat pernyataan pada kontennya:

@Test
fun continuouslyCollect() = runTest {
    val dataSource = FakeDataSource()
    val repository = Repository(dataSource)

    val values = mutableListOf<Int>()
    backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
        repository.scores().toList(values)
    }

    dataSource.emit(1)
    assertEquals(10, values[0]) // Assert on the list contents

    dataSource.emit(2)
    dataSource.emit(3)
    assertEquals(30, values[2])

    assertEquals(3, values.size) // Assert the number of items collected
}

Karena flow yang ditampilkan oleh Repository di sini tidak pernah selesai, panggilan toList yang mengumpulkannya tidak akan pernah ditampilkan. Memulai coroutine pengumpulan dalam TestScope.backgroundScope akan memastikan bahwa coroutine dibatalkan sebelum akhir pengujian. Jika tidak, runTest akan terus menunggu hingga selesai, yang menyebabkan pengujian berhenti merespons dan pada akhirnya akan gagal.

Perhatikan cara UnconfinedTestDispatcher digunakan untuk coroutine pengumpulan di sini. Ini memastikan coroutine pengumpulan diluncurkan dengan segera dan siap menerima nilai setelah launch ditampilkan.

Menggunakan Turbine

Library Turbine pihak ketiga menawarkan API praktis untuk membuat coroutine pengumpulan, serta fitur praktis lainnya untuk menguji Flow:

@Test
fun usingTurbine() = runTest {
    val dataSource = FakeDataSource()
    val repository = Repository(dataSource)

    repository.scores().test {
        // Make calls that will trigger value changes only within test{}
        dataSource.emit(1)
        assertEquals(10, awaitItem())

        dataSource.emit(2)
        awaitItem() // Ignore items if needed, can also use skip(n)

        dataSource.emit(3)
        assertEquals(30, awaitItem())
    }
}

Lihat dokumentasi library untuk detail selengkapnya.

Menguji StateFlow

StateFlow adalah holder data yang dapat diamati, yang dapat dikumpulkan untuk mengamati nilai yang disimpannya dari waktu ke waktu sebagai aliran. Perlu diperhatikan bahwa aliran nilai ini digabungkan. Artinya, jika nilai disetel dalam StateFlow dengan cepat, kolektor StateFlow tersebut tidak dijamin akan menerima semua nilai antara dan hanya menerima nilai terbaru.

Dalam pengujian, jika ingin mempertahankan penggabungan, Anda dapat mengumpulkan nilai StateFlow seperti yang dapat dikumpulkan dalam flow lain, termasuk dengan Turbine. Dalam beberapa skenario pengujian, Anda dapat mencoba mengumpulkan dan menyatakan semua nilai antara.

Namun, sebaiknya perlakukan StateFlow sebagai holder data dan buat pernyataan di properti value-nya. Dengan begitu, pengujian akan memvalidasi status objek saat ini pada waktu tertentu, dan tidak bergantung pada apakah terjadi penggabungan atau tidak.

Misalnya, perhatikan ViewModel ini yang mengumpulkan nilai dari Repository dan menampilkannya ke UI di StateFlow:

class MyViewModel(private val myRepository: MyRepository) : ViewModel() {
    private val _score = MutableStateFlow(0)
    val score: StateFlow<Int> = _score.asStateFlow()

    fun initialize() {
        viewModelScope.launch {
            myRepository.scores().collect { score ->
                _score.value = score
            }
        }
    }
}

Implementasi palsu untuk Repository ini mungkin terlihat seperti berikut:

class FakeRepository : MyRepository {
    private val flow = MutableSharedFlow<Int>()
    suspend fun emit(value: Int) = flow.emit(value)
    override fun scores(): Flow<Int> = flow
}

Saat menguji ViewModel dengan implementasi palsu ini, Anda dapat menampilkan nilai dari implementasi palsu untuk memicu update di StateFlow ViewModel, lalu membuat pernyataan pada value yang telah diupdate:

@Test
fun testHotFakeRepository() = runTest {
    val fakeRepository = FakeRepository()
    val viewModel = MyViewModel(fakeRepository)

    assertEquals(0, viewModel.score.value) // Assert on the initial value

    // Start collecting values from the Repository
    viewModel.initialize()

    // Then we can send in values one by one, which the ViewModel will collect
    fakeRepository.emit(1)
    assertEquals(1, viewModel.score.value)

    fakeRepository.emit(2)
    fakeRepository.emit(3)
    assertEquals(3, viewModel.score.value) // Assert on the latest value
}

Menggunakan StateFlow yang dibuat oleh stateIn

Di bagian sebelumnya, ViewModel menggunakan MutableStateFlow untuk menyimpan nilai terbaru yang dimunculkan oleh flow dari Repository. Ini adalah pola umum, yang biasanya diterapkan dengan cara yang lebih sederhana menggunakan operator stateIn, yang mengubah cold flow menjadi hot StateFlow:

class MyViewModelWithStateIn(myRepository: MyRepository) : ViewModel() {
    val score: StateFlow<Int> = myRepository.scores()
        .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000L), 0)
}

Operator stateIn memiliki parameter SharingStarted, yang menentukan kapan operator menjadi aktif dan mulai menggunakan flow yang mendasarinya. Opsi seperti SharingStarted.Lazily dan SharingStarted.WhileSubsribed sering digunakan di ViewModel.

Meskipun Anda membuat pernyataan pada value dari StateFlow dalam pengujian, Anda harus membuat kolektor. Kolektor dapat berupa kolektor kosong:

@Test
fun testLazilySharingViewModel() = runTest {
    val fakeRepository = HotFakeRepository()
    val viewModel = MyViewModelWithStateIn(fakeRepository)

    // Create an empty collector for the StateFlow
    backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
        viewModel.score.collect()
    }

    assertEquals(0, viewModel.score.value) // Can assert initial value

    // Trigger-assert like before
    fakeRepository.emit(1)
    assertEquals(1, viewModel.score.value)

    fakeRepository.emit(2)
    fakeRepository.emit(3)
    assertEquals(3, viewModel.score.value)
}

Referensi lainnya