Sposób testowania jednostek lub modułów, które komunikują się z przepływem, 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 w ramach fałszywych zależności, które możesz kontrolować z poziomu testów.
- Jeśli jednostka lub moduł eksponuje przepływ, można odczytać i zweryfikować jeden lub kilka elementów generowanych przez przepływ w teście.
Tworzenie sfałszowanego producenta
Gdy testowany obiekt jest konsumentem przepływu, jednym z typowych sposobów sprawdzenia tego jest zastąpienie producenta fałszywą implementacją. Jeśli na przykład klasa obserwuje repozytorium, które pobiera dane z 2 źródeł danych w środowisku produkcyjnym:
Aby uczynić test deterministycznym, możesz zastąpić repozytorium i jego zależności fałszywym repozytorium, które zawsze wysyła te same fałszywe dane:
Aby emitować wstępnie zdefiniowaną serię wartości w przepływie, użyj kreatora flow
:
class MyFakeRepository : MyRepository {
fun observeCount() = flow {
emit(ITEM_1)
}
}
W teście jest wstrzykiwane to fałszywe repozytorium, które zastępuje rzeczywiste wdrożenie:
@Test
fun myTest() {
// Given a class with fake dependencies:
val sut = MyUnitUnderTest(MyFakeRepository())
// Trigger and verify
...
}
Gdy masz już kontrolę nad danymi wyjściowymi danego tematu, możesz sprawdzić, czy działa on prawidłowo.
Określanie emisji związanej z przepływem w teście
Jeśli testowany obiekt ujawnia przepływ, test musi przyjąć asercje do elementów strumienia danych.
Załóżmy, że repozytorium z poprzedniego przykładu ujawnia przepływ:
W przypadku niektórych testów wystarczy sprawdzić tylko pierwszą emisję lub pewną liczbę elementów pochodzących z procesu.
Możesz wykorzystać pierwszą emisję w procesie, wywołując first()
. Ta funkcja czeka, aż otrzyma pierwszy produkt, a następnie wysyła sygnał anulowania 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ć wiele wartości, wywołanie metody toList()
powoduje, że przepływ czeka, aż źródło wyemituje wszystkie wartości, a potem zwraca te wartości w formie listy. Sprawdza się to 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 zbioru elementów lub nie zwracają ograniczonej liczby elementów, możesz wybierać i przekształcać elementy za pomocą interfejsu Flow
API. 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ą funkcji toList()
, jak widać w poprzednim przykładzie, odbywa się wewnętrznie w elemencie collect()
i jest zawieszane do czasu, aż cała lista wyników będzie gotowa do zwrócenia.
Aby przeplatać działania, które powodują, że w przepływie są wyemitowane wartości i asercje, w trakcie testu możesz stale zbierać wartości z przepływu.
Możesz na przykład przetestować następującą klasę Repository
i towarzyszącą implementację fałszywego źródła danych, która wykorzystuje 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
}
Jeśli używasz tej fałszywej funkcji w teście, możesz utworzyć współpracę zbierającą, która będzie stale otrzymywać wartości z metody Repository
. W tym przykładzie zbieramy je na listę, a następnie wykonujemy asercje na jej podstawie:
@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
}
Przepływ, który jest tutaj ujawniany przez Repository
, nigdy się nie kończy, dlatego wywołanie toList
, które je pobiera, nie zostaje zwrócone. Rozpoczęcie współużytkowania kohorty w zadaniu TestScope.backgroundScope
spowoduje, że zostanie ona anulowana przed zakończeniem testu. W przeciwnym razie runTest
będzie czekać na zakończenie, powodując przerwanie odpowiedzi testu i w efekcie jego niepowodzenie.
Zwróć uwagę na sposób użycia narzędzia UnconfinedTestDispatcher
do obsługi współużytkowania. Dzięki temu wtyczka zbierająca będzie od razu uruchamiana i gotowa do odbioru wartości po zwróceniu funkcji launch
.
Korzystanie z turbiny
Biblioteka Turbine innej firmy ma wygodny interfejs API do tworzenia współużytkowania, 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 StateFlows
StateFlow
to możliwy do obserwacji magazyn danych, który można zbierać w celu obserwowania wartości, które posiada w czasie w postaci strumienia. Ten strumień wartości jest scalony, co oznacza, że jeśli wartości zostaną szybko ustawione w elemencie StateFlow
, kolektory tego elementu StateFlow
nie mogą otrzymać wszystkich wartości pośrednich, tylko najnowsze z nich.
Jeśli w testach masz na uwadze konflację, możesz zbierać wartości StateFlow
, podobnie jak każdy inny przepływ, w tym za pomocą Turbine. W niektórych scenariuszach testowych próba zebrania wszystkich wartości pośrednich i zgłoszenia do nich praw może być pożądana.
Ogólnie zalecamy jednak traktowanie StateFlow
jako właściciela danych i umieszczanie go w jego usłudze value
. Dzięki temu testy sprawdzają bieżący stan obiektu w danym momencie i nie zależą od tego, czy wystąpi konflikt.
Weźmy na przykład ten model ViewModel, który zbiera wartości z tabeli Repository
i przekazuje je do interfejsu użytkownika 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
}
Podczas testowania obiektu ViewModel za pomocą tego fałszywego modelu możesz generować wartości pochodzące z fałszywych wartości, aby powodować aktualizacje w modelu StateFlow
modelu ViewModel, a potem zgłaszać roszczenia do zaktualizowanego modelu 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 ze StateFlows utworzonymi przez stateIn
W poprzedniej sekcji model ViewModel używa metody MutableStateFlow
do przechowywania najnowszej wartości generowanej przez przepływ z Repository
. Jest to typowy wzorzec, który zwykle jest zaimplementowany w prostszy sposób za pomocą operatora stateIn
, który zamienia przepływ zimnego na ciepło 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 korzystać z podanego przepływu. Opcje takie jak SharingStarted.Lazily
i SharingStarted.WhileSubsribed
są często używane w modelach ViewModel.
Nawet jeśli w teście podasz value
elementu StateFlow
, 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
- Testowanie współprogramów Kotlin na Androidzie
- Kotlin na Androidzie
StateFlow
iSharedFlow
- Dodatkowe materiały dotyczące współprogramów i przepływu pracy Kotlin