Testowanie kohort Kotlin na Androidzie

Stosowanie kodu testowania jednostkowego, który wykorzystuje korekty, wymaga dodatkowej uwagi, ponieważ ich wykonywanie może być asynchroniczne i następować w wielu wątkach. Z tego przewodnika dowiesz się, jak testować funkcje zawieszania, tworzyć konstrukcje testowe, które musisz znać, oraz jak sprawić, by kod wykorzystujący współprogramy dało się przetestować.

Interfejsy API używane w tym przewodniku są częścią biblioteki kotlinx.coroutines.test. Aby mieć dostęp do tych interfejsów API, dodaj artefakt jako zależność testową do projektu.

dependencies {
    testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:$coroutines_version"
}

Wywoływanie funkcji zawieszania w testach

Aby wywołać w testach funkcje zawieszania, musisz korzystać z współprogramowania. Ponieważ funkcje testowe JUnit nie zawieszają funkcji, musisz wywołać w testach konstruktor współprogramów, aby uruchomić nową instancję.

runTest to konstruktor współgratów przeznaczony do testowania. Użyj go, aby opakować wszystkie testy zawierające współprogramy. Pamiętaj, że współprogramy można uruchamiać nie tylko bezpośrednio w treści testu, ale także 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 na każdy test powinno być jedno wywołanie runTest. Zalecamy też używanie treści wyrażenia.

Umieść kod testu w komponencie runTest, aby przetestować podstawowe funkcje zawieszania i automatycznie pominąć wszelkie opóźnienia w korutynach. Dzięki temu powyższy test zakończy się znacznie szybciej niż jedna sekunda.

W zależności od tego, co dzieje się w testowanym kodzie, trzeba jednak wziąć pod uwagę dodatkowe kwestie:

  • Gdy Twój kod tworzy nowe współprogramy inne niż najwyższego poziomu współprogramy (runTest), musisz kontrolować sposób ich planowania, wybierając odpowiednią TestDispatcher.
  • Jeśli Twój kod przeniesie wykonanie współpracy do innych dyspozytorów (np. za pomocą withContext), runTest nadal będzie działać, ale opóźnienia nie będą już pomijane, a testy będą mniej przewidywalne, ponieważ kod jest uruchamiany w wielu wątkach. Z tego powodu podczas testów należy wstrzykiwać dyspozytorów testowych, aby zastąpić prawdziwych dyspozytorów.

Dyspozytorzy testów

TestDispatchers to implementacje CoroutineDispatcher do celów testowych. Jeśli podczas testu będą tworzone nowe współprogramy, musisz użyć polecenia TestDispatchers, aby zapewnić przewidywalność ich wykonania.

Dostępne są 2 implementacje interfejsu TestDispatcher: StandardTestDispatcher i UnconfinedTestDispatcher, które wykorzystują różne harmonogramy nowo uruchomionych współprogramów. Wykorzystują one TestCoroutineScheduler do kontrolowania czasu wirtualnego i uruchamiania współprogramów w ramach testu.

W teście powinna być używana tylko 1 instancja algorytmu szeregowania wspólna dla wszystkich instancji TestDispatchers. Więcej informacji o udostępnianiu algorytmów szeregowania znajdziesz w sekcji Wstrzykiwanie obiektów TestDispatchers.

Aby uruchomić aplikację testową najwyższego poziomu, runTest tworzy element TestScope, który jest implementacją kodu CoroutineScope, która zawsze używa polecenia TestDispatcher. Jeśli nie podasz żadnej wartości, TestScope domyślnie utworzy regułę StandardTestDispatcher i użyje jej do uruchomienia współpracy testowej najwyższego poziomu.

runTest śledzi współprogramy znajdujące się w kolejce w algorytmie szeregowania, z którego korzysta dyspozytor TestScope, i nie są zwracane, dopóki istnieją oczekujące prace związane z tym algorytmem szeregowania.

Standardowy DyspozytorTestów

