La turbine est une petite bibliothèque d'essai pour Flow de Kotlinx.Coroutines.
flowOf( " one " , " two " ).test {
assertEquals( " one " , awaitItem())
assertEquals( " two " , awaitItem())
awaitComplete()
}Une turbine est un dispositif mécanique rotatif qui extrait l'énergie d'un flux de fluide et le convertit en travaux utiles.
- Wikipedia
repositories {
mavenCentral()
}
dependencies {
testImplementation( " app.cash.turbine:turbine:1.2.0 " )
}repositories {
maven {
url = uri( " https://oss.sonatype.org/content/repositories/snapshots/ " )
}
}
dependencies {
testImplementation( " app.cash.turbine:turbine:1.3.0-SNAPSHOT " )
} Bien que la propre API de Turbine soit stable, nous sommes actuellement obligés de dépendre d'une API instable de Kotlinx.Coroutines Test Artefact: UnconfinedTestDispatcher . Sans cette utilisation de la turbine avec runTest , se casserait. Il est possible que les futures mises à jour de la bibliothèque Coroutine modifient le comportement de cette bibliothèque. Nous nous efforcerons également d'assurer la stabilité comportementale jusqu'à ce que cette dépendance API soit stabilisée (suivi du numéro 132).
Une Turbine est un emballage mince sur un Channel avec une API conçue pour les tests.
Vous pouvez appeler awaitItem() pour suspendre et attendre qu'un article soit envoyé à la Turbine :
assertEquals( " one " , turbine.awaitItem()) ... awaitComplete() pour suspendre jusqu'à ce que la Turbine se termine sans exception:
turbine.awaitComplete() ... ou awaitError() pour suspendre jusqu'à ce que la Turbine se termine avec un Throwable .
assertEquals( " broken! " , turbine.awaitError().message) Si await* est appelé et rien ne se passe, Turbine sera déloyée et échouera au lieu de suspendre.
Lorsque vous avez terminé avec une Turbine , vous pouvez nettoyer en appelant cancel() pour résilier les coroutines de support. Enfin, vous pouvez affirmer que tous les événements ont été consommés en appelant ensureAllEventsConsumed() .
La façon la plus simple de créer et d'exécuter une Turbine est de produire une à partir d'un Flow . Pour tester un seul Flow , appelez l'extension test :
someFlow.test {
// Validation code here!
} test lance une nouvelle coroutine, appelle someFlow.collect et alimente les résultats dans une Turbine . Ensuite, il appelle le bloc de validation, en passant dans l'interface de ReceiveTurbine de lecture comme récepteur:
flowOf( " one " ).test {
assertEquals( " one " , awaitItem())
awaitComplete()
} Lorsque le bloc de validation est terminé, test annule la coroutine et appelle ensureAllEventsConsumed() .
Pour tester plusieurs flux, affectez chaque Turbine à une val séparée en appelant à la place testIn :
runTest {
turbineScope {
val turbine1 = flowOf( 1 ).testIn(backgroundScope)
val turbine2 = flowOf( 2 ).testIn(backgroundScope)
assertEquals( 1 , turbine1.awaitItem())
assertEquals( 2 , turbine2.awaitItem())
turbine1.awaitComplete()
turbine2.awaitComplete()
}
} Comme test , testIn produit une ReceiveTurbine . ensureAllEventsConsumed() sera invoqué à la fin du Coroutine.
testIn ne peut pas nettoyer automatiquement sa coroutine, il vous appartient donc de vous assurer que l'écoulement en cours de course se termine. Utilisez backgroundScope de runTest , et il s'en occupera automatiquement. Sinon, assurez-vous d'appeler l'une des méthodes suivantes avant la fin de votre portée:
cancel()awaitComplete()awaitError()Sinon, votre test sera suspendu.
Ne pas consommer tous les événements avant la fin du bloc de validation d'une Turbine basée sur le flux échouera à votre test:
flowOf( " one " , " two " ).test {
assertEquals( " one " , awaitItem())
} Exception in thread "main" AssertionError:
Unconsumed events found:
- Item(two)
- Complete
Il en va de même pour testIn , mais à la fin de l'appel Coroutine:
runTest {
turbineScope {
val turbine = flowOf( " one " , " two " ).testIn(backgroundScope)
turbine.assertEquals( " one " , awaitItem())
}
} Exception in thread "main" AssertionError:
Unconsumed events found:
- Item(two)
- Complete
Les événements reçus peuvent cependant être explicitement ignorés.
flowOf( " one " , " two " ).test {
assertEquals( " one " , awaitItem())
cancelAndIgnoreRemainingEvents()
}De plus, nous pouvons recevoir l'article émis le plus récent et ignorer les précédents.
flowOf( " one " , " two " , " three " )
.map {
delay( 100 )
it
}
.test {
// 0 - 100ms -> no emission yet
// 100ms - 200ms -> "one" is emitted
// 200ms - 300ms -> "two" is emitted
// 300ms - 400ms -> "three" is emitted
delay( 250 )
assertEquals( " two " , expectMostRecentItem())
cancelAndIgnoreRemainingEvents()
} Les événements de terminaison de flux (exceptions et achèvement) sont exposés comme des événements qui doivent être consommés pour la validation. Ainsi, par exemple, lancer une RuntimeException à l'intérieur de votre flow ne lancera pas d'exception dans votre test. Il produira plutôt un événement d'erreur de turbine:
flow { throw RuntimeException ( " broken! " ) }.test {
assertEquals( " broken! " , awaitError().message)
}Le défaut de consommer une erreur entraînera la même exception d'événement non consommé que ci-dessus, mais à l'exception ajoutée comme cause afin que le stacktrace complet soit disponible.
flow< Nothing > { throw RuntimeException ( " broken! " ) }.test { } app.cash.turbine.TurbineAssertionError: Unconsumed events found:
- Error(RuntimeException)
at app//app.cash.turbine.ChannelTurbine.ensureAllEventsConsumed(Turbine.kt:215)
... 80 more
Caused by: java.lang.RuntimeException: broken!
at example.MainKt$main$1.invokeSuspend(FlowTest.kt:652)
... 105 more
En plus des ReceiveTurbine créées à partir de flux, Turbine autonomes peuvent être utilisées pour communiquer avec le code de test en dehors d'un flux. Utilisez-les partout, et vous pourriez ne plus jamais avoir besoin runCurrent() . Voici un exemple d'utilisation Turbine() dans un faux:
class FakeNavigator : Navigator {
val goTos = Turbine < Screen >()
override fun goTo ( screen : Screen ) {
goTos.add(screen)
}
}runTest {
val navigator = FakeNavigator ()
val events : Flow < UiEvent > =
MutableSharedFlow < UiEvent >(extraBufferCapacity = 50 )
val models : Flow < UiModel > =
makePresenter(navigator).present(events)
models.test {
assertEquals( UiModel (title = " Hi there " ), awaitItem())
events.emit( UiEvent . Close )
assertEquals( Screens . Back , navigator.goTos.awaitItem())
}
} Pour prendre en charge les bases de code avec un mélange de coroutines et de code non coritines, Turbine autonome comprend des API de compat non suspendues. Toutes les méthodes await ont des méthodes take équivalentes qui ne sont pas en suspens:
val navigator = FakeNavigator ()
val events : PublishRelay < UiEvent > = PublishRelay .create()
val models : Observable < UiModel > =
makePresenter(navigator).present(events)
val testObserver = models.test()
testObserver.assertValue( UiModel (title = " Hi there " ))
events.accept( UiEvent . Close )
assertEquals( Screens . Back , navigator.goTos.takeItem()) Utilisez takeItem() et les amis, et Turbine se comporte comme une simple file d'attente; Utilisez awaitItem() et des amis, et c'est une Turbine .
Ces méthodes ne doivent être utilisées que à partir d'un contexte non suspendu. Sur les plates-formes JVM, ils lanceront lorsqu'ils seront utilisés à partir d'un contexte de suspension.
Les flux sont asynchrones par défaut. Votre flux est collecté simultanément par la turbine à côté de votre code de test.
La gestion de cette asynchronicité fonctionne de la même manière avec la turbine que dans la production de Coroutines Code: Au lieu d'utiliser des outils comme runCurrent() pour "pousser" un flux asynchrone le long de Turbine awaitItem() , awaitComplete() et awaitError() " Tirez-les "en stationnement jusqu'à ce qu'un nouvel événement soit prêt.
channelFlow {
withContext( IO ) {
Thread .sleep( 100 )
send( " item " )
}
}.test {
assertEquals( " item " , awaitItem())
awaitComplete()
} Votre code de validation peut fonctionner simultanément avec le flux testé, mais Turbine le met autant que possible dans le siège du conducteur: test se terminera lorsque votre bloc de validation sera terminé, annulant implicitement le débit sous test.
channelFlow {
withContext( IO ) {
repeat( 10 ) {
Thread .sleep( 200 )
send( " item $it " )
}
}
}.test {
assertEquals( " item 0 " , awaitItem())
assertEquals( " item 1 " , awaitItem())
assertEquals( " item 2 " , awaitItem())
}Les flux peuvent également être explicitement annulés à tout moment.
channelFlow {
withContext( IO ) {
repeat( 10 ) {
Thread .sleep( 200 )
send( " item $it " )
}
}
}.test {
Thread .sleep( 700 )
cancel()
assertEquals( " item 0 " , awaitItem())
assertEquals( " item 1 " , awaitItem())
assertEquals( " item 2 " , awaitItem())
} Les turbines peuvent être nommées pour améliorer les commentaires des erreurs. Passez un name pour test , testIn ou Turbine() , et il sera inclus dans toutes les erreurs qui sont lancées:
runTest {
turbineScope {
val turbine1 = flowOf( 1 ).testIn(backgroundScope, name = " turbine 1 " )
val turbine2 = flowOf( 2 ).testIn(backgroundScope, name = " turbine 2 " )
turbine1.awaitComplete()
turbine2.awaitComplete()
}
} Expected complete for turbine 1 but found Item(1)
app.cash.turbine.TurbineAssertionError: Expected complete for turbine 1 but found Item(1)
at app//app.cash.turbine.ChannelKt.unexpectedEvent(channel.kt:258)
at app//app.cash.turbine.ChannelKt.awaitComplete(channel.kt:226)
at app//app.cash.turbine.ChannelKt$awaitComplete$1.invokeSuspend(channel.kt)
at app//kotlin.coroutines.jvm.internal.BaseContinuationImpl.resumeWith(ContinuationImpl.kt:33)
...
Les flux partagés sont sensibles à l'ordre d'exécution. Appeler emit avant d'appeler collect abandonnera la valeur émise:
val mutableSharedFlow = MutableSharedFlow < Int >(replay = 0 )
mutableSharedFlow.emit( 1 )
mutableSharedFlow.test {
assertEquals(awaitItem(), 1 )
} No value produced in 1s
java.lang.AssertionError: No value produced in 1s
at app.cash.turbine.ChannelKt.awaitEvent(channel.kt:90)
at app.cash.turbine.ChannelKt$awaitEvent$1.invokeSuspend(channel.kt)
(Coroutine boundary)
at kotlinx.coroutines.test.TestBuildersKt__TestBuildersKt$runTestCoroutine$2.invokeSuspend(TestBuilders.kt:212)
Les méthodes test et testIn de Turbine garantissent que le flux de test passera au premier point de suspension avant de procéder. Ainsi, appeler test sur un flux partagé avant d'émettre ne baissera pas:
val mutableSharedFlow = MutableSharedFlow < Int >(replay = 0 )
mutableSharedFlow.test {
mutableSharedFlow.emit( 1 )
assertEquals(awaitItem(), 1 )
}Si votre code s'accumule sur les flux partagés, assurez-vous qu'il le fait rapidement d'avoir une belle expérience.
Les types de flux partagés que Kotlin fournit actuellement sont:
MutableStateFlowStateFlowMutableSharedFlowSharedFlow Turbine applique un délai d'expiration chaque fois qu'il attend un événement. Il s'agit d'un délai d'attente de l'horloge murale qui ignore le temps d'horloge virtuel de runTest .
La longueur de délai d'expiration par défaut est de trois secondes. Cela peut être remplacé en passant une durée de délai d'expiration pour test :
flowOf( " one " , " two " ).test(timeout = 10 .milliseconds) {
.. .
}Ce délai d'attente sera utilisé pour tous les appels liés à la turbine à l'intérieur du bloc de validation.
Vous pouvez également remplacer le délai d'expiration des turbines créées avec testIn et Turbine() :
val standalone = Turbine < String >(timeout = 10 .milliseconds)
val flow = flowOf( " one " ).testIn(
scope = backgroundScope,
timeout = 10 .milliseconds,
) Ces remplacements de délai d'expiration ne s'appliquent qu'à la Turbine sur laquelle ils ont été appliqués.
Enfin, vous pouvez également modifier le délai d'expiration de tout un bloc de code en utilisant withTurbineTimeout :
withTurbineTimeout( 10 .milliseconds) {
.. .
} La plupart des API de Turbine sont implémentées sous forme d'extensions sur Channel . La surface API la plus limitée de Turbine est généralement préférable, mais ces extensions sont également disponibles en tant qu'API publiques si vous en avez besoin.
Copyright 2018 Square, Inc.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.