Test delle coroutine Kotlin su Android

Il codice di test delle unità che utilizza le coroutine richiede un'attenzione particolare, poiché la loro esecuzione può essere asincrona e avviene in più thread. Questa guida spiega 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 di funzioni di sospensione nei test

Per chiamare le funzioni di sospensione nei test, devi trovarti in una coroutine. Poiché le funzioni di test di JUnit non sospendono le funzioni, devi chiamare un creatore di coroutine all'interno dei tuoi test per avviare una nuova coroutine.

runTest è un generatore di coroutine progettato per i test. Utilizzalo per eseguire il wrapping di tutti i test che includono 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 è consigliabile utilizzare un corpo di espressione.

L'aggregazione del codice del test in runTest è utile per testare le funzioni di sospensione di base e salterà automaticamente eventuali ritardi nelle coroutine, rendendo il test riportato sopra molto più velocemente di un secondo.

Tuttavia, è necessario fare altre considerazioni in base a ciò che accade nel codice in fase di test:

  • Quando il codice crea nuove coroutine diverse dalla coroutine di test di primo livello creata da runTest, dovrai controllare come vengono pianificate queste nuove coroutine scegliendo le TestDispatcher appropriate.
  • Se il codice trasferisce l'esecuzione della coroutine ad altri supervisori (ad esempio utilizzando withContext), runTest continuerà a funzionare normalmente, ma i ritardi non verranno più ignorati e i test saranno meno prevedibili poiché il codice viene eseguito su più thread. Per questi motivi, nei test dovresti inserire i supervisori di prova per sostituire quelli reali.

Supervisori Test

Le implementazioni TestDispatchers sono CoroutineDispatcher 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 una programmazione diversa delle coroutine appena avviate. Entrambi utilizzano una TestCoroutineScheduler per controllare il tempo virtuale e gestire le coroutine in esecuzione all'interno di un test.

In un test dovrebbe essere utilizzata una sola istanza dello scheduler, condivisa tra tutti i TestDispatchers. Per ulteriori informazioni sulla condivisione degli scheduler, consulta Inserimento di TestDispatchers.

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

runTest tiene traccia delle coroutine in coda nel programma di pianificazione utilizzato dal supervisore del suo TestScope e non tornerà fino a quando sarà presente un lavoro in sospeso per lo scheduler in questione.

SupervisoreTest Standard

Quando avvii nuove coroutine su un StandardTestDispatcher, queste vengono messe in coda nello scheduler sottostante e possono essere eseguite ogni volta che il thread di test è libero. Per far funzionare queste nuove coroutine, devi rendere il thread di prova (liberarlo per consentire l'utilizzo di altre coroutine). Questo comportamento di accodamento ti offre un controllo preciso sul modo in cui le nuove coroutine vengono eseguite durante il test ed è simile alla pianificazione delle coroutine nel codice di produzione.

Se il thread di test non viene mai restituito durante l'esecuzione della coroutine di test di primo livello, eventuali nuove coroutine verranno eseguite solo dopo il completamento della coroutine di test (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 produrre la coroutine di prova e consentire l'esecuzione di quelle in coda. Tutte queste chiamate consentono ad altre coroutine di essere eseguite nel thread di test prima di restituire:

  • advanceUntilIdle: esegue tutte le altre coroutine nello scheduler fino a quando non rimane nulla in coda. Si tratta di una buona scelta predefinita per consentire l'esecuzione di tutte le coroutine in attesa e funzionerà nella maggior parte degli scenari di test.
  • advanceTimeBy: avanza il tempo virtuale in base all'importo specificato ed esegue le coroutine pianificate per l'esecuzione prima di quel punto nel tempo virtuale.
  • runCurrent: esegue coroutine pianificate all'ora virtuale attuale.

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'asserzione:

@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 UnconfinedTestDispatcher, vengono avviate con impazienza nel thread corrente. Ciò significa che inizieranno a funzionare immediatamente, senza dover aspettare il ritorno del costruttore di coroutine. In molti casi, questo comportamento di invio si traduce in un codice di test più semplice, poiché non è necessario generare manualmente il thread di test per consentire l'esecuzione di nuove coroutine.

Tuttavia, questo comportamento è diverso da quello che vedrai in produzione con i supervisori che non partecipano ai test. Se il test è incentrato sulla contemporaneità, è preferibile utilizzare StandardTestDispatcher.

Per utilizzare questo supervisore per la coroutina di test di primo livello in runTest anziché per quella predefinita, crea un'istanza e trasmettila come parametro. In questo modo, le nuove coroutine create all'interno di runTest verranno eseguite con impazienza, 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 di lancio daranno inizio alle nuove coroutine sul UnconfinedTestDispatcher, il che significa che ogni chiamata all'avvio verrà ripristinata solo al termine della registrazione.

Ricorda che UnconfinedTestDispatcher avvia con entusiasmo nuove coroutine, ma questo non significa che le completerà con entusiasmo. Se la nuova coroutine viene sospesa, le altre coroutine riprenderanno l'esecuzione.

Ad esempio, la nuova coroutina lanciata in questo test registrerà Alice, ma poi verrà sospesa quando viene chiamato delay. Ciò consente alla coroutina di primo livello di procedere con l'asserzione e il test ha esito negativo poiché 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 supervisori di test

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

Nei test, sostituisci questi supervisori con istanze di TestDispatchers. Ciò offre diversi vantaggi:

  • Il codice verrà eseguito sul singolo thread di test, rendendo i test più deterministici
  • Puoi controllare il modo in cui vengono pianificate ed eseguite le nuove coroutine
  • I TestDispatchers utilizzano uno scheduler per il tempo virtuale, che ignora automaticamente i ritardi e consente di avanzare manualmente

L'utilizzo dell'inserimento delle dipendenze per fornire ai mittenti le tue classi semplifica la sostituzione dei supervisori nei test. In questi esempi, inietteremo un valore CoroutineDispatcher, ma puoi anche inserire il tipo più ampio CoroutineContext, che consente una maggiore flessibilità durante i test.

Per le classi che avviano le coroutine, puoi anche inserire un CoroutineScope anziché un supervisore, come descritto nella sezione Inserimento di 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 passarla a qualsiasi TestDispatchers appena creato. In questo modo condivideranno la loro comprensione del tempo virtuale e metodi come advanceUntilIdle eseguiranno coroutine su tutti i supervisori dei test fino al completamento.

Nel seguente esempio, puoi vedere una classe Repository che crea una nuova coroutine utilizzando il supervisore IO nel metodo initialize e trasferisce il chiamante al supervisore IO nel 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"
    }
}

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

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

