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