Android'de Kotlin akışlarını test etme

Akış ile iletişim kuran birimleri veya modülleri test etme şekliniz, test edilen kişinin akışı giriş veya çıkış olarak kullanıp kullanmamasına bağlıdır.

  • Test edilen özne bir akış gözlemlerse testlerle kontrol edebileceğiniz sahte bağımlılıklar dahilinde akışlar oluşturabilirsiniz.
  • Birim veya modül bir akış ortaya çıkarsa testteki bir akış tarafından yayınlanan bir veya daha fazla öğeyi okuyup doğrulayabilirsiniz.

Sahte yapımcı oluşturma

Test edilen kişi bir akışın tüketicisi olduğunda bunu test etmenin yaygın yollarından biri, üreticiyi sahte bir uygulamayla değiştirmektir. Örneğin, üretim sırasında iki veri kaynağından veri alan bir depoyu gözlemleyen bir sınıfa bakalım:

ve veri katmanını test etmek için
Şekil 1. Test edilen özne ve veri katmanı.

Testi belirleyici hale getirmek için depoyu ve bağımlılıklarını, her zaman aynı sahte verileri yayınlayan sahte bir kod deposuyla değiştirebilirsiniz:

bağımlılıkların sahte bir uygulamayla değiştirilmesi
Şekil 2. Bağımlılıkların yerini sahte bir uygulama alır.

Bir akışta önceden tanımlanmış bir değer dizisi yayınlamak için flow oluşturucuyu kullanın:

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

Testte, gerçek uygulamanın yerini alan bu sahte depo yerleştirilir:

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

Artık test edilen öznenin çıktılarını kontrol ettiğinize göre, çıktılarını kontrol ederek doğru şekilde çalıştığını doğrulayabilirsiniz.

Bir testte akış emisyonlarını iddia etme

Test edilen özne bir akışı açığa çıkarıyorsa testin, veri akışının öğeleri hakkında iddialarda bulunması gerekir.

Önceki örneğin deposunun bir akış ortaya çıkardığını varsayalım:

akış açığa çıkaran sahte bağımlılıklar içeren depo
Şekil 3. Akış açığa çıkaran sahte bağımlılıklara sahip bir depo (test edilen özne).

Bazı testlerde, akıştan gelen ilk emisyonu veya sınırlı sayıda öğeyi kontrol etmeniz gerekir.

first() yöntemini çağırarak akışa giden ilk emisyonu tüketebilirsiniz. Bu işlev, ilk öğe alınana kadar bekler ve ardından üreticiye iptal sinyalini gönderir.

@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)
}

Testin birden çok değeri kontrol etmesi gerekiyorsa toList() yöntemini çağırmak, akışın kaynağın tüm değerlerini yayınlamasını beklemesine neden olur ve ardından bu değerleri liste olarak döndürür. Bu özellik yalnızca sınırlı veri akışlarında çalışır.

@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)
}

Daha karmaşık bir öğe koleksiyonu gerektiren veya sınırlı sayıda öğe döndürmeyen veri akışlarında, öğeleri seçmek ve dönüştürmek için Flow API'yi kullanabilirsiniz. Bazı örnekler:

// 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)

Bir test sırasında sürekli olarak toplama

Önceki örnekte görüldüğü gibi toList() kullanarak bir akışın toplanması, dahili olarak collect() kullanır ve sonuç listesinin tamamı döndürülmeye hazır olana kadar askıya alır.

Akışın yayınlanan değerlerde değer ve onaylar yaymasına neden olan işlemlere araya boşluk eklemek için test sırasında sürekli olarak bir akıştan değerler toplayabilirsiniz.

Örneğin, test edilecek aşağıdaki Repository sınıfını ve test sırasında değerleri dinamik olarak oluşturmak için emit yöntemi içeren sahte bir veri kaynağı uygulamasını ele alalım:

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
}

