Test delle coroutine Kotlin su Android

Il codice per il test delle unità che utilizza le coroutine richiede un'ulteriore attenzione perché la sua esecuzione può essere asincrona e può avvenire su più thread. Questa guida illustra come testare le funzioni di sospensione, i costrutti di test che devi conoscere e come rendere testabile il codice che utilizza le coroutine.

Le API utilizzate in questa guida fanno parte della libreria kotlinx.coroutines.test. Assicurati di aggiungere l'artefatto come dipendenza di test al tuo progetto per avere accesso a queste API.

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

Richiamo delle funzioni di sospensione nei test

Per chiamare le funzioni di sospensione nei test, devi essere in una coroutine. Poiché le funzioni di test JUnit di per sé non stanno sospendendo le funzioni, devi chiamare un generatore di coroutine all'interno dei test per avviare una nuova coroutina.

runTest è uno strumento per la creazione di coroutine progettato per i test. Da utilizzare per eseguire un test che include le coroutine. Tieni presente che le coroutine possono essere avviate non solo direttamente nel corpo del test, ma anche dagli oggetti utilizzati nel test.

suspend fun fetchData(): String {
    delay(1000L)
    return "Hello world"
}

@Test
fun dataShouldBeHelloWorld() = runTest {
    val data = fetchData()
    assertEquals("Hello world", data)
}

In generale, dovresti avere una chiamata di runTest per test ed è consigliato utilizzare un corpo di espressione.

L'aggregazione del codice del test in runTest consente di testare le funzioni di sospensione di base ed evita automaticamente eventuali ritardi nelle coroutine, rendendo il test sopra completato molto più velocemente di un secondo.

Tuttavia, è necessario fare altre considerazioni, a seconda di ciò che accade nel codice sottoposto a test:

  • Quando il tuo codice crea nuove coroutine diverse dalla coroutine di test di primo livello creata da runTest, dovrai controllare il modo in cui vengono programmate le nuove coroutine scegliendo la TestDispatcher appropriata.
  • Se il tuo codice trasferisce l'esecuzione della coroutine ad altri committenti (ad esempio, utilizzando withContext), in genere runTest continuerà a funzionare, ma i ritardi non verranno più ignorati e i test saranno meno prevedibili poiché il codice viene eseguito su più thread. Per questi motivi, durante i test è necessario inserire committenti di test in modo che sostituiscano i veri committenti.

Distributori di prova

TestDispatchers sono CoroutineDispatcher implementazioni a scopo di test. Dovrai utilizzare TestDispatchers se durante il test vengono create nuove coroutine per rendere prevedibile l'esecuzione delle nuove coroutine.

Sono disponibili due implementazioni di TestDispatcher: StandardTestDispatcher e UnconfinedTestDispatcher, che eseguono programmazioni diverse delle coroutine appena iniziate. Entrambi utilizzano una TestCoroutineScheduler per controllare il tempo virtuale e gestire le coroutine durante un test.

In un test deve essere utilizzata una sola istanza scheduler, condivisa tra tutte le TestDispatchers. Per informazioni sulla condivisione degli scheduler, consulta Inserimento di TestDispatcher.

Per avviare la coroutine di test di primo livello, runTest crea una TestScope, ovvero un'implementazione di CoroutineScope che utilizza sempre un TestDispatcher. Se non specificato, TestScope creerà un StandardTestDispatcher per impostazione predefinita e lo utilizzerà per eseguire la coroutina di prova di primo livello.

runTest tiene traccia delle coroutine in coda sul programma di pianificazione utilizzato dal supervisore del proprio TestScope e non viene ripristinata finché ci sono lavori in sospeso sul programma di pianificazione.

Dispatcher di prova standard

Quando avvii nuove coroutine su un StandardTestDispatcher, queste vengono messe in coda sullo scheduler sottostante per essere eseguite ogni volta che il thread di test è libero. Per consentire l'esecuzione di queste nuove coroutine, devi ottenere il thread di prova (liberalo per l'uso da parte di altre coroutine). Questo comportamento di accodamento ti consente di controllare con precisione come vengono eseguite le nuove coroutine durante il test e ricorda la pianificazione delle coroutine nel codice di produzione.

Se il thread di test non viene mai restituito durante l'esecuzione della coroutina di test di primo livello, le eventuali nuove coroutine verranno eseguite solo dopo il completamento della coroutina di prova (ma prima del ritorno di runTest):