Gdy uruchomisz nowe współprogramy w StandardTestDispatcher, zostaną one umieszczone w kolejce w bazowym algorytmie szeregowania, które będą uruchamiane za każdym razem, gdy wątek testowy jest dostępny. Aby uruchomić te nowe współprogramy, musisz wygenerować wątek testowy (zwolnić go, aby mogły korzystać z innych współprogramów). To zachowanie związane z kolejkowaniem daje precyzyjną kontrolę nad uruchamianiem nowych współprogramów podczas testu i przypomina planowanie koordynacji w kodzie produkcyjnym.

Jeśli wątek testowy nie zostanie wywołany podczas wykonywania współpracy testowej najwyższego poziomu, nowe współprogramy zostaną uruchomione dopiero po zakończeniu współużytkowania (ale przed zwróceniem kodu 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 na uruchomienie współużytkowniczki testowej w celu umożliwienia uruchamiania współprogramów oczekujących w kolejce. Wszystkie te wywołania pozwalają innym koordynatorom uruchomić je w wątku testowym przed zwróceniem:

  • advanceUntilIdle: uruchamia wszystkie pozostałe współprogramy w algorytmie szeregowania, aż w kolejce nie ma już niczego. Jest to dobry wybór domyślny, który pozwala na uruchamianie wszystkich oczekujących współprogramów. Działa w większości scenariuszy testowych.
  • advanceTimeBy: wydłuża czas wirtualny o podaną ilość i uruchamia wszystkie współprogramy, które mają zostać uruchomione przed tym punktem.
  • runCurrent: uruchamia współprogramy zaplanowane w bieżącym czasie wirtualnym.

Aby naprawić poprzedni test, przed przejściem do asercji 2 oczekujące korepetycje powinny wykonać działanie przy użyciu advanceUntilIdle:

@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
}

Nieograniczony dyspozytor testu

Gdy w projekcie UnconfinedTestDispatcher uruchamiane są nowe współprogramy, z niecierpliwością zaczynają one pracę w bieżącym wątku. Oznacza to, że zostaną one uruchomione natychmiast, bez czekania na zwrot kreatora. W wielu przypadkach taki sposób wysyłania skutkuje prostszym kodem testowym, ponieważ nie trzeba ręcznie generować wątku testowego, aby uruchomić nowe współprogramy.

Jest to jednak działanie inne niż w przypadku wersji produkcyjnej przez dyspozytorów, którzy nie przeprowadzają testów. Jeśli test skupia się na równoczesności, zamiast tego używaj zasady StandardTestDispatcher.

Aby użyć tego dyspozytora na potrzebykoordynacji testowej najwyższego poziomu w runTest zamiast domyślnej, utwórz instancję i przekaż ją jako parametr. Spowoduje to sprawne wykonywanie nowych współprac w obrębie runTest, ponieważ odziedziczą one dyspozytora z komponentu 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 uruchamiania nowych klientów z niecierpliwością rozpoczynają się UnconfinedTestDispatcher, co oznacza, że każde wywołanie uruchomione po zakończeniu rejestracji wróci dopiero po zakończeniu rejestracji.

Pamiętaj, że UnconfinedTestDispatcher z przyjemnością inicjuje nowe programy, ale nie oznacza to, że z niecierpliwością czeka na ich realizację. Jeśli nowa współużytkowniczka zostanie zawieszona, wykonywanie jej innych zadań zostanie wznowione.

Na przykład nowa współpraca uruchomiona w ramach tego testu zarejestruje Alicję, ale zostanie zawieszona po wywołaniu delay. Pozwoli to współtwórcy najwyższego poziomu kontynuować asercję, a test zakoń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
}

Wstrzykiwanie dyspozytorów testów

Testowany kod może używać 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ą być niestabilne. Wykonywanie asercji we właściwym czasie lub czekanie na ich zakończenie może być trudne, jeśli są one uruchomione w wątkach w tle, nad którymi nie masz kontroli.

W testach zamień tych dyspozytorów na wystąpienia TestDispatchers. Ma to kilka zalet:

  • Kod zostanie uruchomiony w pojedynczym wątku testowym, co sprawi, że testy będą bardziej deterministyczne
  • Możesz kontrolować sposób planowania i wykonywania nowych współprac
  • TestDispatcher używa algorytmu szeregowania dla czasu wirtualnego, który automatycznie pomija opóźnienia i umożliwia ręczne przesunięcie