Bu sahte öğeyi bir testte kullanırken, değerleri sürekli olarak Repository öğesinden alacak bir toplama eş yordamı oluşturabilirsiniz. Bu örnekte, bu öğeleri bir listede topladık ve içeriklerine dair iddialar gerçekleştireceğiz:

@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
}

Burada Repository tarafından açığa çıkarılan akış hiçbir zaman tamamlanmadığından bu akışı toplayan toList çağrısı hiçbir zaman geri dönmez. Ortak programın TestScope.backgroundScope üzerinden toplanması, test bitmeden önce eş yordamın iptal edilmesini sağlar. Aksi takdirde runTest, işlemin tamamlanmasını beklemeye devam eder ve testin yanıt vermeyi bırakmasına ve sonunda başarısız olur.

Buradaki toplama eş yordamı için UnconfinedTestDispatcher ürününün nasıl kullanıldığına dikkat edin. Bu, toplama eş yordasının istekle başlatılmasını ve launch döndürüldükten sonra değerleri almaya hazır olmasını sağlar.

Türbin Kullanımı

Üçüncü taraf Türbin kitaplığı, toplama eş yordamı oluşturmak için kullanışlı bir API'nin yanı sıra Akışları test etmeye yönelik diğer kullanışlı özellikler sunar:

@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())
    }
}

Daha fazla bilgi için kitaplık belgelerine bakın.

StateFlow'ları test etme

StateFlow gözlemlenebilir bir veri sahibidir. Bu veri, zaman içinde akış olarak sahip olduğu değerleri gözlemlemek için toplanabilir. Bu değer akışının birleştirildiğini unutmayın. Bu, değerler bir StateFlow içinde hızlı bir şekilde ayarlanırsa bu StateFlow toplayıcılarının tüm ara değerleri, yalnızca en yeni değeri alacağının garanti edilmediği anlamına gelir.

Testlerde, birleştirmeyi göz önünde bulundurursanız Türbin de dahil olmak üzere diğer akışları toplayabileceğiniz için bir StateFlow değerlerini toplayabilirsiniz. Bazı test senaryolarında, tüm ara değerleri toplamaya ve hak iddia etmeye çalışmak istenebilir.

Ancak genellikle StateFlow öğesini veri sahibi olarak değerlendirmenizi ve bunun yerine value mülkü için hak talebinde bulunmanızı öneririz. Bu şekilde, testler nesnenin belirli bir zamandaki mevcut durumunu doğrular ve birleştirmenin gerçekleşip gerçekleşmemesine bağlı olmaz.

Örneğin, Repository öğesinden değerler toplayan ve bunları StateFlow içinde kullanıcı arayüzüne gösteren şu ViewModel'i ele alalım:

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
            }
        }
    }
}

Bu Repository için sahte bir uygulama aşağıdaki gibi görünebilir:

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

ViewModel'i bu sahte öğe ile test ederken, ViewModel'in StateFlow içindeki güncellemeleri tetiklemek için sahte değerden değerler verebilir ve ardından, güncellenen value öğesine onay verebilirsiniz:

@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
}

StateIn tarafından oluşturulan StateFlows ile çalışma

Önceki bölümde ViewModel, Repository akışının yaydığı en son değeri depolamak için bir MutableStateFlow kullanır. Bu, soğuk akışı sıcak StateFlow değerine dönüştüren stateIn operatörü kullanılarak genellikle daha basit bir şekilde uygulanan yaygın bir kalıptır:

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

stateIn operatörü, ne zaman etkinleşeceğini belirleyen ve temel akışı tüketmeye başlayan bir SharingStarted parametresine sahiptir. SharingStarted.Lazily ve SharingStarted.WhileSubsribed gibi seçenekler, ViewModels'de sıkça kullanılır.

Testinizde StateFlow öğesinin value üzerinde hak iddia ediyor olsanız bile bir toplayıcı oluşturmanız gerekir. Bu boş bir toplayıcı olabilir:

@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)
}

Ek kaynaklar