@Test
fun standardTest() = runTest {
    val userRepo = UserRepository()

    launch { userRepo.register("Alice") }
    launch { userRepo.register("Bob") }

    assertEquals(listOf("Alice", "Bob"), userRepo.getAllUsers()) // ❌ Fails
}

Esistono diversi modi per cedere la coroutine di prova in modo da far funzionare le coroutine in coda. Tutte queste chiamate consentono ad altre coroutine di eseguire il thread di test prima di tornare:

  • advanceUntilIdle: esegue tutte le altre coroutine sul programma di pianificazione fino a quando non ci sono più elementi in coda. Questa è una buona scelta predefinita per consentire l'esecuzione di tutte le coroutine in sospeso e funzionerà nella maggior parte degli scenari di test.
  • advanceTimeBy: avanza il tempo virtuale in base all'importo specificato ed esegue le coroutine programmate per essere eseguite prima di quel momento nel tempo virtuale.
  • runCurrent: esegue le coroutine pianificate all'ora virtuale corrente.

Per correggere il test precedente, è possibile utilizzare advanceUntilIdle per consentire alle due coroutine in attesa di eseguire il proprio lavoro prima di continuare con l'affermazione:

@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

Quando vengono avviate nuove coroutine su un UnconfinedTestDispatcher, queste vengono immediatamente avviate sul thread corrente. Ciò significa che inizieranno a correre immediatamente, senza aspettare il ritorno del generatore di coroutine. In molti casi, questo comportamento di invio risulta in un codice di test più semplice, in quanto non è necessario cedere manualmente il thread di test per consentire l'esecuzione di nuove coroutine.

Tuttavia, questo comportamento è diverso da quello che vedrai in produzione con committenti non di test. Se il test è incentrato sulla contemporaneità, preferisci utilizzare StandardTestDispatcher.

Per utilizzare questo supervisore per la coroutine di prova di primo livello in runTest al posto di quella predefinita, crea un'istanza e passala come parametro. In questo modo le nuove coroutine create all'interno di runTest verranno eseguite con grande entusiasmo, poiché ereditano il supervisore da TestScope.

@Test
fun unconfinedTest() = runTest(UnconfinedTestDispatcher()) {
    val userRepo = UserRepository()

    launch { userRepo.register("Alice") }
    launch { userRepo.register("Bob") }

    assertEquals(listOf("Alice", "Bob"), userRepo.getAllUsers()) // ✅ Passes
}

In questo esempio, le chiamate al lancio faranno partire con entusiasmo le nuove coroutine il UnconfinedTestDispatcher, il che significa che ogni chiamata al lancio tornerà pronta solo al termine della registrazione.

Ricorda che UnconfinedTestDispatcher inizia con entusiasmo le nuove coroutine, ma questo non significa che le eseguirà con impazienza anche fino al completamento. Se la nuova coroutine viene sospesa, le altre coroutine riprenderanno l'esecuzione.

Ad esempio, la nuova coroutine lanciata in questo test registra Alice, ma poi viene sospesa quando viene chiamato delay. In questo modo la coroutine di primo livello può procedere con l'asserzione e il test non riesce perché Roberto non è ancora registrato:

@Test
fun yieldingTest() = runTest(UnconfinedTestDispatcher()) {
    val userRepo = UserRepository()

    launch {
        userRepo.register("Alice")
        delay(10L)
        userRepo.register("Bob")
    }

    assertEquals(listOf("Alice", "Bob"), userRepo.getAllUsers()) // ❌ Fails
}

Inserimento dei committenti di test

Il codice sottoposto a test potrebbe utilizzare i committenti per cambiare thread (utilizzando withContext) o per avviare nuove coroutine. Quando il codice viene eseguito su più thread in parallelo, i test possono diventare irregolari. Può essere difficile eseguire le asserzioni al momento giusto o attendere il completamento delle attività se sono in esecuzione su thread in background su cui non hai alcun controllo.

Durante i test, sostituisci questi committenti con istanze di TestDispatchers. Questo approccio comporta diversi vantaggi:

  • Il codice verrà eseguito sul singolo thread di test, rendendo i test più deterministici
  • Puoi controllare il modo in cui vengono programmate ed eseguite le nuove coroutine
  • I TestDispatcher utilizzano un programma di pianificazione per il tempo virtuale, che evita automaticamente i ritardi e ti consente di avanzare manualmente

Usare l'inserimento di dipendenze per fornire i committenti delle classi rendono più facile sostituire i veri committenti test. In questi esempi inseriremo un valore CoroutineDispatcher, ma puoi anche inserisci il più ampio CoroutineContext il che consente una flessibilità ancora maggiore durante i test.

