Testowanie przepływów Kotlin na Androidzie

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

  • Jeśli testowany obiekt obserwuje przepływ, możesz generować przepływy fałszywe zależności, które można kontrolować dzięki testom.
  • Jeśli jednostka lub moduł ujawnia przepływ, możesz go odczytać i zweryfikować, wielu elementów emitowanych przez przepływ w teście.
.

Tworzenie fałszywego producenta

Gdy przedmiot jest konsumentem przepływu, jeden z popularnych sposobów jego przetestowania polega na zastąpieniu producenta fałszywą implementacją. Na przykład, jeśli która obserwuje repozytorium pobierające dane z dwóch źródeł produkcja:

pod testowanym obiektem i warstwą danych,
Rysunek 1. Podmiot testowany i dane

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

zostały zastąpione fałszywą implementacją
Rys. 2. Zależności są zastąpione fałszywym implementacji.

Aby emitować wstępnie zdefiniowany ciąg wartości w przepływie, użyj konstruktora flow:

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

W teście wstrzykiwane jest fałszywe repozytorium, które zastępuje prawdziwe implementacja:

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

Mając już kontrolę nad wynikami testowanego obiektu, ustal, czy działa prawidłowo, sprawdzając dane wyjściowe.

Potwierdzanie emisji przepływu w teście

Jeśli testowany obiekt ujawnia przepływ, test musi potwierdzić asercję na elementach strumienia danych.

Załóżmy, że repozytorium z poprzedniego przykładu ujawnia przepływ:

repozytorium z fałszywymi zależnościami, które ujawniają przepływ
Rys. 3. repozytorium (testowany obiekt) z fałszywymi informacjami, które ujawniają przepływ.

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

Możesz wykorzystać pierwszą emisję dla przepływu, wywołując funkcję first(). Ten funkcja czeka na odbiór pierwszego elementu, a następnie wysyła informację o anulowaniu do producenta.

@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 musi sprawdzić kilka wartości, wywołanie metody toList() powoduje uruchomienie przepływu. i poczekać, aż źródło wyemituje wszystkie swoje wartości, a następnie zwrócić je jako z listy. Ta funkcja działa tylko w przypadku ograniczonych strumieni danych.

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

W przypadku strumieni danych, które wymagają bardziej złożonego zbierania elementów lub nie zwracają skończonej liczby elementów, możesz wybierać i przekształcać je za pomocą interfejsu API Flow; elementy(ó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

Gromadzenie procesu za pomocą toList(), tak jak w poprzednim przykładzie: collect() wewnętrznie i została zawieszona do momentu, aż cała lista wyników będzie gotowa .

Aby przeplatać działania, które powodują, że przepływ generuje wartości i asercje na możesz zbierać wartości z przepływu w trakcie na test.

Na przykład do przetestowania użyj następującej klasy Repository, a na przykład towarzyszącej implementacji fałszywego źródła danych z użyciem metody emit dynamiczne generowanie 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
}

Używając tej fałszywej treści w teście, możesz utworzyć połączenie kolekcjonerskie, które stale otrzymuje wartości z Repository. W tym przykładzie zbierając je na listę, a następnie stosując asercje do jej treści:

@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 ujawniany przez Repository w tym miejscu nigdy się nie kończy, toList które zbiera dane. Rozpoczęcie współprogramu zbierania danych za TestScope.backgroundScope gwarantuje anulowanie współprogramu przed zakończeniem testu. W przeciwnym razie Aplikacja runTest czekała na zakończenie procesu, co spowodowało zatrzymanie testu odpowiada, a w końcu kończy się niepowodzeniem.

Zwróć uwagę, jak UnconfinedTestDispatcher jest używany do współudziału zbierania danych w tym miejscu. Dzięki temu funkcja zbierania danych współrzędna jest uruchamiana szybko i jest gotowa do odbierania wartości po launch „powrót karetki”.

Korzystanie z turbiny

Turbina innej firmy. udostępnia wygodny interfejs API do tworzenia współrzędu kolekcjonerskiego, jako inne udogodnienie dla 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())
    }
}

Zobacz dokumentacji biblioteki dotyczącej .

Testowanie StateFlows

StateFlow jest obiektem dostrzegalnym właściciela danych, który może być gromadzony w celu obserwowania wartości posiadanych w czasie, w postaci strumienia. Zwróć uwagę, że ten strumień wartości jest scalony, co oznacza, że jeśli są szybko ustawiane w StateFlow, kolektory tych elementów StateFlow nie są wszystkie wartości pośrednie, tylko najnowsze.

Jeśli w testach masz na uwadze konflikt, możesz zebrać wartości parametru StateFlow możesz zbierać wszystkie inne przepływy, łącznie z trybem Turbine. Próbuję zebrać i asyst na wszystkich wartościach pośrednich może być pożądane w niektórych scenariuszach testowych.

Zasadniczo zalecamy jednak traktowanie domeny StateFlow jako właściciela danych i w standardzie value. W ten sposób testy sprawdzają bieżący stanu obiektu w danym punkcie w czasie i nie zależą od tego, czy które często się ze sobą stykają.

Weźmy na przykład ten model widoku danych, który zbiera wartości z modeli Repository oraz ujawnia je w interfejsie 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
            }
        }
    }
}

Fałszywa implementacja elementu 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
}

Gdy testujesz model ViewModel z użyciem fałszywego modelu, możesz przesyłać wartości Wyzwalaj aktualizacje w modelu StateFlow modelu widoku danych, a następnie zatwierdź 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 StateFlows utworzonymi przez stateIn

W poprzedniej sekcji model widoku danych używa MutableStateFlow do przechowywania najnowsza wartość emitowana przez przepływ z Repository. To częsty schemat, implementowane zazwyczaj w prostszy sposób za pomocą stateIn który zamienia przepływ zimny w ciepły 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 zużywać bazowy przepływ. Opcje takie jak Często używane są SharingStarted.Lazily i SharingStarted.WhileSubsribed w modelach ViewModels.

Nawet jeśli zgłaszasz prośbę do value elementów StateFlow w teście, musisz utworzyć kolektor. Może to być pusty kolektor:

@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