Code für Einheitentests, der Koroutinen verwendet, erfordert besondere Aufmerksamkeit, da ihre Ausführung asynchron sein kann und über mehrere Threads hinweg erfolgen kann. In diesem Leitfaden wird beschrieben, wie Sperrfunktionen getestet werden können, mit welchen Testkonstrukten Sie vertraut sein müssen und wie Sie Ihren Code erstellen, der Koroutinen testbar verwendet.
Die in diesem Leitfaden verwendeten APIs sind Teil der Bibliothek kotlinx.coroutines.test. Achten Sie darauf, Ihrem Projekt das Artefakt als Testabhängigkeit hinzuzufügen, um Zugriff auf diese APIs zu haben.
dependencies {
testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:$coroutines_version"
}
Aussetzende Funktionen in Tests aufrufen
Wenn Sie Anhaltende Funktionen in Tests aufrufen möchten, müssen Sie sich in einer Koroutine befinden. Da die JUnit-Testfunktionen selbst keine Funktionen anhalten, müssen Sie in Ihren Tests einen Coroutine-Builder aufrufen, um eine neue Koroutine zu starten.
runTest
ist ein Coroutine-Builder für Tests. Hiermit werden alle Tests zusammengefasst, die Koroutinen enthalten. Koroutinen können nicht nur direkt im Testtext gestartet werden, sondern auch von den im Test verwendeten Objekten.
suspend fun fetchData(): String { delay(1000L) return "Hello world" } @Test fun dataShouldBeHelloWorld() = runTest { val data = fetchData() assertEquals("Hello world", data) }
Im Allgemeinen sollte pro Test ein Aufruf von runTest
verwendet werden. Die Verwendung eines Ausdrucks wird empfohlen.
Wenn Sie den Code Ihres Tests in runTest
umschließen, können Sie grundlegende Sperrfunktionen testen. Außerdem werden Verzögerungen in Koroutinen automatisch übersprungen, sodass der obige Test viel schneller als eine Sekunde abgeschlossen wird.
Je nachdem, was in dem zu testenden Code passiert, sind jedoch weitere Überlegungen erforderlich:
- Wenn Ihr Code neue Koroutinen erstellt, die von der von
runTest
erstellten Testkoroutine der obersten Ebene stammen, müssen Sie steuern, wie diese neuen Koroutinen geplant werden, indem Sie die entsprechendeTestDispatcher
auswählen. - Wenn Ihr Code die Ausführung der Koroutine zu anderen Disponenten verschiebt (z. B. mithilfe von
withContext
), funktioniertrunTest
in der Regel zwar weiterhin, Verzögerungen werden jedoch nicht mehr übersprungen und Tests sind weniger vorhersehbar, da Code in mehreren Threads ausgeführt wird. Aus diesen Gründen sollten Sie in Tests Test-Disponenten einfügen, um echte Disponenten zu ersetzen.
TestDispatcher
TestDispatchers
sind CoroutineDispatcher
-Implementierungen zu Testzwecken. Sie müssen TestDispatchers
verwenden, wenn während des Tests neue Koroutinen erstellt werden, damit die Ausführung der neuen Koroutinen vorhersehbar ist.
Es gibt zwei Implementierungen von TestDispatcher
: StandardTestDispatcher
und UnconfinedTestDispatcher
, mit denen neu gestartete Koroutinen unterschiedlich geplant werden. Beide verwenden ein TestCoroutineScheduler
, um die virtuelle Zeit zu steuern und laufende Koroutinen innerhalb eines Tests zu verwalten.
In einem Test sollte nur eine Planerinstanz verwendet werden, die von allen TestDispatchers
gemeinsam genutzt wird. Weitere Informationen zur Freigabe von Planern finden Sie unter TestDispatcher einfügen.
Um die Testkoroutine der obersten Ebene zu starten, erstellt runTest
ein TestScope
. Das ist eine Implementierung von CoroutineScope
, die immer einen TestDispatcher
verwendet. Wenn nicht angegeben, erstellt ein TestScope
standardmäßig ein StandardTestDispatcher
und verwendet dieses, um die Testkoroutine der obersten Ebene auszuführen.
runTest
verfolgt die Koroutinen, die sich in der Warteschlange des Planers befinden, der vom Disponenten seiner TestScope
verwendet wird, und wird nicht zurückgegeben, solange es ausstehende Aufgaben für diesen Planer gibt.
StandardTestDispatcher
Wenn Sie neue Koroutinen für einen StandardTestDispatcher
starten, werden sie im zugrunde liegenden Planer in die Warteschlange gestellt und immer dann ausgeführt, wenn der Testthread verfügbar ist. Damit diese neuen Koroutinen ausgeführt werden können, müssen Sie den Testthread mit yield versehen. So können Sie ihn für andere Koroutinen freigeben. Mit diesem Warteschlangenverhalten können Sie genau steuern, wie neue Koroutinen während des Tests ausgeführt werden. Es ähnelt der Planung von Koroutinen im Produktionscode.
Wenn der Testthread bei der Ausführung der Testkoroutine der obersten Ebene nicht zurückgegeben wird, werden neue Koroutinen erst nach Abschluss der Testkoroutine ausgeführt (aber bevor runTest
zurückgegeben wird):
@Test fun standardTest() = runTest { val userRepo = UserRepository() launch { userRepo.register("Alice") } launch { userRepo.register("Bob") } assertEquals(listOf("Alice", "Bob"), userRepo.getAllUsers()) // ❌ Fails }
Es gibt mehrere Möglichkeiten, die Testkoroutine zu erhalten, damit Koroutinen aus der Warteschlange ausgeführt werden können. Durch alle diese Aufrufe werden andere Koroutinen im Testthread ausgeführt, bevor sie zurückgegeben werden:
advanceUntilIdle
: Führt alle anderen Koroutinen im Planer aus, bis in der Warteschlange nichts mehr vorhanden ist. Dies ist eine gute Standardeinstellung, um alle ausstehenden Koroutinen laufen zu lassen, und sie funktioniert in den meisten Testszenarien.advanceTimeBy
: Verschiebt die virtuelle Zeit um die angegebene Zeit und führt alle Koroutinen aus, die vor diesem Zeitpunkt in der virtuellen Zeit ausgeführt werden sollen.runCurrent
: Führt Koroutinen aus, die zur aktuellen virtuellen Zeit geplant sind.
Zum Beheben des vorherigen Tests kann advanceUntilIdle
verwendet werden, damit die beiden ausstehenden Koroutinen ihre Arbeit ausführen können, bevor mit der Assertion fortgefahren wird:
@Test fun standardTest() = runTest { val userRepo = UserRepository() launch { userRepo.register("Alice") } launch { userRepo.register("Bob") } advanceUntilIdle() // Yields to perform the registrations assertEquals(listOf("Alice", "Bob"), userRepo.getAllUsers()) // ✅ Passes }
UnconfinedTestDispatcher
Wenn neue Koroutinen für einen UnconfinedTestDispatcher
gestartet werden, werden sie mit Spannung im aktuellen Thread gestartet. Sie werden also sofort gestartet, ohne auf die Rückgabe ihres Koroutinen-Builders warten zu müssen. In vielen Fällen führt dieses Weiterleitungsverhalten zu einfacherem Testcode, da Sie den Testthread nicht manuell zurückgeben müssen, um neue Koroutinen ausführen zu können.
Dieses Verhalten unterscheidet sich jedoch von dem, was Sie in der Produktion bei Nicht-Test-Disponenten beobachten werden. Wenn sich der Test auf Nebenläufigkeit konzentriert, sollten Sie stattdessen StandardTestDispatcher
verwenden.
Wenn Sie diesen Dispatcher für die Testkoroutine der obersten Ebene in runTest
anstelle der Standardkoroutine verwenden möchten, erstellen Sie eine Instanz und übergeben Sie sie als Parameter. Dadurch werden neue Koroutinen, die in runTest
erstellt wurden, motiviert ausgeführt, da sie den Disponenten von TestScope
übernehmen.
@Test fun unconfinedTest() = runTest(UnconfinedTestDispatcher()) { val userRepo = UserRepository() launch { userRepo.register("Alice") } launch { userRepo.register("Bob") } assertEquals(listOf("Alice", "Bob"), userRepo.getAllUsers()) // ✅ Passes }
In diesem Beispiel starten die Startaufrufe ihre neuen Koroutinen ehrgeizig am UnconfinedTestDispatcher
, was bedeutet, dass jeder Startaufruf erst nach Abschluss der Registrierung zurückgegeben wird.
Denk daran, dass UnconfinedTestDispatcher
neue Koroutinen freudig startet. Das bedeutet aber nicht, dass sie auch eifrig bis zum Ende ausgeführt werden. Wenn die neue Koroutine aussetzt, wird die Ausführung der anderen Koroutinen fortgesetzt.
Die neue Koroutine, die in diesem Test gestartet wird, registriert beispielsweise Alice, wird aber angehalten, wenn delay
aufgerufen wird. Dadurch kann die Koroutine der obersten Ebene mit der Assertion fortfahren und der Test schlägt fehl, da Bob noch nicht registriert ist:
@Test fun yieldingTest() = runTest(UnconfinedTestDispatcher()) { val userRepo = UserRepository() launch { userRepo.register("Alice") delay(10L) userRepo.register("Bob") } assertEquals(listOf("Alice", "Bob"), userRepo.getAllUsers()) // ❌ Fails }
Testdisponenten einschleusen
Zu testender Code kann Disponenten verwenden, um Threads mit withContext
zu wechseln oder neue Koroutinen zu starten. Wenn Code in mehreren Threads parallel ausgeführt wird, können die Tests instabil werden. Es kann schwierig sein, Assertions zum richtigen Zeitpunkt auszuführen oder auf den Abschluss von Aufgaben zu warten, wenn sie in Hintergrundthreads ausgeführt werden, über die Sie keine Kontrolle haben.
Ersetzen Sie diese Disponenten in Tests durch Instanzen von TestDispatchers
. Das hat mehrere Vorteile:
- Der Code wird im einzelnen Test-Thread ausgeführt, was die Tests deterministischer macht
- Sie können steuern, wie neue Koroutinen geplant und ausgeführt werden
- TestDispatchers verwenden einen Planer für virtuelle Termine, mit dem Verzögerungen automatisch übersprungen werden und Sie die Zeit manuell verlängern können.
Die Verwendung von Dependency Injection, um Disponenten für Ihre Klassen bereitzustellen, macht es einfach, die echten Disponenten in Tests zu ersetzen. In diesen Beispielen wird ein CoroutineDispatcher
eingefügt. Sie können aber auch den breiter gefassten CoroutineContext
-Typ injizieren, was für noch mehr Flexibilität während der Tests sorgt.
Bei Klassen, die Koroutinen starten, können Sie auch einen CoroutineScope
anstelle eines Disponenten injizieren, wie im Abschnitt Bereich einfügen beschrieben.
TestDispatchers
erstellt standardmäßig einen neuen Planer, wenn der Planer instanziiert wird. Innerhalb von runTest
können Sie auf die testScheduler
-Eigenschaft des TestScope
zugreifen und sie an jede neu erstellte TestDispatchers
übergeben. Dadurch wird ihr Verständnis von virtueller Zeit vermittelt, und Methoden wie advanceUntilIdle
führen Koroutinen für alle Test-Disponenten bis zum Abschluss aus.
Im folgenden Beispiel sehen Sie eine Repository
-Klasse, die mithilfe des Disponenten IO
in ihrer Methode initialize
eine neue Koroutine erstellt und den Aufrufer in ihrer Methode fetchData
auf den Disponenten IO
umstellt:
// Example class demonstrating dispatcher use cases class Repository(private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO) { private val scope = CoroutineScope(ioDispatcher) val initialized = AtomicBoolean(false) // A function that starts a new coroutine on the IO dispatcher fun initialize() { scope.launch { initialized.set(true) } } // A suspending function that switches to the IO dispatcher suspend fun fetchData(): String = withContext(ioDispatcher) { require(initialized.get()) { "Repository should be initialized first" } delay(500L) "Hello world" } }
In Tests können Sie eine TestDispatcher
-Implementierung einschleusen, um den IO
-Disponenten zu ersetzen.
Im folgenden Beispiel fügen wir einen StandardTestDispatcher
in das Repository ein und verwenden advanceUntilIdle
, um sicherzustellen, dass die in initialize
gestartete neue Koroutine abgeschlossen ist, bevor Sie fortfahren.
fetchData
ist auch vom Ausführen in einem TestDispatcher
profitieren, da es im Testthread ausgeführt wird und die Verzögerung überspringt, die es während des Tests gibt.
class RepositoryTest { @Test fun repoInitWorksAndDataIsHelloWorld() = runTest { val dispatcher = StandardTestDispatcher(testScheduler) val repository = Repository(dispatcher) repository.initialize() advanceUntilIdle() // Runs the new coroutine assertEquals(true, repository.initialized.get()) val data = repository.fetchData() // No thread switch, delay is skipped assertEquals("Hello world", data) } }
Neue Koroutinen, die auf einem TestDispatcher
gestartet wurden, können wie oben gezeigt mit initialize
manuell erweitert werden. Beachten Sie jedoch, dass dies in Produktionscode weder möglich noch wünschenswert wäre. Stattdessen sollte diese Methode so umgestaltet werden, dass sie entweder pausiert (für sequenzielle Ausführung) oder einen Deferred
-Wert zurückgibt (für gleichzeitige Ausführung).
Sie können beispielsweise async
verwenden, um eine neue Koroutine zu starten und eine Deferred
zu erstellen:
class BetterRepository(private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO) { private val scope = CoroutineScope(ioDispatcher) fun initialize() = scope.async { // ... } }
Auf diese Weise kannst du die Vervollständigung dieses Codes sowohl in Tests als auch in Produktionscode sicher await
:
@Test fun repoInitWorks() = runTest { val dispatcher = StandardTestDispatcher(testScheduler) val repository = BetterRepository(dispatcher) repository.initialize().await() // Suspends until the new coroutine is done assertEquals(true, repository.initialized.get()) // ... }
runTest
wartet, bis ausstehende Koroutinen abgeschlossen sind, und gibt sie dann zurück, wenn sich die Koroutinen auf einem TestDispatcher
befinden, mit dem sie einen Planer verwendet. Außerdem wird auf Koroutinen gewartet, die der Testkoroutine der obersten Ebene untergeordnet sind, selbst wenn sie sich auf anderen Disponenten befinden (bis zu einem durch den Parameter dispatchTimeoutMs
festgelegten Zeitlimit; standardmäßig 60 Sekunden).
Festlegen des Haupt-Disponenten
Bei lokalen Einheitentests ist der Main
-Dispatcher, der den Android-UI-Thread umschließt, nicht verfügbar, da diese Tests auf einer lokalen JVM und nicht auf einem Android-Gerät ausgeführt werden. Wenn der getestete Code auf den Hauptthread verweist, wird bei Einheitentests eine Ausnahme ausgelöst.
In einigen Fällen können Sie den Main
-Disponenten auf dieselbe Weise wie andere Disponenten einfügen, wie im vorherigen Abschnitt beschrieben, sodass Sie ihn in Tests durch einen TestDispatcher
ersetzen können. Einige APIs wie viewModelScope
nutzen jedoch im Hintergrund einen hartcodierten Main
Disponenten.
Hier ein Beispiel für eine ViewModel
-Implementierung, die viewModelScope
verwendet, um eine Koroutine zu starten, die Daten lädt:
class HomeViewModel : ViewModel() { private val _message = MutableStateFlow("") val message: StateFlow<String> get() = _message fun loadMessage() { viewModelScope.launch { _message.value = "Greetings!" } } }
Verwenden Sie die Funktionen Dispatchers.setMain
und Dispatchers.resetMain
, um den Disponenten Main
in allen Fällen durch TestDispatcher
zu ersetzen.
class HomeViewModelTest { @Test fun settingMainDispatcher() = runTest { val testDispatcher = UnconfinedTestDispatcher(testScheduler) Dispatchers.setMain(testDispatcher) try { val viewModel = HomeViewModel() viewModel.loadMessage() // Uses testDispatcher, runs its coroutine eagerly assertEquals("Greetings!", viewModel.message.value) } finally { Dispatchers.resetMain() } } }
Wenn der Disponent Main
durch einen TestDispatcher
ersetzt wurde, verwenden alle neu erstellten TestDispatchers
-Disponenten automatisch den Planer des Disponenten Main
, einschließlich des von runTest
erstellten Disponenten StandardTestDispatcher
, wenn kein anderer Disponent an ihn übergeben wird.
So lässt sich leichter sicherstellen, dass während des Tests nur ein einzelner Planer verwendet wird. Damit dies funktioniert, müssen Sie nach dem Aufruf von Dispatchers.setMain
alle anderen TestDispatcher
-Instanzen erstellen.
Um eine Duplizierung des Codes zu vermeiden, der den Main
-Dispatcher in jedem Test ersetzt, lässt sich ein gängiges Muster in eine JUnit-Testregel extrahieren:
// Reusable JUnit4 TestRule to override the Main dispatcher class MainDispatcherRule( val testDispatcher: TestDispatcher = UnconfinedTestDispatcher(), ) : TestWatcher() { override fun starting(description: Description) { Dispatchers.setMain(testDispatcher) } override fun finished(description: Description) { Dispatchers.resetMain() } } class HomeViewModelTestUsingRule { @get:Rule val mainDispatcherRule = MainDispatcherRule() @Test fun settingMainDispatcher() = runTest { // Uses Main’s scheduler val viewModel = HomeViewModel() viewModel.loadMessage() assertEquals("Greetings!", viewModel.message.value) } }
Bei dieser Regelimplementierung wird standardmäßig ein UnconfinedTestDispatcher
verwendet, aber ein StandardTestDispatcher
kann als Parameter übergeben werden, wenn der Main
-Dispatcher in einer bestimmten Testklasse nicht motiviert ausführen soll.
Wenn Sie eine TestDispatcher
-Instanz im Testtext benötigen, können Sie die testDispatcher
aus der Regel wiederverwenden, solange der gewünschte Typ verwendet wird. Wenn du den im Test verwendeten TestDispatcher
-Typ explizit angeben möchtest oder ein TestDispatcher
benötigst, der nicht mit dem für Main
verwendeten Typ übereinstimmt, kannst du in runTest
eine neue TestDispatcher
erstellen. Da der Main
-Disponent auf TestDispatcher
gesetzt ist, gibt jeder neu erstellte TestDispatchers
-Disponent seinen Planer automatisch frei.
class DispatcherTypesTest { @get:Rule val mainDispatcherRule = MainDispatcherRule() @Test fun injectingTestDispatchers() = runTest { // Uses Main’s scheduler // Use the UnconfinedTestDispatcher from the Main dispatcher val unconfinedRepo = Repository(mainDispatcherRule.testDispatcher) // Create a new StandardTestDispatcher (uses Main’s scheduler) val standardRepo = Repository(StandardTestDispatcher()) } }
Disponenten außerhalb eines Tests erstellen
In einigen Fällen muss möglicherweise ein TestDispatcher
außerhalb der Testmethode verfügbar sein. Beispielsweise während der Initialisierung einer Property in der Testklasse:
class Repository(private val ioDispatcher: CoroutineDispatcher) { /* ... */ } class RepositoryTestWithRule { private val repository = Repository(/* What TestDispatcher? */) @get:Rule val mainDispatcherRule = MainDispatcherRule() @Test fun someRepositoryTest() = runTest { // Test the repository... // ... } }
Wenn Sie den Disponenten Main
wie im vorherigen Abschnitt beschrieben ersetzen, wird der Planer für TestDispatchers
, der nach dem Ersetzen des Disponenten Main
erstellt wurde, automatisch freigegeben.
Dies gilt jedoch nicht für TestDispatchers
, die als Attribute der Testklasse erstellt wurden, oder für TestDispatchers
, die während der Initialisierung von Attributen in der Testklasse erstellt wurden. Sie werden initialisiert, bevor der Disponent Main
ersetzt wird. Daher werden neue Planer erstellt.
Damit der Test nur einen Planer enthält, müssen Sie zuerst das Attribut MainDispatcherRule
erstellen. Verwenden Sie dann nach Bedarf seinen Disponenten (oder seinen Planer, falls Sie einen TestDispatcher
eines anderen Typs benötigen) in den Initialisierern anderer Eigenschaften auf Klassenebene.
class RepositoryTestWithRule { @get:Rule val mainDispatcherRule = MainDispatcherRule() private val repository = Repository(mainDispatcherRule.testDispatcher) @Test fun someRepositoryTest() = runTest { // Takes scheduler from Main // Any TestDispatcher created here also takes the scheduler from Main val newTestDispatcher = StandardTestDispatcher() // Test the repository... } }
Beachten Sie, dass sowohl runTest
als auch TestDispatchers
, die im Test erstellt wurden, automatisch den Planer des Disponenten Main
freigeben.
Wenn Sie den Disponenten Main
nicht ersetzen, erstellen Sie die erste TestDispatcher
(wodurch ein neuer Planer erstellt wird) als Eigenschaft der Klasse. Übergeben Sie diesen Planer dann manuell an jeden runTest
-Aufruf und jeden neu erstellten TestDispatcher
– sowohl als Attribute als auch innerhalb des Tests:
class RepositoryTest { // Creates the single test scheduler private val testDispatcher = UnconfinedTestDispatcher() private val repository = Repository(testDispatcher) @Test fun someRepositoryTest() = runTest(testDispatcher.scheduler) { // Take the scheduler from the TestScope val newTestDispatcher = UnconfinedTestDispatcher(this.testScheduler) // Or take the scheduler from the first dispatcher, they’re the same val anotherTestDispatcher = UnconfinedTestDispatcher(testDispatcher.scheduler) // Test the repository... } }
In diesem Beispiel wird der Planer vom ersten Dispatcher an runTest
übergeben. Dadurch wird mit diesem Planer eine neue StandardTestDispatcher
für die TestScope
erstellt. Sie können den Disponenten auch direkt an runTest
übergeben, um die Testkoroutine für diesen Disponenten auszuführen.
Eigenen TestScope erstellen
Wie bei TestDispatchers
müssen Sie möglicherweise außerhalb des Testtexts auf ein TestScope
zugreifen. runTest
erstellt automatisch eine „TestScope
“ im Hintergrund. Du kannst aber auch deine eigene TestScope
zur Verwendung mit runTest
erstellen.
Achten Sie dabei darauf, runTest
für die TestScope
aufzurufen, die Sie erstellt haben:
class SimpleExampleTest { val testScope = TestScope() // Creates a StandardTestDispatcher @Test fun someTest() = testScope.runTest { // ... } }
Mit dem Code oben werden implizit ein StandardTestDispatcher
und ein neuer Planer für die TestScope
erstellt. Diese Objekte können auch alle explizit erstellt werden. Dies kann nützlich sein, wenn Sie Abhängigkeitsinjektionskonfigurationen einbinden müssen.
class ExampleTest { val testScheduler = TestCoroutineScheduler() val testDispatcher = StandardTestDispatcher(testScheduler) val testScope = TestScope(testDispatcher) @Test fun someTest() = testScope.runTest { // ... } }
Bereich einfügen
Wenn Sie eine Klasse haben, die Koroutinen erstellt, die Sie während Tests steuern müssen, können Sie einen Koroutinenbereich in diese Klasse injizieren und in Tests durch einen TestScope
ersetzen.
Im folgenden Beispiel hängt die Klasse UserState
von einem UserRepository
ab, um neue Nutzer zu registrieren und die Liste der registrierten Nutzer abzurufen. Da diese Aufrufe von UserRepository
Funktionsaufrufe unterbrechen, verwendet UserState
den eingefügten CoroutineScope
-Wert, um eine neue Koroutine innerhalb der Funktion registerUser
zu starten.
class UserState( private val userRepository: UserRepository, private val scope: CoroutineScope, ) { private val _users = MutableStateFlow(emptyList<String>()) val users: StateFlow<List<String>> = _users.asStateFlow() fun registerUser(name: String) { scope.launch { userRepository.register(name) _users.update { userRepository.getAllUsers() } } } }
Um diese Klasse zu testen, können Sie beim Erstellen des UserState
-Objekts den TestScope
aus runTest
übergeben:
class UserStateTest { @Test fun addUserTest() = runTest { // this: TestScope val repository = FakeUserRepository() val userState = UserState(repository, scope = this) userState.registerUser("Mona") advanceUntilIdle() // Let the coroutine complete and changes propagate assertEquals(listOf("Mona"), userState.users.value) } }
Informationen zum Einfügen eines Bereichs außerhalb der Testfunktion, z. B. in ein zu testendes Objekt, das als Attribut in der Testklasse erstellt wird, finden Sie unter Eigenen TestScope erstellen.