Per i corsi che iniziano le coroutine, puoi anche inserire un CoroutineScope anziché un supervisore, come descritto in Inserire un ambito .

Per impostazione predefinita, TestDispatchers creerà un nuovo scheduler quando viene creata un'istanza. All'interno di runTest, puoi accedere alla proprietà testScheduler di TestScope e trasmetterla a qualsiasi TestDispatchers appena creato. In questo modo, condivideranno la loro comprensione del tempo virtuale e metodi come advanceUntilIdle eseguiranno coroutine su tutti i committenti di test fino al loro completamento.

Nell'esempio seguente, puoi vedere un corso Repository che crea una nuova coroutine utilizzando il supervisore IO nel suo metodo initialize e passa il chiamante al supervisore IO nel suo metodo 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"
    }
}

Durante i test, puoi inserire un'implementazione TestDispatcher per sostituire il supervisore IO.

Nell'esempio seguente, iniettiamo un StandardTestDispatcher nel repository e utilizziamo advanceUntilIdle per assicurarci che la nuova coroutine avviata in initialize venga completata prima di procedere.

fetchData trarrà vantaggio anche dall'esecuzione su un TestDispatcher, poiché verrà eseguito sul thread di test e ignorerà il ritardo che contiene durante il test.

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

Le nuove coroutine avviate con un TestDispatcher possono essere avanzate manualmente come mostrato sopra con initialize. Tieni presente, tuttavia, che ciò non sarebbe possibile o auspicabile nel codice di produzione. Questo metodo dovrebbe invece essere riprogettato in modo da essere sospeso (per l'esecuzione sequenziale) o restituire un valore Deferred (per l'esecuzione contemporanea).

Ad esempio, puoi utilizzare async per iniziare una nuova coroutine e creare una Deferred:

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

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

In questo modo puoi await in sicurezza il completamento di questo codice sia nei test che nel codice di produzione:

@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 attenderà il completamento delle coroutine in attesa prima di tornare se le coroutine si trovano su un TestDispatcher con cui condivide uno scheduler. Attenderà anche le coroutine figli della coroutine di primo livello, anche se si trovano su altri committenti (fino a un timeout specificato dal parametro dispatchTimeoutMs, che per impostazione predefinita è 60 secondi).

Impostazione del supervisore principale

Nei test delle unità locali, il supervisore Main che esegue il wrapping del thread della UI di Android non sarà disponibile, poiché questi test vengono eseguiti su una JVM locale e non su un dispositivo Android. Se il codice sottoposto a test fa riferimento al thread principale, verrà generata un'eccezione durante i test delle unità.

In alcuni casi, puoi inserire il supervisore Main nello stesso modo degli altri committenti, come descritto nella sezione precedente, in modo da sostituirlo con un valore TestDispatcher nei test. Tuttavia, alcune API, come viewModelScope, utilizzano in background un supervisore Main codificato.

Di seguito è riportato un esempio di implementazione di ViewModel che utilizza viewModelScope per avviare una coroutine che carica dati:

class HomeViewModel : ViewModel() {
    private val _message = MutableStateFlow("")
    val message: StateFlow<String> get() = _message

    fun loadMessage() {
        viewModelScope.launch {
            _message.value = "Greetings!"
        }
    }
}

Per sostituire in ogni caso il supervisore Main con un TestDispatcher, utilizza le funzioni Dispatchers.setMain e 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()
        }
    }
}

Se il supervisore Main è stato sostituito con un TestDispatcher, ogni TestDispatchers appena creato utilizzerà automaticamente il programma di pianificazione del supervisore Main, incluso il StandardTestDispatcher creato da runTest se non viene trasmesso nessun altro supervisore.

In questo modo è più facile garantire che sia in uso un solo scheduler durante il test. Affinché questo comando funzioni, assicurati di creare tutte le altre istanze TestDispatcher dopo aver chiamato Dispatchers.setMain.

Un metodo comune per evitare di duplicare il codice che sostituisce il supervisore Main in ogni test è estrarlo in una regola di test 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)
    }
}

Per impostazione predefinita, l'implementazione di questa regola utilizza un UnconfinedTestDispatcher, ma è possibile passare un StandardTestDispatcher come parametro se il supervisore Main non deve eseguire con entusiasmo una determinata classe di test.