Udostępnienie dyspozytorów do klas przy użyciu funkcji wstrzykiwania zależności ułatwia zastąpienie prawdziwych dyspozytorów w testach. W tych przykładach wstawiamy CoroutineDispatcher, ale możesz też wstawić szerszy typ CoroutineContext, co zapewnia jeszcze 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 Wstrzykiwanie zakresu.

Domyślnie TestDispatchers utworzy nowy algorytm szeregowania po utworzeniu jego instancji. W usłudze runTest możesz uzyskać dostęp do właściwości testScheduler obiektu TestScope i przekazać ją do każdej nowo utworzonej właściwości TestDispatchers. W ten sposób udostępnią wiedzę na temat czasu wirtualnego, a metody takie jak advanceUntilIdle uruchomią współprogramy u wszystkich dyspozytorów testów.

W poniższym przykładzie widać klasę Repository, która tworzy nową współpracę przy użyciu dyspozytora IO w metodzie initialize i przełącza obiekt wywołujący na dyspozytora IO w swojej 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 wstrzyknęliśmy w repozytorium StandardTestDispatcher i użyliśmy polecenia advanceUntilIdle, aby przed kontynuacją kończyć nową współpracę uruchomioną w initialize.

fetchData Użycie tagu TestDispatcher skorzysta również na wątku testowym z pominięciem opóźnienia zawartego w teście.

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 projekcie TestDispatcher można zaawansowane ręcznie zaawansowane jak w przypadku polecenia initialize powyżej. Pamiętaj jednak, że nie jest to możliwe ani pożądane w kodzie produkcyjnym. Zamiast tego należy zmodyfikować tę metodę tak, aby była zawieszana (w przypadku wykonywania sekwencyjnego) lub zwracała wartość Deferred (w przypadku jednoczesnego wykonywania).

Za pomocą polecenia async możesz na przykład uruchomić nową współpracę i utworzyć Deferred:

class BetterRepository(private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO) {
    private val scope = CoroutineScope(ioDispatcher)

    fun initialize() = scope.async {
        // ...
    }
}

Dzięki temu możesz bezpiecznie await ukończyć ten kod 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 zaczeka na zakończenie oczekujących korekt przed zwróceniem, jeśli są one na urządzeniu TestDispatcher, z którym współużytkuje algorytm szeregowania. Będzie też czekać na współprogramy podrzędne wobec współrzędnej testowej, nawet jeśli pracują w innych dyspozytorach (do limitu czasu określonego przez parametr dispatchTimeoutMs, który domyślnie wynosi 60 sekund).

Ustawianie głównego dyspozytora

W testach jednostek lokalnych dyspozytor Main, który pakuje wątek UI Androida, będzie niedostępny, ponieważ testy są wykonywane na lokalnej maszynie wirtualnej JVM, a nie na urządzeniu z Androidem. Jeśli testowany kod odwołuje się do wątku głównego, podczas testów jednostkowych zgłosi wyjątek.

W niektórych przypadkach możesz wstrzykiwać dyspozytora Main w taki sam sposób jak inni dyspozytorzy, co opisano w poprzedniej sekcji. Dzięki temu możesz zastąpić go dyspozytorem TestDispatcher w testach. Jednak niektóre interfejsy API, takie jak viewModelScope, używają wbudowanego na stałe dyspozytora Main.

Oto przykład implementacji ViewModel, która wykorzystuje viewModelScope do uruchamiania współpracy, która wczytuje 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, każdy nowo utworzony dyspozytor TestDispatchers automatycznie użyje algorytmu szeregowania od dyspozytora Main, w tym StandardTestDispatcher utworzonego przez runTest, jeśli nie zostanie do niego przekazany żaden inny dyspozytor.

Dzięki temu możesz mieć pewność, że podczas testu będzie używany tylko 1 algorytm szeregowania. Aby to zadziałało, utwórz wszystkie pozostałe instancje TestDispatcher po wywołaniu Dispatchers.setMain.

