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

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

  • Test edilen konu bir akış gözlemliyorsa testlerden kontrol edebileceğiniz sahte bağımlılıklar içinde akışlar oluşturabilirsiniz.
  • Birim veya modül bir akış sunuyorsa testte bir akış tarafından yayınlanan bir veya daha fazla öğeyi okuyabilir ve doğrulayabilirsiniz.

Sahte yapımcı oluşturma

Test edilen konu bir akış tüketicisi olduğunda, bunu test etmenin yaygın yollarından biri üreticiyi sahte bir uygulamayla değiştirmektir. Örneğin, üretimde iki veri kaynağından veri alan bir deposu gözlemleyen bir sınıf verildiğinde:

test edilen konu ve veri katmanı
Şekil 1. Test edilen konu ve veri katmanı.

Testi kesin hale getirmek için deposu ve bağımlılıkları her zaman aynı sahte verileri yayınlayan sahte bir deposuyla değiştirebilirsiniz:

Bağımlılıklar sahte bir uygulamayla değiştirilir.
Şekil 2. Bağımlılıklar sahte bir uygulamayla değiştirilir.

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 yerine bu sahte depoyu ekleyin:

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

Test edilen öğenin çıkışları üzerinde kontrol sahibi olduğunuza göre, çıkışlarını kontrol ederek öğenin düzgün çalıştığını doğrulayabilirsiniz.

Testte akış emisyonlarını doğrulama

Test edilen konu bir akış sunuyorsa testin, veri akışının öğeleri hakkında iddialarda bulunması gerekir.

Önceki örneğin deposunda bir akış olduğunu varsayalım:

Bir akışı gösteren sahte bağımlılıklara sahip depo
Şekil 3. Bir akışı gösteren sahte bağımlılıklara sahip bir depo (test edilen konu).

Belirli testlerde yalnızca ilk yayını veya akıştan gelen sınırlı sayıda öğeyi kontrol etmeniz gerekir.

first() işlevini çağırarak akışa gönderilen ilk yayını kullanabilirsiniz. Bu işlev, ilk öğe alınana kadar bekler ve ardından iptal sinyalini üreticiye 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 fazla değeri kontrol etmesi gerekiyorsa toList() çağrısı, 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 işlem yalnızca sonlu 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 sonlu sayıda öğe döndürmeyen veri akışları için öğeleri seçip dönüştürmek üzere Flow API'yi kullanabilirsiniz. Aşağıda bazı örnekler verilmiştir:

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

Test sırasında sürekli veri toplama

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

Akışta değer yayınlanmasına neden olan işlemleri ve yayınlanan değerlerle ilgili iddiaları art arda yerleştirmek için bir test sırasında akıştan sürekli olarak değer toplayabilirsiniz.

Örneğin, test edilecek aşağıdaki Repository sınıfını ve test sırasında dinamik olarak değer üretmek için emit yöntemi içeren eşlik eden sahte 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 nesneyi bir testte kullanırken, Repository kaynağından sürekli olarak değer alacak bir toplayıcı coroutine oluşturabilirsiniz. Bu örnekte, bunları bir listeye toplayıp ardından içeriğiyle ilgili iddialarda bulunuyoruz:

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

Buradaki Repository tarafından sunulan akış hiçbir zaman tamamlanmadığı için, verileri toplayan toList çağrısı hiçbir zaman döndürülmez. Toplayıcı coroutine'u TestScope.backgroundScope içinde başlatmak, coroutine'un testin sona ermesinden önce iptal edilmesini sağlar. Aksi takdirde runTest, tamamlanmasını beklemeye devam eder ve testin yanıt vermeyi bırakmasına ve sonunda başarısız olmasına neden olur.

Burada toplayıcı iş parçacığı için UnconfinedTestDispatcher değerinin nasıl kullanıldığına dikkat edin. Bu, toplayıcı iş parçacığının istekli olarak başlatılmasını ve launch döndükten sonra değerler almaya hazır olmasını sağlar.

Turbine'i kullanma

Üçüncü taraf Turbine kitaplığı, toplayıcı bir coroutine oluşturmak için kullanışlı bir API'nin yanı sıra akışları test etmek için diğer kolaylık özelliklerini 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 ayrıntı için kitaplığın belgelerine bakın.

StateFlow'ları test etme

StateFlow, zaman içinde tuttuğu değerleri bir akış olarak gözlemlemek için toplanabilen gözlemlenebilir bir veri tutucusudur. Bu değer akışının birleştirildiğini unutmayın. Yani değerler bir StateFlow içinde hızlı bir şekilde ayarlanırsa bu StateFlow'ün toplayıcılarının tüm ara değerleri almaları garanti edilmez, yalnızca en son değer alınır.

Testlerde, birleştirme işlemini göz önünde bulundurursanız Turbine dahil olmak üzere diğer tüm akışları toplayabileceğiniz gibi bir StateFlow değerini de toplayabilirsiniz. Bazı test senaryolarında tüm ara değerleri toplamaya ve bunlarla ilgili iddiada bulunmaya çalışmak istenebilir.

Bununla birlikte, genellikle StateFlow'ü veri sahibi olarak ele almanızı ve bunun yerine value mülkünde iddiada bulunmanızı öneririz. Bu sayede testler, belirli bir zamanda nesnenin mevcut durumunu doğrular ve birleştirme işleminin gerçekleşip gerçekleşmediğine bağlı değildir.

Örneğin, bir Repository'dan değer toplayan ve bunları StateFlow içinde kullanıcı arayüzüne gösteren bu ViewModel'ü 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 şu şekilde 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 öğesini bu sahte öğeyle test ederken, ViewModel öğesinin StateFlow alanında güncellemeleri tetiklemek için sahte öğeden değer yayınlayabilir ve ardından güncellenen value öğesinde doğrulama yapabilirsiniz:

@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 StateFlow'larla çalışma

Önceki bölümde ViewModel, Repository kaynağından gelen bir akış tarafından yayınlanan son değeri depolamak için bir MutableStateFlow kullanır. Bu, genellikle soğuk bir akışı sıcak StateFlow'a dönüştüren stateIn operatörü kullanılarak 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ünün, ne zaman etkinleşeceğini ve temel akışı ne zaman tüketmeye başlayacağını belirleyen bir SharingStarted parametresi vardır. Görüntüleme modellerinde SharingStarted.Lazily ve SharingStarted.WhileSubscribed gibi seçenekler sıklıkla kullanılır.

Testinizde StateFlow öğesinin value özelliğini doğruluyor 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