Sposób testowania jednostek lub modułów, które komunikują się z automatyzacją zależy od tego, czy testowany obiekt używa automatyzacji jako danych wejściowych czy wyjściowych.
- Jeśli testowany obiekt obserwuje automatyzację, możesz generować automatyzacje w ramach fałszywych zależności, które możesz kontrolować za pomocą testów.
- Jeśli jednostka lub moduł udostępnia automatyzację, możesz odczytać i zweryfikować w teście co najmniej 1 element emitowany przez automatyzację.
Tworzenie fałszywego producenta
Gdy testowany obiekt jest odbiorcą automatyzacji, jednym z typowych sposobów jego 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:
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:
Aby emitować w automatyzacji wstępnie zdefiniowaną serię wartości, użyj konstruktora flow:
class MyFakeRepository : MyRepository { fun observeCount() = flow { emit(ITEM_1) } }
W teście to fałszywe repozytorium jest wstrzykiwane, zastępując prawdziwą implementację:
@Test fun myTest() { // Given a class with fake dependencies: val sut = MyUnitUnderTest(MyFakeRepository()) // Trigger and verify // ... }
Teraz, gdy masz kontrolę nad danymi wyjściowymi testowanego obiektu, możesz sprawdzić, czy działa on prawidłowo, sprawdzając jego dane wyjściowe.
Sprawdzanie emisji automatyzacji w teście
Jeśli testowany obiekt udostępnia automatyzację, test musi zawierać asercje dotyczące elementów strumienia danych.
Załóżmy, że repozytorium z poprzedniego przykładu udostępnia automatyzację:
W niektórych testach wystarczy sprawdzić tylko pierwszą emisję lub skończoną liczbę elementów pochodzących z automatyzacji.
Aby wykorzystać pierwszą emisję do automatyzacji, wywołaj funkcję 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 musi sprawdzić wiele wartości, wywołanie funkcji toList() spowoduje, że automatyzacja poczeka, aż źródło wyemituje wszystkie wartości, a następnie zwróci te wartości 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 API Flow, aby wybierać i przekształcać elementy. 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 automatyzacji za pomocą funkcji toList() zgodnie z poprzednim przykładem używa wewnętrznie funkcji collect() i zawiesza się, dopóki cała lista wyników nie będzie gotowa do zwrócenia.
Aby przeplatać działania, które powodują, że automatyzacja emituje wartości, i asercje dotyczące emitowanych wartości, możesz podczas testu ciągle zbierać wartości z automatyzacji.
Na przykład weź pod uwagę tę klasę Repository, która ma zostać przetestowana, oraz towarzyszącą jej fałszywą implementację źródła danych z metodą emit, która umożliwia 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 }
Gdy używasz tego fałszywego obiektu w teście, możesz utworzyć korutynę zbierającą, która będzie ciągle odbierać wartości z Repository. W tym przykładzie zbieramy je w listę, a następnie wykonujemy asercje dotyczące jej zawartości:
@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ż automatyzacja udostępniana przez Repository nigdy się nie kończy, wywołanie funkcji toList, która ją zbiera, nigdy nie zwraca wartości. Uruchomienie korutyny zbierającej w
TestScope.backgroundScope
gwarantuje, że korutyna zostanie anulowana przed zakończeniem testu. W przeciwnym razie funkcja runTest będzie czekać na jej zakończenie, co spowoduje, że test przestanie odpowiadać i ostatecznie zakończy się niepowodzeniem.
Zwróć uwagę, że w tym przypadku do korutyny zbierającej używamy
UnconfinedTestDispatcher. Dzięki temu korutyna zbierająca jest uruchamiana od razu i jest gotowa do odbierania wartości po powrocie funkcji launch.
Korzystanie z Turbine
Biblioteka Turbine innej firmy udostępnia wygodny interfejs API do tworzenia korutyny zbierającej oraz inne przydatne funkcje do testowania automatyzacji:
@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 kontener danych, który można zbierać, aby obserwować wartości, które zawiera, jako strumień. Pamiętaj, że ten strumień wartości jest spójny, co oznacza, że jeśli wartości są ustawiane w StateFlow szybko, odbiorcy tego StateFlow nie mają gwarancji, że otrzymają wszystkie wartości pośrednie, tylko najnowszą.
W testach, jeśli pamiętasz o spójności, możesz zbierać wartości StateFlow tak jak w przypadku każdej innej automatyzacji, w tym za pomocą Turbine. W niektórych scenariuszach testowych może być pożądane zbieranie i sprawdzanie wszystkich wartości pośrednich.
Ogólnie jednak zalecamy traktowanie StateFlow jako kontenera danych i sprawdzanie jego właściwości value. Dzięki temu testy sprawdzają bieżący stan obiektu w danym momencie i nie zależą od tego, czy występuje spójność.
Na przykład weź pod uwagę 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 } } } }
Fałszywa implementacja tego 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 fałszywego obiektu możesz emitować wartości z fałszywego obiektu, aby wywołać aktualizacje w StateFlow w ViewModel, a następnie sprawdzić zaktualizowaną 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 StateFlow utworzonymi przez stateIn
W poprzedniej sekcji ViewModel używa MutableStateFlow do przechowywania najnowszej wartości emitowanej przez automatyzację z Repository. Jest to typowy wzorzec,
który zwykle jest implementowany w prostszy sposób za pomocą
stateIn
operatora, który przekształca zimną automatyzację w gorącą 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ć bazową automatyzację. W modelach widoków często używane są opcje takie jak SharingStarted.Lazily i SharingStarted.WhileSubscribed.
Nawet jeśli w teście sprawdzasz value w StateFlow, musisz utworzyć odbiorcę. Może to być pusty odbiorca:
@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
- Testowanie korutyn Kotlin w Androidzie
- Automatyzacje Kotlin w Androidzie
StateFlowiSharedFlow- Dodatkowe materiały dotyczące korutyn i automatyzacji Kotlin