Kod do testowania jednostkowego, który używa współrzędnych, wymaga dodatkowej uwagi, ponieważ jego wykonanie może być asynchroniczne i być wykonywane w wielu wątkach. W tym przewodniku omawiamy sposób testowania funkcji zawieszania, konstrukcje testowe, które musisz znać, i sposób testowania kodu korzystającego z współrzędnych.
Interfejsy API używane w tym przewodniku wchodzą w skład biblioteki kotlinx.coroutines.test. Pamiętaj, aby dodać artefakt jako zależność testową do projektu, aby uzyskać dostęp do tych interfejsów API.
dependencies {
testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:$coroutines_version"
}
Wywoływanie funkcji zawieszania w testach
Aby wywoływać funkcje zawieszania w testach, musisz działać w współudziale. Ponieważ funkcje testowe JUnit nie zawieszają funkcji, musisz wywołać w testach konstruktor współprogramów, aby uruchomić nową współpracę.
runTest
to narzędzie do tworzenia współpracowników przeznaczone do testowania. Służy do pakowania wszystkich testów, w których są współrzędne. Pamiętaj, że współrzędne można uruchamiać nie tylko bezpośrednio w treści testowej, ale też przez obiekty używane w teście.
suspend fun fetchData(): String { delay(1000L) return "Hello world" } @Test fun dataShouldBeHelloWorld() = runTest { val data = fetchData() assertEquals("Hello world", data) }
Ogólnie zalecamy jedno wywołanie funkcji runTest
na test i zalecamy używanie treści wyrażenia.
Opakowanie kodu testu w środowisku runTest
sprawdzi się podczas testowania podstawowych funkcji zawieszania, a wszystkie opóźnienia w współrzędnych zostaną automatycznie pominięte, dzięki czemu powyższy test zakończy się znacznie szybciej niż jedna sekunda.
Musisz jednak pamiętać o dodatkowych kwestiach, które zależą od tego, co dzieje się w testowanym kodzie:
- Gdy Twój kod tworzy nowe współrzędne inne niż współrzędna testowa najwyższego poziomu utworzona przez
runTest
, musisz kontrolować sposób ich planowania, wybierając odpowiednieTestDispatcher
. - Jeśli Twój kod przeniesie to samo wykonanie do innych dyspozytorów (na przykład za pomocą
withContext
),runTest
nadal będzie działać, ale opóźnienia nie będą już pomijane, a testy będą mniej przewidywalne, gdy kod będzie uruchamiany w wielu wątkach. Dlatego podczas testów należy wstrzyknąć dyspozytorów testowych, aby zastąpić prawdziwych dyspozytorów.
Dyspozytorzy testów
TestDispatchers
to implementacje CoroutineDispatcher
do celów testowych. Jeśli w trakcie testu zostaną utworzone nowe współrzędne, musisz użyć interfejsu TestDispatchers
, aby zapewnić przewidywalność ich wykonania.
Dostępne są 2 implementacje funkcji TestDispatcher
: StandardTestDispatcher
i UnconfinedTestDispatcher
, które wykonują różne harmonogramy nowo uruchomionych współprogramów. Wykorzystują one TestCoroutineScheduler
do kontrolowania czasu wirtualnego i zarządzania uruchomionymi współrzędnymi w ramach testu.
W teście powinna być używana tylko jedna instancja algorytmu szeregowania, używana przez wszystkie zasoby TestDispatchers
. Więcej informacji o udostępnianiu algorytmów szeregowania znajdziesz w artykule Wstrzykiwanie algorytmów TestDispatchers.
Aby uruchomić korytę testową najwyższego poziomu, runTest
tworzy obiekt TestScope
, który jest implementacją CoroutineScope
, która zawsze używa TestDispatcher
. Jeśli nie podasz żadnej wartości, TestScope
domyślnie utworzy StandardTestDispatcher
i użyje go do uruchomienia współrzędu testowego najwyższego poziomu.
runTest
śledzi współrzędne, które znajdują się w kolejce w algorytmie szeregowania używanym przez dyspozytora instancji TestScope
, i nie wraca, dopóki są oczekujące prace nad tym algorytmem szeregowania.
StandardDispatcher Testów
Gdy uruchamiasz nowe współrzędne w StandardTestDispatcher
, są one umieszczane w kolejce w alternatywnym algorytmie szeregowania, dzięki czemu są uruchamiane zawsze, gdy wątek testowy jest dowolny. Aby uruchomić te nowe współrzędne, musisz przekazać wątek testowy (zwolnij go na potrzeby innych współrzędnych). Ten sposób dodawania do kolejki daje precyzyjną kontrolę nad tym, jak nowe współrzędne są uruchamiane podczas testu. Przypomina ono planowanie współrzędnych w kodzie produkcyjnym.
Jeśli wątek testowy nigdy nie jest zwracany podczas wykonywania współrzędu testowego najwyższego poziomu, wszelkie nowe współprogramy będą uruchamiane dopiero po zakończeniu współrzędu testowego (ale przed powrotem funkcji runTest
):
@Test fun standardTest() = runTest { val userRepo = UserRepository() launch { userRepo.register("Alice") } launch { userRepo.register("Bob") } assertEquals(listOf("Alice", "Bob"), userRepo.getAllUsers()) // ❌ Fails }
Istnieje kilka sposobów uzyskania współrzędu testowego umożliwiającego działanie współrzędnych oczekujących w kolejce. Wszystkie te wywołania pozwalają innym współrzędnym na uruchomienie w wątku testowym, zanim zwrócą odpowiedź:
advanceUntilIdle
: uruchamia wszystkie inne współprogramy w algorytmie szeregowania, aż w kolejce nie ma nic. Jest to dobry wybór domyślny, który pozwala uruchamiać wszystkie oczekujące współudziały. Działa w większości scenariuszy testowych.advanceTimeBy
: przyspiesza czas wirtualny o podaną ilość i uruchamia wszystkie współprogramy, które mają zostać uruchomione przed tym momentem w czasie wirtualnym.runCurrent
: uruchamia współrzędne zaplanowane w bieżącym czasie wirtualnym.
Aby naprawić poprzedni test, można za pomocą funkcji advanceUntilIdle
pozwolić, by 2 oczekujące współprogramy wykonały pracę, zanim przejdziesz do potwierdzenia:
@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
Gdy w UnconfinedTestDispatcher
są uruchamiane nowe współprogramy, są one szybko uruchamiane w bieżącym wątku. Oznacza to, że zostaną one uruchomione natychmiast, bez czekania na powrót kreatora reguł. W wielu przypadkach taki sposób wysyłania powoduje prostszy kod testowy, ponieważ nie trzeba ręcznie generować wątku testowego, aby uruchomić nowe współrzędne.
Różni się to jednak od tego, co zauważysz w wersji produkcyjnej w przypadku dyspozytorów innych firm. Jeśli test skupia się na równoczesności, lepiej jest używać obiektu StandardTestDispatcher
.
Aby użyć tego dyspozytora na potrzeby kogutyny testowej najwyższego poziomu w runTest
zamiast domyślnej, utwórz instancję i przekaż ją jako parametr. Spowoduje to, że nowe współrzędne utworzone w zasadzie runTest
zostaną uruchomione z zaangażowaniem, ponieważ dziedziczą one dyspozytora z elementu TestScope
.
@Test fun unconfinedTest() = runTest(UnconfinedTestDispatcher()) { val userRepo = UserRepository() launch { userRepo.register("Alice") } launch { userRepo.register("Bob") } assertEquals(listOf("Alice", "Bob"), userRepo.getAllUsers()) // ✅ Passes }
W tym przykładzie wywołania startowe inicjują swoje nowe współprogramy w dniu UnconfinedTestDispatcher
, co oznacza, że każde wywołanie jest powtórzone dopiero po zakończeniu rejestracji.
Pamiętaj, że UnconfinedTestDispatcher
z zapałem uruchamia nowe współprogramy, ale to nie znaczy, że będzie je także z chęcią dokończyć. Jeśli nowy współprogram zostanie zawieszony, pozostałe współrzędne zostaną wznowione.
Na przykład nowa współrzędna uruchomiona w ramach tego testu spowoduje zarejestrowanie Alicji, ale zostanie ona zawieszona po wywołaniu funkcji delay
. Dzięki temu współrzędna najwyższego poziomu może kontynuować asercję, a test kończy się niepowodzeniem, ponieważ Robert nie jest jeszcze zarejestrowany:
@Test fun yieldingTest() = runTest(UnconfinedTestDispatcher()) { val userRepo = UserRepository() launch { userRepo.register("Alice") delay(10L) userRepo.register("Bob") } assertEquals(listOf("Alice", "Bob"), userRepo.getAllUsers()) // ❌ Fails }
Dyspozytorzy testu wstrzykiwania
Testowany kod może korzystać z dyspozytorów do przełączania wątków (za pomocą: withContext
) lub uruchamiania nowych współprac. Gdy kod jest wykonywany równolegle w wielu wątkach, testy mogą nie działać prawidłowo. Wykonywanie asercji w odpowiednim czasie lub czekanie na zakończenie zadań, jeśli są one uruchamiane w wątkach w tle, nad którymi nie masz kontroli, może być trudne.
W testach zastąp tych dyspozytorów instancjami TestDispatchers
. Ma to kilka korzyści:
- Kod będzie działać w pojedynczym wątku testowym, dzięki czemu testy będą bardziej deterministyczne
- Możesz kontrolować sposób planowania i wykonywania nowych współudziałów
- Dyspozytorzy testów korzystają z algorytmu szeregowania w przypadku czasu wirtualnego, który automatycznie pomija opóźnienia i umożliwia ręczne przesunięcie czasu.
Stosuję wstrzykiwanie zależności, aby dostarczyć
Dyspozytorzy na Twoich zajęciach ułatwiają zastępowanie prawdziwych dyspozytorów
testów. W tych przykładach wstrzykujemy element CoroutineDispatcher
, ale możesz też
wstrzyknij szerszą grupę
CoroutineContext
.
co daje większą elastyczność podczas testów.
W przypadku klas, które uruchamiają współprogramy, możesz też wstrzyknąć CoroutineScope
zamiast dyspozytora, jak opisano w sekcji Wstrzyknięcie zakresu.
.
Domyślnie TestDispatchers
tworzy nowy algorytm szeregowania podczas tworzenia instancji. W usłudze runTest
możesz uzyskać dostęp do właściwości testScheduler
elementu TestScope
i przekazać ją do każdego nowo utworzonego elementu TestDispatchers
. Dzięki temu będą mogli podzielić się wiedzą na temat czasu wirtualnego, a metody takie jak advanceUntilIdle
będą do końca uruchamiać współdziałanie wszystkich dyspozytorów testów.
W poniższym przykładzie widać klasę Repository
, która tworzy nową współrzędną przy użyciu dyspozytora IO
w metodzie initialize
i przełącza element wywołujący na dyspozytora IO
w metodzie fetchData
:
// 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" } }
W testach możesz wstrzyknąć implementację TestDispatcher
, aby zastąpić dyspozytora IO
.
W poniższym przykładzie wstrzykujemy obiekt StandardTestDispatcher
do repozytorium i używamy metody advanceUntilIdle
, aby przed kontynuowaniem upewnić się, że nowa współrzędna uruchomiona w tym miejscu: initialize
.
Działanie fetchData
będzie korzystne również na podstawie reguły TestDispatcher
, ponieważ będzie działać w wątku testowym z pominięciem opóźnienia w trakcie testu.
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) } }
Nowe współprogramy uruchomione w elemencie TestDispatcher
można ulepszać ręcznie, jak pokazano powyżej w zasadzie initialize
. Pamiętaj jednak, że nie jest to możliwe lub pożądane w kodzie produkcyjnym. Należy przeprojektować tę metodę, tak aby była zawieszana (w przypadku wykonywania sekwencyjnego) lub zwracała wartość Deferred
(w przypadku wykonywania równoczesnego).
Możesz na przykład użyć narzędzia async
, aby rozpocząć nową współudział i utworzyć Deferred
:
class BetterRepository(private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO) { private val scope = CoroutineScope(ioDispatcher) fun initialize() = scope.async { // ... } }
Pozwoli Ci to bezpiecznie await
dokończyć uzupełnienie kodu zarówno w testach, jak i w kodzie produkcyjnym:
@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
poczeka na zakończenie oczekujących współudziałów, zanim zwróci procedurę, jeśli są one na urządzeniu TestDispatcher
, z którym współużytkuje algorytm szeregowania. Będzie on również czekać na współudziały podrzędne koutyny testowej najwyższego poziomu, nawet jeśli są one przypisane do innych dyspozytorów (maksymalnie po czasie oczekiwania określonym przez parametr dispatchTimeoutMs
, który domyślnie wynosi 60 sekund).
Ustawianie głównego dyspozytora
W testach lokalnych jednostek dyspozytor Main
, który opakowuje wątek interfejsu Androida, będzie niedostępny, ponieważ testy są wykonywane na lokalnej maszynie wirtualnej, a nie na urządzeniu z Androidem. Jeśli testowany kod odwołuje się do wątku głównego, podczas testów jednostkowych zostanie zgłoszony wyjątek.
W niektórych przypadkach możesz wstrzyknąć dyspozytora Main
w taki sam sposób jak u innych dyspozytorów, co opisano w poprzedniej sekcji, co pozwoli Ci w testach zastąpić go kodem TestDispatcher
. Jednak niektóre interfejsy API, takie jak viewModelScope
, korzystają z zakodowanego na stałe dyspozytora Main
.
Oto przykład implementacji ViewModel
, w której użyto viewModelScope
do uruchomienia współrzędu ładującego dane:
class HomeViewModel : ViewModel() { private val _message = MutableStateFlow("") val message: StateFlow<String> get() = _message fun loadMessage() { viewModelScope.launch { _message.value = "Greetings!" } } }
Aby we wszystkich przypadkach zastąpić dyspozytora Main
elementem TestDispatcher
, użyj funkcji Dispatchers.setMain
i Dispatchers.resetMain
.
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() } } }
Jeśli dyspozytor Main
został zastąpiony przez TestDispatcher
, nowo utworzona TestDispatchers
będzie automatycznie używać algorytmu szeregowania z dyspozytora Main
, w tym StandardTestDispatcher
utworzonego przez runTest
, jeśli nie zostanie do niego przekazany żaden inny dyspozytor.
Dzięki temu łatwiej jest zadbać o to, aby podczas testu był używany tylko jeden algorytm szeregowania. Aby to działało, pamiętaj, aby utworzyć wszystkie pozostałe instancje TestDispatcher
po wywołaniu funkcji Dispatchers.setMain
.
Typowym wzorcem pozwalającym uniknąć duplikowania kodu zastępującego dyspozytora Main
w każdym teście jest wyodrębnienie go do reguły testowej JUnit:
// 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) } }
Ta implementacja reguły domyślnie korzysta z UnconfinedTestDispatcher
, ale StandardTestDispatcher
może zostać przekazany jako parametr, jeśli dyspozytor Main
nie powinien wykonać bezprawnie w danej klasie testowej.
Jeśli potrzebujesz wystąpienia TestDispatcher
w treści testowej, możesz ponownie użyć atrybutu testDispatcher
z reguły, o ile jest to odpowiedni typ. Jeśli chcesz wyraźnie określić typ obiektu TestDispatcher
używanego w teście lub jeśli potrzebujesz TestDispatcher
innego typu niż używany w przypadku Main
, możesz utworzyć nowy TestDispatcher
w usłudze runTest
. Ponieważ dyspozytor Main
jest ustawiony na TestDispatcher
, każdy nowo utworzony TestDispatchers
będzie automatycznie udostępniać swój algorytm szeregowania.
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()) } }
Tworzenie dyspozytorów poza testem
W niektórych przypadkach poza metodą testową może być potrzebny TestDispatcher
. Na przykład podczas inicjowania właściwości w klasie testowej:
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... // ... } }
Jeśli zastępujesz dyspozytora Main
w sposób pokazany w poprzedniej sekcji, TestDispatchers
utworzony po zastąpieniu dyspozytora Main
automatycznie udostępni swój algorytm szeregowania.
Nie dotyczy to jednak obiektu TestDispatchers
utworzonego jako właściwości klasy testowej lub obiektu TestDispatchers
utworzonego podczas inicjowania właściwości w klasie testowej. Są one inicjowane przed zastąpieniem dyspozytora Main
. W związku z tym tworzy nowe algorytmy szeregowania.
Aby upewnić się, że w teście występuje tylko 1 algorytm szeregowania, najpierw utwórz właściwość MainDispatcherRule
. Następnie użyj ponownie jego dyspozytora (lub algorytmu szeregowania, jeśli potrzebujesz obiektu TestDispatcher
innego typu) w inicjatorach innych właściwości na poziomie klasy.
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... } }
Pamiętaj, że zarówno runTest
, jak i TestDispatchers
utworzone w teście nadal będą automatycznie korzystać z funkcji algorytmu szeregowania dyspozytora Main
.
Jeśli nie zastępujesz dyspozytora Main
, utwórz pierwszą właściwość TestDispatcher
(co spowoduje utworzenie nowego algorytmu szeregowania) jako właściwość klasy. Następnie ręcznie przekazuj ten algorytm szeregowania do każdego wywołania runTest
i każdego nowo utworzonego elementu TestDispatcher
zarówno jako właściwości, jak i w teście:
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... } }
W tym przykładzie algorytm szeregowania z pierwszego dyspozytora jest przekazywany do runTest
. Spowoduje to utworzenie nowego StandardTestDispatcher
dla obiektu TestScope
korzystającego z tego algorytmu szeregowania. Możesz też przekazać dyspozytora bezpośrednio do runTest
, aby uruchomić u tego dyspozytora cykl testowy.
Tworzenie własnego zakresu TestScope
Tak jak w przypadku usługi TestDispatchers
, może być konieczne uzyskanie dostępu do TestScope
poza treścią testową. runTest
automatycznie tworzy TestScope
dla zaawansowanych, ale możesz też utworzyć własny element TestScope
do użycia z runTest
.
Podczas wykonywania tej czynności pamiętaj, by zadzwonić do użytkownika runTest
w utworzonym przez Ciebie TestScope
:
class SimpleExampleTest { val testScope = TestScope() // Creates a StandardTestDispatcher @Test fun someTest() = testScope.runTest { // ... } }
Powyższy kod tworzy domyślnie StandardTestDispatcher
dla obiektu TestScope
, a także nowy algorytm szeregowania. Wszystkie te obiekty można też utworzyć bezpośrednio. Może to być przydatne, jeśli musisz zintegrować je z konfiguracjami wstrzykiwania zależności.
class ExampleTest { val testScheduler = TestCoroutineScheduler() val testDispatcher = StandardTestDispatcher(testScheduler) val testScope = TestScope(testDispatcher) @Test fun someTest() = testScope.runTest { // ... } }
Wstrzykiwanie zakresu
Jeśli masz klasę, która tworzy współprogramy, którymi musisz sterować podczas
testów, możesz wstrzyknąć do tej klasy zakres współrzędny, zastępując go
TestScope
w testach.
W poniższym przykładzie klasa UserState
zależy od obiektu UserRepository
do rejestrowania nowych użytkowników
i pobierania listy zarejestrowanych użytkowników. Ponieważ te połączenia
do UserRepository
to zawieszanie wywołań funkcji, UserState
używa wstrzykniętego parametru
CoroutineScope
, aby uruchomić nową współudział w funkcji registerUser
.
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() } } } }
Aby przetestować te zajęcia, możesz zdać TestScope
z runTest
podczas tworzenia
obiekt UserState
:
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) } }
Aby wstawić zakres poza funkcją testową, na przykład do obiektu w utworzonym jako właściwość w klasie testowej, zobacz Tworzenie własnego obiektu TestScope