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 laTestDispatcher
appropriata. - Se il tuo codice trasferisce l'esecuzione della coroutine ad altri committenti (ad esempio, utilizzando
withContext
), in genererunTest
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.