Kotlin Coroutines vs RxJava per networking e lavoro in background
Kotlin Coroutines vs RxJava: confronta cancellazione, gestione degli errori e pattern di testing per networking e lavoro in background nelle app Android reali.

Perché questa scelta conta per il networking in produzione
Il networking e il lavoro in background in una vera app Android sono più di una singola chiamata API. Comprendono login e refresh token, schermate che possono ruotare a metà caricamento, sincronizzazioni dopo che l'utente lascia una schermata, upload di foto e lavoro periodico che non può scaricare la batteria.
I bug più dannosi di solito non sono problemi di sintassi. Appaiono quando il lavoro asincrono vive più a lungo dell'UI (leak), quando la cancellazione ferma l'interfaccia ma non la richiesta reale (traffico sprecato e spinner bloccati), quando i retry moltiplicano le richieste (rate limit, ban), o quando livelli diversi gestiscono gli errori in modi differenti così che nessuno sa cosa vedrà l'utente.
La decisione Kotlin Coroutines vs RxJava influisce sull'affidabilità quotidiana:
- Come modelli il lavoro (chiamate one-shot vs stream)
- Come si propaga la cancellazione
- Come gli errori vengono rappresentati e mostrati all'UI
- Come controlli i thread per rete, disco e UI
- Quanto è testabile il timing, i retry e i casi limite
I pattern qui sotto si concentrano su cosa tende a rompersi sotto carico o su reti lente: cancellazione, gestione errori, retry e timeout, e abitudini di test che prevengono regressioni. Gli esempi restano brevi e pratici.
Modelli mentali fondamentali: suspend, stream e Flow
La differenza principale tra Kotlin Coroutines vs RxJava è la forma del lavoro che stai modellando.
Una funzione suspend rappresenta un'operazione one-shot. Ritorna un valore o lancia un errore. Questo corrisponde alla maggior parte delle chiamate di rete: fetch profilo, aggiornare impostazioni, upload foto. Il codice chiamante si legge dall'alto in basso, restando facile da capire anche dopo aver aggiunto logging, caching e branching.
RxJava parte chiedendosi se stai trattando un singolo valore o molti valori nel tempo. Un Single è un risultato one-shot (successo o errore). Un Observable (o Flowable) è uno stream che può emettere più valori, poi completare o fallire. Questo si adatta a feature davvero event-like: eventi di testo, messaggi websocket o polling.
Flow è il modo friendly per le coroutine di rappresentare uno stream. Pensalo come la versione "stream" delle coroutine, con cancellazione strutturata e integrazione diretta con API sospendenti.
Una regola pratica:
- Usa
suspendper una richiesta e una risposta. - Usa
Flowper valori che cambiano nel tempo. - Usa RxJava quando la tua app già si basa pesantemente su operatori e composizione complessa di stream.
Man mano che le feature crescono, la leggibilità di solito si rompe prima quando forzi un modello a stream su una chiamata one-shot, o quando cerchi di trattare eventi continui come un singolo valore di ritorno. Abbina prima l'astrazione alla realtà, poi definisci le convenzioni.
Cancellazione nella pratica (con brevi esempi di codice)
La cancellazione è il punto in cui il codice asincrono o dà la sensazione di essere sicuro, o si trasforma in crash casuali e chiamate sprecate. L'obiettivo è semplice: quando l'utente lascia una schermata, qualsiasi lavoro avviato per quella schermata dovrebbe fermarsi.
Con Kotlin Coroutines la cancellazione è integrata nel modello. Un Job rappresenta lavoro e con la concorrenza strutturata di solito non passi job in giro. Avvii il lavoro dentro uno scope (come il ViewModel scope). Quando quello scope è cancellato, tutto ciò che c'è dentro viene cancellato.
class ProfileViewModel(
private val api: Api
) : ViewModel() {
fun loadProfile() = viewModelScope.launch {
// If the ViewModel is cleared, this coroutine is cancelled,
// and so is the in-flight network call (if the client supports it).
val profile = api.getProfile() // suspend
// update UI state here
}
}
Due dettagli di produzione contano:
- Chiama le API sospendenti tramite un client cancellabile. Altrimenti la coroutine si ferma ma la chiamata HTTP può continuare.
- Usa
withTimeout(owithTimeoutOrNull) per richieste che non devono bloccarsi.
RxJava usa la disposal esplicita. Tieni un Disposable per ogni sottoscrizione, o raccoglili in un CompositeDisposable. Quando la schermata sparisce, disposi e la catena dovrebbe fermarsi.
class ProfilePresenter(private val api: ApiRx) {
private val bag = CompositeDisposable()
fun attach() {
bag += api.getProfile()
.subscribe(
{ profile -> /* render */ },
{ error -> /* show error */ }
)
}
fun detach() {
bag.clear() // cancels in-flight work if upstream supports cancellation
}
}
Una regola pratica per l'uscita dalla schermata: se non riesci a indicare dove avviene la cancellazione (cancellazione dello scope o dispose()), assumiti che il lavoro continuerà a girare e correggilo prima di rilasciare.
Gestione degli errori che resta comprensibile
Una grande differenza tra Kotlin Coroutines vs RxJava è come viaggiano gli errori. Le coroutine fanno apparire i fallimenti come codice normale: una chiamata suspend lancia, e il chiamante decide cosa fare. Rx spinge i fallimenti attraverso lo stream, il che è potente, ma è facile nascondere problemi se non stai attento.
Usa eccezioni per fallimenti inaspettati (timeout, 500, bug di parsing). Modella gli errori come dati quando l'UI ha bisogno di una risposta specifica (password sbagliata, “email già usata”) e vuoi che sia parte del modello di dominio.
Un semplice pattern con coroutine mantiene lo stack trace e resta leggibile:
suspend fun loadProfile(): Profile = try {
api.getProfile() // may throw
} catch (e: IOException) {
throw NetworkException("No connection", e)
}
runCatching e Result sono utili quando vuoi davvero restituire successo o fallimento senza lanciare:
suspend fun loadProfileResult(): Result<Profile> =
runCatching { api.getProfile() }
Fai attenzione a getOrNull() se non gestisci anche il fallimento. Questo può trasformare silenziosamente bug reali in schermate di "stato vuoto".
In RxJava mantieni il percorso degli errori esplicito. Usa onErrorReturn solo per fallback sicuri. Preferisci onErrorResumeNext quando hai bisogno di switchare sorgenti (per esempio ai dati in cache). Per i retry, mantieni regole strette con retryWhen così non ritenti per una "password sbagliata".
Una serie di abitudini che prevengono errori inghiottiti:
- Logga o segnala un errore una sola volta, vicino al punto dove hai contesto.
- Conserva l'eccezione originale come
causequando la avvolgi. - Evita fallback catch-all che trasformano ogni errore in un valore di default.
- Rendi gli errori destinati all'utente un modello tipato, non una stringa.
Nozioni sul threading: Dispatchers vs Schedulers
Molti bug asincroni derivano dal threading: fare lavoro pesante sul main thread, o toccare la UI da un thread di background. Kotlin Coroutines vs RxJava differiscono principalmente sul come esprimi i salti di thread.
Con le coroutine spesso parti sul main thread per lavoro UI, poi salti su un dispatcher di background per le parti costose. Scelte comuni sono:
Dispatchers.Mainper aggiornamenti UIDispatchers.IOper I/O bloccante come rete e discoDispatchers.Defaultper lavoro CPU-bound come parsing JSON, sort, crittografia
Un pattern semplice è: fetch, parse off-main, poi render.
viewModelScope.launch(Dispatchers.Main) {
val json = withContext(Dispatchers.IO) { api.fetchProfileJson() }
val profile = withContext(Dispatchers.Default) { parseProfile(json) }
_uiState.value = UiState.Content(profile)
}
RxJava esprime "dove avviene il lavoro" con subscribeOn e "dove si osservano i risultati" con observeOn. Una sorpresa comune è aspettarsi che observeOn influenzi l'upstream. Non lo fa. subscribeOn imposta il thread per la sorgente e gli operatori sopra di essa, e ogni observeOn cambia thread da quel punto in poi.
api.fetchProfileJson()
.subscribeOn(Schedulers.io())
.map { json -> parseProfile(json) } // still on io unless you change it
.observeOn(AndroidSchedulers.mainThread())
.subscribe(
{ profile -> render(profile) },
{ error -> showError(error) }
)
Una regola che evita sorprese: tieni il lavoro UI in un solo posto. Nelle coroutine assegna o raccogli lo stato UI su Dispatchers.Main. In RxJava metti un unico observeOn(main) finale prima del rendering e non sparpagliare observeOn a meno che non sia realmente necessario.
Se una schermata scatta, sposta parsing e mapping fuori dal main thread per primo. Quel singolo cambiamento risolve molti problemi reali.
Retry, timeout e lavoro parallelo per le chiamate di rete
Il percorso felice raramente è il problema. Le issue arrivano da chiamate che si bloccano, retry che peggiorano le cose, o lavoro “parallelo” che in realtà non è parallelo. Questi pattern spesso decidono se un team preferisce Kotlin Coroutines vs RxJava.
Timeout che falliscono velocemente
Con le coroutine puoi mettere un tetto intorno a qualsiasi chiamata suspend. Tieni il timeout vicino al call site così puoi mostrare il messaggio UI corretto.
val user = withTimeout(5_000) {
api.getUser() // suspend
}
In RxJava attacchi un operatore timeout allo stream. Questo è utile quando il comportamento di timeout deve far parte di una pipeline condivisa.
Retry senza causare danni
Ritenta solo quando il retry è sicuro. Una regola semplice: ritenta più liberamente le richieste idempotenti (come GET) rispetto a richieste che creano side effect (come "crea ordine"). Anche in quel caso, limita il numero e aggiungi delay o jitter.
Guardrail di default utili:
- Ritenta su timeout di rete ed errori temporanei lato server.
- Non ritentare su errori di validazione (400) o su fallimenti di auth.
- Limita i retry (spesso 2–3) e logga il fallimento finale.
- Usa backoff per non martellare il server.
In RxJava retryWhen ti permette di esprimere "ritenta solo per questi errori, con questo delay". Nelle coroutine, Flow ha retry e retryWhen, mentre le funzioni suspend spesso usano un piccolo loop con delay.
Chiamate parallele senza codice aggrovigliato
Le coroutine rendono il lavoro parallelo diretto: avvii due richieste e aspetti entrambe.
coroutineScope {
val profile = async { api.getProfile() }
val feed = async { api.getFeed() }
profile.await() to feed.await()
}
RxJava brilla quando combinare più sorgenti è l'intero scopo della catena. zip è lo strumento usuale per "aspettare entrambi", e merge è utile quando vuoi risultati non appena arrivano.
Per stream grandi o veloci la backpressure conta ancora. Flowable di RxJava ha strumenti maturi per la backpressure. Flow delle coroutine gestisce molti casi bene, ma potresti aver bisogno di buffering o di politiche di drop se gli eventi superano la capacità della UI o del database.
Interop e pattern di migrazione (codebase miste)
La maggior parte dei team non switcha da un giorno all'altro. Una migrazione pratica Kotlin Coroutines vs RxJava mantiene l'app stabile mentre sposti modulo per modulo.
Avvolgere un'API Rx in una funzione suspend
Se hai un Single<T> o Completable esistente, avvolgilo con supporto alla cancellazione così una coroutine cancellata disposa la sottoscrizione Rx.
suspend fun <T : Any> Single<T>.awaitCancellable(): T =
suspendCancellableCoroutine { cont ->
val d = subscribe(
{ value -> cont.resume(value) {} },
{ error -> cont.resumeWithException(error) }
)
cont.invokeOnCancellation { d.dispose() }
}
Questo evita una modalità di fallimento comune: l'utente lascia la schermata, la coroutine viene cancellata, ma la chiamata di rete continua e aggiorna poi uno stato condiviso.
Esporre codice coroutine a caller Rx
Durante la migrazione alcuni layer si aspettano ancora tipi Rx. Avvolgi il lavoro suspend in Single.fromCallable e blocca solo su un thread di background.
fun loadProfileRx(api: Api): Single<Profile> =
Single.fromCallable {
runBlocking { api.loadProfile() } // ensure subscribeOn(Schedulers.io())
}
Mantieni questo confine piccolo e documentato. Per codice nuovo, preferisci chiamare l'API suspend direttamente da uno scope di coroutine.
Dove Flow si adatta e dove no
Flow può sostituire molti casi d'uso di Observable: stato UI, aggiornamenti DB e stream tipo paging. Può essere meno diretto se fai molto affidamento su hot stream, subjects, tuning avanzato della backpressure o una grande serie di operatori custom che il team già conosce.
Una strategia di migrazione che riduce la confusione:
- Converti prima i moduli foglia (rete, storage) a API suspend.
- Aggiungi piccoli adapter ai confini dei moduli (Rx -> suspend, suspend -> Rx).
- Sostituisci gli stream Rx con Flow solo quando controlli anche i consumer.
- Mantieni uno stile asincrono per area funzionale.
- Elimina gli adapter appena l'ultimo caller è migrato.
Pattern di test che userai davvero
Timing e cancellazione sono dove i bug asincroni si nascondono. Buoni test asincroni rendono il tempo deterministico e gli esiti facili da asserire. Anche qui Kotlin Coroutines vs RxJava sembra diverso, anche se entrambi possono essere ben testati.
Coroutines: runTest, TestDispatcher e controllo del tempo
Per il codice coroutine preferisci runTest con un test dispatcher così il test non dipende da thread reali o delay reali. Il tempo virtuale ti permette di triggerare timeout, retry e debounce senza dormire.
@OptIn(ExperimentalCoroutinesApi::class)
@Test
fun `emits Loading then Success`() = runTest {
val dispatcher = StandardTestDispatcher(testScheduler)
val repo = Repo(api = fakeApi, io = dispatcher)
val states = mutableListOf<UiState>()
val job = launch(dispatcher) { repo.loadProfile().toList(states) }
testScheduler.runCurrent() // run queued work
assert(states.first() is UiState.Loading)
testScheduler.advanceTimeBy(1_000) // trigger delay/retry windows
testScheduler.runCurrent()
assert(states.last() is UiState.Success)
job.cancel()
}
Per testare la cancellazione, cancella il Job che raccoglie (o lo scope parent) e asserisci che la fake API si fermi o che non vengano emessi altri stati.
RxJava: TestScheduler, TestObserver, tempo deterministico
I test Rx di solito combinano un TestScheduler per il tempo e un TestObserver per le asserzioni.
@Test
fun `disposes on cancel and stops emissions`() {
val scheduler = TestScheduler()
val observer = TestObserver<UiState>()
val d = repo.loadProfileRx(scheduler)
.subscribeWith(observer)
scheduler.triggerActions()
observer.assertValueAt(0) { it is UiState.Loading }
d.dispose()
scheduler.advanceTimeBy(1, TimeUnit.SECONDS)
observer.assertValueCount(1) // no more events after dispose
}
Quando testi i percorsi di errore in entrambi gli stili, concentrati sulla mappatura, non sul tipo di eccezione. Asserisci lo stato UI che ti aspetti dopo un 401, un timeout o una risposta malformata.
Un piccolo set di controlli copre la maggior parte delle regressioni:
- Stati Loading e finali (Success, Empty, Error)
- Pulizia alla cancellazione (job cancellato, disposable disposed)
- Mappatura errori (codici server a messaggi utente)
- Nessuna emissione duplicata dopo i retry
- Logica temporale usando tempo virtuale, non delay reali
Errori comuni che causano bug in produzione
La maggior parte dei problemi in produzione non dipende dalla scelta Kotlin Coroutines vs RxJava. Derivano da poche abitudini che fanno sì che il lavoro giri più a lungo del previsto, giri due volte o tocchi la UI al momento sbagliato.
Un leak comune è lanciare lavoro nello scope sbagliato. Se avvii una chiamata di rete da uno scope che vive più a lungo della schermata (o crei uno scope tuo e non lo cancelli), la richiesta può terminare dopo che l'utente se n'è andato e provare comunque ad aggiornare lo stato. Nelle coroutine questo appare spesso come l'uso di uno scope di lunga durata per impostazione predefinita. In RxJava è di solito un dispose mancante.
Un altro classico è il "fire and forget". Scope globali e Disposables dimenticati vanno bene finché il lavoro non si accumula. Una schermata chat che fa refresh a ogni resume può facilmente accumulare più job di refresh dopo alcune navigazioni, ognuno trattenendo memoria e competendo per la rete.
I retry sono anche facili da sbagliare. Retry illimitati, o senza delay, possono spamare il backend e scaricare la batteria. È pericoloso soprattutto quando il fallimento è permanente, come un 401 dopo il logout. Rendi i retry condizionali, aggiungi backoff e fermati quando l'errore non è recuperabile.
Gli errori di threading causano crash difficili da riprodurre. Potresti fare parsing JSON sul main thread o aggiornare la UI da un thread di background a seconda di dove hai messo un dispatcher o uno scheduler.
Controlli rapidi che catturano la maggior parte di questi problemi:
- Collega il lavoro a un lifecycle owner e cancellalo quando quell'owner termina.
- Rendi la pulizia ovvia: cancella Job o clear Disposables in un solo posto.
- Metti limiti stretti sui retry (conteggio, delay e quali errori qualificano).
- Applica una regola unica per aggiornamenti UI (solo main thread) nelle code review.
- Tratta la sincronizzazione di background come un sistema con vincoli, non come una funzione casuale.
Se distribuisci app Android generate da codice Kotlin (per esempio da AppMaster), gli stessi punti critici si applicano. Hai comunque bisogno di convenzioni chiare per scope, cancellazione, limiti di retry e regole sui thread.
Checklist rapida per scegliere Coroutines, RxJava o entrambi
Parti dalla forma del lavoro. La maggior parte delle chiamate di rete sono one-shot, ma le app hanno anche segnali continui come connettività, stato auth o aggiornamenti live. Scegliere l'astrazione sbagliata all'inizio di solito si palesa dopo con cancellazioni disordinate e percorsi di errore difficili da leggere.
Un modo semplice per decidere (e spiegare la scelta al team):
- Richiesta one-time (login, fetch profilo): preferisci una funzione
suspend. - Stream continuo (eventi, aggiornamenti DB): preferisci
FlowoObservabledi Rx. - Cancellazione legata al lifecycle UI: le coroutine in
viewModelScopeolifecycleScopespesso sono più semplici dei disposable manuali. - Grande dipendenza da operatori avanzati e backpressure: RxJava può restare la scelta migliore, specialmente in codebase legacy.
- Retry complessi e mappatura errori: scegli l'approccio che il team riesce a mantenere leggibile.
Una regola pratica: se una schermata fa una richiesta e renderizza un risultato, le coroutine tengono il codice vicino a una normale chiamata di funzione. Se costruisci una pipeline di molti eventi (typing, debounce, cancel previous requests, combina filtri), RxJava o Flow spesso risultano più naturali.
La coerenza batte la perfezione. Due buoni pattern usati ovunque sono più facili da supportare di cinque “migliori” pattern usati in modo incoerente.
Scenario d'esempio: login, fetch profilo e sync in background
Un flusso comune in produzione è: l'utente tocca Login, chiami un endpoint di auth, poi prendi il profilo per la home screen e infine avvii una sincronizzazione in background. Qui Kotlin Coroutines vs RxJava può apparire diverso nella manutenzione quotidiana.
Versione con Coroutines (sequenziale + cancellabile)
Con le coroutine la forma "fai questo, poi quello" è naturale. Se l'utente chiude la schermata, cancellando lo scope si fermano i lavori in corso.
suspend fun loginAndLoadProfile(): Result<Profile> = runCatching {
val token = api.login(email, password) // suspend
val profile = api.profile("Bearer $token")
syncManager.startSyncInBackground(token) // fire-and-forget
profile
}.recoverCatching { e ->
throw when (e) {
is HttpException -> when (e.code()) {
401 -> AuthExpiredException()
in 500..599 -> ServerDownException()
else -> e
}
is IOException -> NoNetworkException()
else -> e
}
}
// UI layer
val job = viewModelScope.launch { loginAndLoadProfile() }
override fun onCleared() { job.cancel() }
Versione RxJava (catena + disposal)
In RxJava lo stesso flusso è una catena. La cancellazione significa fare dispose, tipicamente con un CompositeDisposable.
val d = api.login(email, password)
.flatMap { token -> api.profile("Bearer $token").map { it to token } }
.doOnSuccess { (_, token) -> syncManager.startSyncInBackground(token) }
.onErrorResumeNext { e: Throwable ->
Single.error(
when (e) {
is HttpException -> if (e.code() == 401) AuthExpiredException() else e
is IOException -> NoNetworkException()
else -> e
}
)
}
.subscribe({ (profile, _) -> show(profile) }, { showError(it) })
compositeDisposable.add(d)
override fun onCleared() { compositeDisposable.clear() }
Una suite di test minima qui dovrebbe coprire tre esiti: successo, fallimenti mappati (401, 500, no network) e cancellazione/disposal.
Prossimi passi: scegli convenzioni e mantienile
I team di solito hanno problemi perché i pattern variano tra le feature, non perché Kotlin Coroutines vs RxJava sia intrinsecamente sbagliato. Una breve nota decisionale (anche una pagina) salva tempo nelle review e rende il comportamento prevedibile.
Inizia con una separazione chiara: lavoro one-shot (una singola chiamata di rete che ritorna una volta) vs stream (aggiornamenti nel tempo, come websocket, location o cambiamenti DB). Decidi il default per ciascuno e definisci quando sono ammessi eccezioni.
Poi aggiungi un piccolo set di helper condivisi così ogni feature si comporta allo stesso modo quando la rete fa i capricci:
- Un posto unico per mappare errori (codici HTTP, timeout, offline) in fallimenti a livello app comprensibili dalla UI
- Valori timeout di default per le chiamate di rete, con modo chiaro per sovrascrivere per operazioni lunghe
- Una policy di retry che dichiara cosa è sicuro ritentare (es. GET vs POST)
- Una regola di cancellazione: cosa si ferma quando l'utente lascia una schermata e cosa può continuare
- Regole di logging che aiutano il supporto senza esporre dati sensibili
Le convenzioni di testing contano altrettanto. Concorda un approccio standard così i test non dipendono dal tempo reale o da thread reali. Per le coroutine di solito significa test dispatcher e scope strutturati. Per RxJava significa test scheduler e disposal esplicito. In entrambi i casi punta a test veloci e deterministici senza sleep.
Se vuoi andare più veloce in generale, AppMaster è un'opzione per generare API backend e app mobile Kotlin senza scrivere tutto da zero. Anche con codice generato, le stesse convenzioni di produzione su cancellazione, errori, retry e testing sono ciò che mantiene il comportamento di networking prevedibile.
FAQ
Default a suspend per una richiesta che ritorna una sola volta, come login o fetch del profilo. Usa Flow (o stream Rx) quando i valori cambiano nel tempo, ad esempio messaggi websocket, connettività o aggiornamenti del database.
Sì, ma solo se il tuo client HTTP supporta la cancellazione. Le coroutine interrompono la coroutine quando lo scope è cancellato, ma la chiamata HTTP sottostante deve supportare la cancellazione altrimenti la richiesta può continuare in background.
Collega il lavoro a uno scope legato al lifecycle, come viewModelScope, così si cancella quando la logica della schermata termina. Evita di lanciare lavoro in scope globali o di lunga durata a meno che il lavoro non sia davvero a livello di app.
Nelle coroutine, i fallimenti normalmente lanciano eccezioni e li gestisci con try/catch vicino al punto in cui puoi mapparli allo stato UI. In RxJava gli errori scorrono nello stream, quindi tieni il percorso degli errori esplicito e evita operatori che trasformano silenziosamente i fallimenti in valori di default.
Usa eccezioni per fallimenti inaspettati come timeout, 500 o errori di parsing. Usa errori modellati come dati quando l'interfaccia deve reagire a casi specifici come “password sbagliata” o “email già usata”, così non fai affidamento su confronti di stringhe.
Applica un timeout vicino al punto in cui puoi mostrare il messaggio giusto all'utente. Nelle coroutine withTimeout è semplice per le chiamate suspend; in RxJava l'operatore timeout integra il timeout nel flusso.
Ritenta solo quando è sicuro: di solito per richieste idempotenti come GET, limita il numero di retry a 2–3 e non ritentare su errori di validazione o fallimenti di autenticazione. Aggiungi delay (e jitter) così non sovraccarichi il server o la batteria.
Il problema principale è dove esegui il lavoro: nelle coroutine usa Dispatchers e solitamente parti da Main per la UI, poi vai su IO o Default per lavoro pesante. In RxJava subscribeOn decide dove gira l'upstream e observeOn dove consumi i risultati; metti un unico observeOn(main) finale prima del render per evitare sorprese.
Sì, ma tieni il confine piccolo e attento alla cancellazione. Avvolgi Rx in suspend con un adattatore cancellabile che disposa la sottoscrizione quando la coroutine è cancellata, e espone il lavoro suspend a caller Rx solo tramite bridge documentati e limitati.
Usa tempo virtuale così i test non fanno sleep né dipendono da thread reali. Per le coroutine, runTest con un test dispatcher ti permette di controllare delay e cancellazione; per RxJava usa TestScheduler e verifica che non ci siano emissioni dopo dispose().


