Testowanie przepływów Kotlin na Androidzie

Sposób testowania jednostek lub modułów, które komunikują się z przepływem, zależy od tego, czy testowany element używa przepływu jako danych wejściowych czy wyjściowych.

  • Jeśli testowany obiekt obserwuje przepływ, możesz generować przepływy w ramach fałszywych zależności, które możesz kontrolować za pomocą testów.
  • Jeśli jednostka lub moduł udostępnia przepływ, możesz odczytać i zweryfikować w teście co najmniej 1 element wyemitowany przez przepływ.

Tworzenie fałszywego producenta

Jeśli testowany obiekt jest odbiorcą przepływu, jednym z często stosowanych sposobów testowania jest zastąpienie producenta fałszywą implementacją. Na przykład, jeśli masz klasę, która obserwuje repozytorium pobierające dane z 2 źródeł danych w środowisku produkcyjnym:

testowany element i warstwa danych,
Rysunek 1. Obiekt testowany i warstwa danych.

Aby test był deterministyczny, możesz zastąpić repozytorium i jego zależności fałszywym repozytorium, które zawsze emituje te same fałszywe dane:

zależności są zastępowane fałszywą implementacją.
Rysunek 2. Zależności są zastępowane fałszywą implementacją.

Aby wyemitować w przepływie wstępnie zdefiniowaną serię wartości, użyj narzędzia flow:

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

W teście wstrzykiwane jest to fałszywe repozytorium, które zastępuje prawdziwą implementację:

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

Teraz, gdy masz kontrolę nad wynikami testowanego obiektu, możesz sprawdzić, czy działa on prawidłowo, analizując jego wyniki.

Sprawdzanie emisji przepływu w teście

Jeśli testowany obiekt udostępnia przepływ, test musi zawierać asercje dotyczące elementów strumienia danych.

Załóżmy, że repozytorium z poprzedniego przykładu udostępnia przepływ:

repozytorium z fałszywymi zależnościami, które ujawnia przepływ;
Rysunek 3. repozytorium (obiekt testowany) z fałszywymi zależnościami, które udostępnia przepływ;

W przypadku niektórych testów wystarczy sprawdzić pierwszą emisję lub skończoną liczbę elementów pochodzących z przepływu.

Pierwszą emisję w strumieniu możesz wykorzystać, wywołując first(). Ta funkcja czeka na otrzymanie pierwszego elementu, a następnie wysyła do producenta sygnał anulowania.

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

Jeśli test wymaga sprawdzenia wielu wartości, wywołanie toList() powoduje, że automatyzacja czeka, aż źródło wyemituje wszystkie wartości, a następnie zwraca je w postaci listy. Działa to tylko w przypadku skończonych strumieni danych.

@Test
fun myRepositoryTestList() = runTest {
    val repository = MyFakeRepository()
    // 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)
}

W przypadku strumieni danych, które wymagają bardziej złożonego zbierania elementów lub nie zwracają skończonej liczby elementów, możesz użyć interfejsu Flow API do wybierania i przekształcania elementów. Oto przykłady:

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

Ciągłe zbieranie danych podczas testu

Zbieranie przepływu za pomocą toList(), jak w poprzednim przykładzie, używa wewnętrznie collect() i zawiesza działanie do momentu, gdy cała lista wyników będzie gotowa do zwrócenia.

Aby przeplatać działania powodujące emitowanie wartości przez przepływ i asercje dotyczące wyemitowanych wartości, możesz w trakcie testu stale zbierać wartości z przepływu.

Weźmy na przykład tę Repository klasę do przetestowania i towarzyszącą jej implementację fałszywego źródła danych, która ma metodę emit do dynamicznego generowania wartości podczas testu:

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
}

Podczas używania tego obiektu zastępczego w teście możesz utworzyć zbierający współprogram, który będzie nieustannie odbierać wartości z Repository. W tym przykładzie zbieramy je na liście, a następnie sprawdzamy jej zawartość:

@OptIn(ExperimentalCoroutinesApi::class)
@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
}

Ponieważ proces udostępniany przez Repository nigdy się nie kończy, wywołanie toList, które go zbiera, nigdy nie zwraca wartości. Uruchomienie współprogramu zbierającego w TestScope.backgroundScope gwarantuje, że zostanie on anulowany przed zakończeniem testu. W przeciwnym razie funkcja runTest będzie czekać na zakończenie działania, co spowoduje, że test przestanie odpowiadać i w końcu zakończy się niepowodzeniem.

Zwróć uwagę, jak w tym przypadku do zbierania współprogramu używana jest funkcja UnconfinedTestDispatcher. Dzięki temu zbierający współprogram zostanie uruchomiony od razu i będzie gotowy do odbierania wartości po zwróceniu launch.

Korzystanie z Turbine

Biblioteka Turbine innej firmy udostępnia wygodny interfejs API do tworzenia współprogramu zbierającego dane, a także inne przydatne funkcje do testowania przepływów:

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

Więcej informacji znajdziesz w dokumentacji biblioteki.

Testowanie StateFlow

StateFlow to obserwowalny pojemnik na dane, który można zbierać, aby obserwować przechowywane w nim wartości na przestrzeni czasu jako strumień. Pamiętaj, że ten strumień wartości jest połączony, co oznacza, że jeśli wartości są ustawiane StateFlow szybko, kolektory tego StateFlow nie mają gwarancji, że otrzymają wszystkie wartości pośrednie, a tylko ostatnią.

Podczas testów, jeśli pamiętasz o łączeniu, możesz zbierać wartości StateFlow, tak jak w przypadku innych przepływów, w tym za pomocą Turbine. W niektórych scenariuszach testowych warto spróbować zebrać i sprawdzić wszystkie wartości pośrednie.

Zalecamy jednak traktowanie StateFlow jako podmiotu przechowującego dane i sprawdzanie jego właściwości value. W ten sposób testy weryfikują bieżący stan obiektu w danym momencie i nie zależą od tego, czy nastąpiło połączenie.

Weźmy na przykład ten ViewModel, który zbiera wartości z Repository i udostępnia je w interfejsie w 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
            }
        }
    }
}

Przykładowa implementacja funkcji Repository może wyglądać tak:

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

Podczas testowania ViewModel za pomocą tego obiektu zastępczego możesz emitować wartości z obiektu zastępczego, aby wywoływać aktualizacje w StateFlow ViewModel, a następnie sprawdzać zaktualizowany value:

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

Praca z obiektami StateFlow utworzonymi za pomocą funkcji stateIn

W poprzedniej sekcji ViewModel używa MutableStateFlow do przechowywania najnowszej wartości wyemitowanej przez przepływ z Repository. Jest to typowy wzorzec, który zwykle jest implementowany w prostszy sposób za pomocą operatora stateIn, który przekształca zimny przepływ w gorący StateFlow:

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

Operator stateIn ma parametr SharingStarted, który określa, kiedy staje się aktywny i zaczyna wykorzystywać bazowy przepływ. Opcje takie jak SharingStarted.LazilySharingStarted.WhileSubscribed są często używane w modelach widoku.

Nawet jeśli w teście sprawdzasz value StateFlow, musisz utworzyć kolektor. Może to być pusty kolektor:

@OptIn(ExperimentalCoroutinesApi::class)
@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)
}

Dodatkowe materiały