Quando hai bisogno di un'istanza TestDispatcher nel corpo del test, puoi riutilizzare testDispatcher dalla regola, purché sia del tipo desiderato. Se vuoi essere esplicito in merito al tipo di TestDispatcher utilizzato nel test o se hai bisogno di un TestDispatcher di tipo diverso da quello utilizzato per Main, puoi creare un nuovo TestDispatcher all'interno di runTest. Poiché il supervisore Main è impostato su un TestDispatcher, ogni TestDispatchers appena creato condividerà automaticamente il proprio programma di pianificazione.

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())
    }
}

Creazione di committenti al di fuori di un test

In alcuni casi, potrebbe essere necessario che un TestDispatcher sia disponibile al di fuori del metodo di test. Ad esempio, durante l'inizializzazione di una proprietà nella classe di test:

class ExampleRepository(private val ioDispatcher: CoroutineDispatcher) { /* ... */ }

class RepositoryTestWithRule {
    private val repository = ExampleRepository(/* What TestDispatcher? */)

    @get:Rule
    val mainDispatcherRule = MainDispatcherRule()

    @Test
    fun someRepositoryTest() = runTest {
        // Test the repository...
        // ...
    }
}

Se stai sostituendo il supervisore Main come mostrato nella sezione precedente, TestDispatchers creato dopo la sostituzione del supervisore Main condividerà automaticamente il proprio programma di pianificazione.

Tuttavia, non è così per TestDispatchers creato come proprietà della classe di test o per TestDispatchers creato durante l'inizializzazione delle proprietà nella classe di test. Questi vengono inizializzati prima che il supervisore Main venga sostituito. Pertanto, creeranno nuovi scheduler.

Per assicurarti che nel test sia presente un solo scheduler, crea prima la proprietà MainDispatcherRule. Quindi, riutilizza il relativo supervisore (o il relativo scheduler, se hai bisogno di un TestDispatcher di tipo diverso) negli inizializzatori di altre proprietà a livello di classe, se necessario.

class RepositoryTestWithRule {
    @get:Rule
    val mainDispatcherRule = MainDispatcherRule()

    private val repository = ExampleRepository(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...
    }
}

Tieni presente che sia runTest sia TestDispatchers creati nel test condivideranno comunque automaticamente il programma di pianificazione del supervisore Main.

Se non stai sostituendo il supervisore Main, crea il tuo primo TestDispatcher (in modo da creare un nuovo programma di pianificazione) come proprietà del corso. Quindi, passa manualmente lo scheduler a ogni chiamata a runTest e a ogni nuovo TestDispatcher creato, sia come proprietà sia all'interno del test:

class RepositoryTest {
    // Creates the single test scheduler
    private val testDispatcher = UnconfinedTestDispatcher()
    private val repository = ExampleRepository(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 questo esempio, il programma di pianificazione del primo supervisore viene passato a runTest. Verrà creato un nuovo StandardTestDispatcher per TestScope utilizzando lo scheduler. Puoi anche passare direttamente il supervisore a runTest per eseguire la coroutine di prova su quel supervisore.

Creazione del tuo TestScope

Come con TestDispatchers, potresti dover accedere a un TestScope esterno al corpo del test. Mentre runTest crea automaticamente TestScope, puoi anche creare un TestScope personalizzato da utilizzare con runTest.

Durante questa operazione, assicurati di chiamare runTest sul TestScope che hai creato:

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

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

Il codice riportato sopra crea implicitamente un StandardTestDispatcher per TestScope e un nuovo scheduler. Tutti questi oggetti possono anche essere creati in modo esplicito. Questo può essere utile se hai bisogno di integrarlo con configurazioni di inserimento delle dipendenze.

class ExampleTest {
    val testScheduler = TestCoroutineScheduler()
    val testDispatcher = StandardTestDispatcher(testScheduler)
    val testScope = TestScope(testDispatcher)

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

Inserimento di un ambito

Se hai un corso che crea coroutine che devi controllare durante test, puoi inserire un ambito coroutina in quella classe, sostituendolo con un TestScope nei test.

Nell'esempio seguente, la classe UserState dipende da un UserRepository per registrare nuovi utenti e recuperare l'elenco degli utenti registrati. Quando queste chiamate a UserRepository stanno sospendendo le chiamate di funzione, UserState utilizza la CoroutineScope per iniziare una nuova coroutine all'interno della sua funzione 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() }
        }
    }
}

Per testare questo corso, puoi superare il TestScope di runTest durante la creazione l'oggetto 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)
    }
}

Per inserire un ambito al di fuori della funzione di test, ad esempio in un oggetto in creato come proprietà nella classe test, consulta Creazione di un TestScope personalizzato.

Risorse aggiuntive