Typowym wzorcem zapobiegania duplikowaniu kodu, który w każdym teście zastępuje dyspozytora Main, 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)
    }
}

W tej implementacji reguły domyślnie używany jest UnconfinedTestDispatcher, ale parametr StandardTestDispatcher może zostać przekazany jako parametr, jeśli dyspozytor Main nie powinien wykonać polecenia w danej klasie testowej.

Jeśli potrzebujesz wystąpienia TestDispatcher w treści testowej, możesz ponownie użyć elementu testDispatcher z reguły, o ile ma on pożądany typ. Jeśli chcesz podać dokładne informacje o typie obiektu TestDispatcher używanego w teście lub jeśli potrzebujesz obiektu TestDispatcher innego niż używany w przypadku Main, możesz utworzyć nowy obiekt TestDispatcher w usłudze runTest. Gdy 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ą testowania może być potrzebny interfejs 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 jego algorytm szeregowania.

Inaczej jest jednak w przypadku elementów TestDispatchers utworzonych jako właściwości klasy testowej lub TestDispatchers utworzonych podczas inicjowania właściwości w klasie testowej. Te ustawienia są inicjowane przed zastąpieniem dyspozytora Main. W związku z tym będą tworzone nowe algorytmy szeregowania.

Aby upewnić się, że w teście jest tylko 1 algorytm szeregowania, najpierw utwórz właściwość MainDispatcherRule. Następnie ponownie wykorzystaj jego dyspozytora (lub algorytm szeregowania, jeśli potrzebujesz 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...
    }
}

Zwróć uwagę, że zarówno zasady runTest, jak i TestDispatchers utworzone w ramach testu będą automatycznie udostępniać algorytm szeregowania dyspozytora Main.

Jeśli nie zastępujesz dyspozytora Main, utwórz pierwsze TestDispatcher (co powoduje utworzenie nowego algorytmu szeregowania) jako właściwość klasy. Następnie ręcznie przekaż ten algorytm szeregowania do każdego wywołania runTest i każdego nowego utworzonego elementu TestDispatcher – zarówno jako właściwości, jak i w ramach testu:

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 obiektu StandardTestDispatcher dla: TestScope przy użyciu tego algorytmu szeregowania. Możesz też przekazać dyspozytora bezpośrednio do dystrybutora runTest, aby uruchomić u niego konfigurację testową.

Tworzenie własnego zakresu TestScope

Podobnie jak w przypadku TestDispatchers, może być konieczne uzyskanie dostępu do TestScope poza treścią testu. runTest automatycznie tworzy w tle zasób TestScope, ale możesz też utworzyć własny TestScope do użycia w runTest.

Pamiętaj, aby wywołać runTest na utworzonym urządzeniu TestScope:

class SimpleExampleTest {
    val testScope = TestScope() // Creates a StandardTestDispatcher

    @Test
    fun someTest() = testScope.runTest {
        // ...
    }
}

Powyższy kod domyślnie tworzy StandardTestDispatcher dla interfejsu TestScope, a także nowy algorytm szeregowania. Wszystkie te obiekty można również tworzyć bezpośrednio. Może to być przydatne, jeśli musisz zintegrować go 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óre musisz kontrolować podczas testów, możesz wstrzyknąć do niej zakres koordynacyjny, zastępując go w testach wartością TestScope.

W poniższym przykładzie klasa UserState zależy od klasy UserRepository, aby rejestrować nowych użytkowników i pobierać listę zarejestrowanych użytkowników. Ponieważ te wywołania UserRepository zawieszają wywołania funkcji, UserState używa wstrzykniętego CoroutineScope do uruchomienia nowej współpracy w ramach 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ć tę klasę, możesz przekazać TestScope z runTest podczas tworzenia obiektu 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 wstrzyknąć zakres poza funkcję testową, na przykład do testowanego obiektu, który został utworzony jako właściwość w klasie testowej, przeczytaj informacje o tworzeniu własnego zakresu TestScope.

Dodatkowe materiały