Inoltre, fetchData trarrà vantaggio dall'esecuzione su TestDispatcher, poiché verrà eseguito sul thread di test ignorando 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 su TestDispatcher possono essere avanzate manualmente come mostrato sopra con initialize. Tuttavia, tieni presente che ciò non sarebbe possibile o auspicabile nel codice di produzione. Questo metodo deve invece essere riprogettato in modo da essere sospeso (per l'esecuzione sequenziale) o per restituire un valore Deferred (per l'esecuzione contemporanea).

Ad esempio, puoi utilizzare async per avviare 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 modo sicuro 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 sono su un TestDispatcher con cui condivide lo scheduler. Attendi anche le coroutine figlie della coroutine di test di primo livello, anche se si trovano su altri supervisori (fino a un timeout specificato dal parametro dispatchTimeoutMs, che per impostazione predefinita è di 60 secondi).

Impostazione del supervisore principale

Nei test delle unità locali, il supervisore Main che aggrega il thread dell'interfaccia utente Android non sarà disponibile, perché 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, genererà un'eccezione durante i test delle unità.

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

Ecco un esempio di implementazione di ViewModel che utilizza viewModelScope per avviare una coroutine che carica i dati:

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

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

Per sostituire il supervisore Main con un TestDispatcher in tutti i casi, 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, qualsiasi TestDispatchers appena creato utilizzerà automaticamente il pianificatore del supervisore Main, incluso il StandardTestDispatcher creato da runTest se non viene trasmesso nessun altro supervisore.

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

Un pattern comune per evitare la duplicazione del 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)
    }
}

Questa implementazione della regola utilizza un UnconfinedTestDispatcher per impostazione predefinita, ma è possibile trasmettere un StandardTestDispatcher come parametro se il supervisore Main non deve eseguire con entusiasmo una determinata classe di test.

Se hai bisogno di un'istanza TestDispatcher nel corpo del test, puoi riutilizzare il valore testDispatcher della regola, purché sia del tipo desiderato. Se vuoi indicare esplicitamente il tipo di TestDispatcher usato nel test o se ti serve un TestDispatcher diverso da quello usato per Main, puoi creare un nuovo TestDispatcher all'interno di runTest. Poiché il supervisore Main è impostato su TestDispatcher, ogni TestDispatchers appena creato condividerà automaticamente il proprio scheduler.

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 supervisori 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 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...
        // ...
    }
}

Se stai sostituendo il Main supervisore come mostrato nella sezione precedente, TestDispatchers creato dopo il supervisore Main è stato sostituito condividerà automaticamente il proprio scheduler.

Questo non accade, tuttavia, per i valori TestDispatchers creati come proprietà della classe di test o TestDispatchers creati durante l'inizializzazione delle proprietà nella classe di test. Questi vengono inizializzati prima della sostituzione del supervisore Main. Di conseguenza, creerebbero nuovi scheduler.

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

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

Tieni presente che runTest e TestDispatchers creati nel test condivideranno comunque automaticamente lo scheduler 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 runTest e a ogni nuovo TestDispatcher creato, sia come proprietà che all'interno del test:

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 questo esempio, lo scheduler del primo supervisore viene passato a runTest. Verrà creato un nuovo StandardTestDispatcher per TestScope che utilizza lo scheduler. Puoi anche passare direttamente il supervisore a runTest per eseguire la coroutine di prova su quel supervisore.

Creazione di un TestScope personalizzato

Come per TestDispatchers, potresti dover accedere a un TestScope all'esterno del corpo del test. Mentre runTest crea automaticamente un TestScope, puoi anche creare il tuo TestScope da utilizzare con runTest.

Quando esegui 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, nonché un nuovo scheduler. Questi oggetti possono anche essere creati esplicitamente. Questo può essere utile se hai bisogno di integrarlo con le 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 una classe che crea coroutine che devi controllare durante i test, puoi inserire un ambito coroutine in quella classe, sostituendolo con TestScope nei test.

Nell'esempio seguente, la classe UserState dipende da un UserRepository per registrare i nuovi utenti e recuperare l'elenco degli utenti registrati. Poiché queste chiamate a UserRepository sospenderanno le chiamate di funzione, UserState utilizza la CoroutineScope inserita per avviare una nuova coroutina 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 questa classe, puoi passare il TestScope da runTest durante la creazione dell'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 test, ad esempio in un oggetto durante il test creato come proprietà nella classe di test, consulta Creazione di un proprio TestScope.

Risorse aggiuntive