I've written a "minimal" AS project to replicate my the problem I'm facing. Here's the gh link.
I'm trying to write an end-to-end ui test in my compose-only project. The test covers a simple sign-in -> sync data -> go to main view use case.
Here's the whole test:
#HiltAndroidTest
class ExampleInstrumentedTest {
#get:Rule(order = 1)
val hiltRule = HiltAndroidRule(this)
#get:Rule(order = 2)
val composeTestRule = createAndroidComposeRule<MainActivity>()
#Inject
lateinit var dao: DummyDao
val isSyncing = mutableStateOf(false)
#Before
fun setup() {
runBlocking {
hiltRule.inject()
dao.deleteAllData()
dao.deleteUser()
}
composeTestRule.activity.isSyncingCallback = {
synchronized(isSyncing) {
isSyncing.value = it
}
}
composeTestRule.registerIdlingResource(
object : IdlingResource {
override val isIdleNow: Boolean
get() {
synchronized(isSyncing) {
return !isSyncing.value
}
}
}
)
}
#Test
fun runsTheStuffAndItWorks() {
composeTestRule
.onNodeWithText("login", ignoreCase = true, useUnmergedTree = true)
.assertIsDisplayed()
.performClick()
composeTestRule
.onNodeWithTag("sync")
.assertExists()
composeTestRule.waitForIdle()
assertFalse(isSyncing.value)
composeTestRule.onRoot().printToLog("not in the list")
composeTestRule
.onNodeWithTag("the list", useUnmergedTree = true)
.assertIsDisplayed()
}
}
The test runs "alright" up to the point where it should be waiting for the sync worker to finish its job and finally navigate to the "main composable".
Unfortunately, the test seems to be blocking the device's ui thread when the idling resource is not idle, finishing the test immediately as the idling resource does become idle.
I've tried using Espresso's IdlingResource directly, which also didn't work, showing similar results. I've tried adding compose's IdlingResource in different points as well, but that also didn't work (adding one between navigation calls also blocks the UI thread and the test fails even sooner).
What am I doing wrong here? Am I forgetting to setup something?
Related
viewModelScope blocks UI in Jetpack Compose
I know viewModelScope.launch(Dispatchers.IO) {} can avoid this problem, but how to use viewModelScope.launch(Dispatchers.IO) {}?
This is my UI level code
#Composable
fun CountryContent(viewModel: CountryViewModel) {
SingleRun {
viewModel.getCountryList()
}
val pagingItems = viewModel.countryGroupList.collectAsLazyPagingItems()
// ...
}
Here is my ViewModel, Pager is my pagination
#HiltViewModel
class CountryViewModel #Inject constructor() : BaseViewModel() {
var countryGroupList = flowOf<PagingData<CountryGroup>>()
private val config = PagingConfig(pageSize = 26, prefetchDistance = 1, initialLoadSize = 26)
fun getCountryList() {
countryGroupList = Pager(config) {
CountrySource(api)
}.flow.cachedIn(viewModelScope)
}
}
This is the small package
#Composable
fun SingleRun(onClick: () -> Unit) {
val execute = rememberSaveable { mutableStateOf(true) }
if (execute.value) {
onClick()
execute.value = false
}
}
I don't use Compose much yet, so I could be wrong, but this stood out to me.
I don't think your thread is being blocked. I think you subscribed to an empty flow before replacing it, so there is no data to show.
You shouldn't use a var property for your flow, because the empty original flow could be collected before the new one replaces it. Also, it defeats the purpose of using cachedIn because the flow could be replaced multiple times.
You should eliminate the getCountryList() function and just directly assign the flow. Since it is a cachedIn flow, it doesn't do work until it is first collected anyway. See the documentation:
It won't execute any unnecessary code unless it is being collected.
So your view model should look like:
#HiltViewModel
class CountryViewModel #Inject constructor() : BaseViewModel() {
private val config = PagingConfig(pageSize = 26, prefetchDistance = 1, initialLoadSize = 26)
val countryGroupList = Pager(config) {
CountrySource(api)
}.flow.cachedIn(viewModelScope)
}
}
...and you can remove the SingleRun block from your Composable.
You are not doing anything that would require you to specify dispatchers. The default of Dispatchers.Main is fine here because you are not calling any blocking functions directly anywhere in your code.
I have a consumer that reads messages off MutableSharedFlow (which acts as an EventBus in my application). I am trying to write a unit test to show that passing a message into the Flow triggers my Listener.
This is my Flow definition:
class MessageBus {
private val _messages = MutableSharedFlow<Message>()
val messages = _messages.asSharedFlow()
suspend fun send(message: Message) {
_messages.emit(message)
}
}
Here is the Listener:
class Listener(private val messageBus: MessageBus) {
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
init {
scope.launch {
messageBus.messages.collectLatest { message ->
when (message) {
is CustomMessage -> handleCustomMessage(message)
}
}
}
}
And finally here is my unit test:
class CommandTest {
#Test
fun `should process CustomMessage`(): Unit = runBlocking {
val messageBus = MessageBus()
val listener = Listener(messageBus)
messageBus.send(CustomMessage("test command"))
//argumentCaptor...verify[removed for brevity]
}
}
Unfortunately the above code does not trigger the break point in my Listener (breakpoint on line init is triggered, but a message is never received and no breakpoints triggered in the collectLatest block).
I even tried adding a Thread.sleep(5_000) before the verify statement but the result is the same. Am I missing something obvious with how coroutines work?
Edit: if it matters this is not an Android project. Simply Kotlin + Ktor
I imagine that since the code is in the init block in the Listener once you initialize val listener = Listener(messageBus, this) in the test it reads all messages and at this point you have none then in the next line you emit a message messageBus.send(CustomMessage("test command")) but your launch block should have finished by then. You can emit the message first or place your launch in an loop or in a different method that can be called after you emit the message
First of all I would recomend reading this article about how to test flows in Android.
Secondly in your example the issues arise from having the scope inside the Listener hardcoded. You should pass the scope as a parameter and inject it in the test:
class Listener(private val messageBus: MessageBus, private val scope: CoroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob()))
class CommandTest {
#Test
fun `should process CustomMessage`(): Unit = runBlockingTest {
val messageBus = MessageBus()
val listener = Listener(messageBus, this)
messageBus.send(CustomMessage("test command"))
//argumentCaptor...verify[removed for brevity]
}
}
I would also recomend using runBlockingTest instead of runBlocking so your tests don't have to actually wait. It will also fail in case any coroutines are left running once the test finishes.
You could use something like this
class Emitter {
private val emitter: MutableSharedFlow<String> = MutableSharedFlow()
suspend fun publish(messages: Flow<String>) = messages.onEach {
emitter.emit(it)
}.collect()
fun stream(): Flow<String> = emitter
}
the collect at the end of your onEach will be used to trigger the collection initially as a terminal operation... I need further understanding on emit because it does not work as I expect in all cases and when used in this way you have initially it does not post anything in your Flow unless you collect first to process
Then in your collector itself
class Collector {
suspend fun collect(emitter: Emitter): Unit = coroutineScope {
println("Starting collection...")
emitter.stream().collect { println("collecting message: $it") }
}
}
then your main (or test)
fun main() = runBlocking {
withContext(Dispatchers.Default + Job()) {
val emitter = Emitter()
val collector = Collector()
launch {
collector.collect(emitter)
}
emitter.publish(listOf("article#1", "article#2", "article#3", "article#4").asFlow())
}
}
output:
Starting collection...
collecting message: article#1
collecting message: article#2
collecting message: article#3
collecting message: article#4
Recently I've been trying to familiarize myself with Kotlin some more, so I decided to write a webscraper utilizing coroutines. What I want to accomplish is pull each page, harvest it for links and contents or posts, then feed the links back to the process, until there is nowhere left to go. As of now it has some obvious shortcomings, such no delay enforced between calls or saving addresses and only visiting new ones. But the questions I have are regarding coroutines, here.
Consider the following class,. I've added some toy classes to simulate how it is intended to work, which I won't detail, but you can imagine how they work.
class Scraper(
private val client: Client = ToyClient(delayMillis = 1000, alwaysFindBody = "Test body"),
private val extraction: Extraction = ToyExtraction(
alwaysFindLinks = listOf("https://google.com"),
alwaysFindPosts = listOf("Test post")
),
private val repository: Repository = ToyRepository()
) {
// I could manage my own coroutine scope's lifecycle, but how would I go about this?
// private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
private val seed = "https://google.com"
private val log = KotlinLogging.logger {}
fun start() = runBlocking {
log.info { "Scraping started!" }
scrape(seed).join()
log.info { "Scraping finished!" }
}
private fun CoroutineScope.scrape(address: String): Job = launch(Dispatchers.Default) {
log.info { "A scraping coroutine has started" }
val page = request(address)
val contents = extract(page)
save(contents)
contents.links.forEach { scrape(it) }
// Job would not progress here after submitting new jobs, only after each children have been completed
// log.info { "A scraping coroutine has finished" }
}
private suspend fun request(address: String): Page {
log.info { "Getting page: $address" }
return client.get(address)
}
private suspend fun extract(page: Page): PageContents {
log.info { "Extracting page: ${page.address}" }
return extraction.extract(page)
}
private suspend fun save(contents: PageContents) {
log.info { "Processing contents of: $contents" }
repository.save(contents.posts)
}
}
The main recursive operation is CoroutineScope.scrape() which launches a job, which itself can launch children jobs as well and so on.
My main questions are:
If I were to manage the scope myself as a property, how could I do that and achieve the same behavior? That is, I would wait for all dynamically spawned jobs to complete as well, return when all are finished.
I wrote my webclient's function using a 3rd party library as such:
fun suspend get(address: String): Page { ... }
Am I fine just marking this method as suspend to get all benefits from this in terms of coroutines?
Thanks in advance!
You don't even need a scope for that, launch a top-level job and use job.join() to await until it and all its children are done. If you want to block while waiting for that to happen, then you are already doing it right by using runBlocking.
No, marking a function as suspend doesn't affect its blocking behavior. It only allows the function to suspend itself, which must be explicit either in your code or the code you're calling into.
I run into a problem during writing tests for a viewModel. The problem occurred when I was trying to verify LiveData that is updated with channelFlow flow on Dispatchers.IO.
I created a simple project to show the issue.
There is a data provider class that is providing 10 numbers:
As it is, the numbers variable in the test is empty and the test fails. I know it is a problem with coroutine dispatchers.
val numbersFlow: Flow<Int> = channelFlow {
var i = 0
while (i < 10) {
delay(100)
send(i)
i++
}
}.flowOn(Dispatchers.IO)
a simple viewModel that is collecting data:
class NumbersViewModel: ViewModel() {
private val _numbers: MutableLiveData<IntArray> = MutableLiveData(IntArray(0))
val numbers: LiveData<IntArray> = _numbers
val dataProvider = NumbersProvider()
fun startCollecting() {
viewModelScope.launch(Dispatchers.Main) {
dataProvider.numbersFlow
.onStart { println("start") }
.onCompletion { println("end") }
.catch { exception -> println(exception.message.orEmpty())}
.collect { data -> onDataRead(data) }
}
}
fun onDataRead(data: Int) {
_numbers.value = _numbers.value?.plus(data)
}
}
and the test:
class NumbersViewModelTest {
#get:Rule
var instantTaskExecutorRule = InstantTaskExecutorRule()
#get:Rule
var mainCoroutineRule = MainCoroutineRule()
private lateinit var viewModel: NumbersViewModel
#Before
fun setUp() {
viewModel = NumbersViewModel()
}
#Test
fun `provider_provides_10_values`() {
viewModel.startCollecting()
mainCoroutineRule.advanceTimeBy(2000)
val numbers = viewModel.numbers.value
assertThat(numbers?.size).isEqualTo(10)
}
}
There is a common solution with changing the main dispatcher for test usage but... is there any good solution for dealing with the IO one?
I found a solution with injecting dispatchers everywhere - similarly to how I would inject NumbersProvider using Hilt in a real app - and that enables injecting our test dispatcher when we need it. It works but now I have to inject dispatchers everywhere in the code and I don't really like that if it only serves to solve the testing problem
I tried another solution and created a Singleton which makes all the standard dispatchers available in the production code and which I can configure for tests (by setting every dispatcher to the test one). I like how the resulting source code looks more - there is no additional code in viewModels and data providers but there is this singleton and everyone shouting 'Don't use singletons'
Is there any better option to correctly test code with coroutines?
I am trying to test Kotlin implementation using Flows. I use Kotest for testing. This code works:
ViewModel:
val detectedFlow = flow<String> {
emit("123")
delay(10L)
emit("123")
}
Test:
class ScanViewModelTest : StringSpec({
"when the flow contains values they are emitted" {
val detectedString = "123"
val vm = ScanViewModel()
launch {
vm.detectedFlow.collect {
it shouldBe detectedString
}
}
}
})
However, in the real ViewModel I need to add values to the flow, so I use ConflatedBroadcastChannel as follows:
private val _detectedValues = ConflatedBroadcastChannel<String>()
val detectedFlow = _detectedValues.asFlow()
suspend fun sendDetectedValue(detectedString: String) {
_detectedValues.send(detectedString)
}
Then in the test I try:
"when the flow contains values they are emitted" {
val detectedString = "123"
val vm = ScanViewModel()
runBlocking {
vm.sendDetectedValue(detectedString)
}
runBlocking {
vm.detectedFlow.collect { it shouldBe detectedString }
}
}
The test just hangs and never completes. I tried all kind of things: launch or runBlockingTest instead of runBlocking, putting sending and collecting in the same or separate coroutines, offer instead of send... Nothing seems to fix it. What am I doing wrong?
Update: If I create flow manually it works:
private val _detectedValues = ConflatedBroadcastChannel<String>()
val detectedFlow = flow {
this.emit(_detectedValues.openSubscription().receive())
}
So, is it a bug in asFlow() method?
The problem is that the collect function you used in your test is a suspend function that will suspend the execution until the Flow is finished.
In the first example, your detectedFlow is finite. It will just emit two values and finish. In your question update, you are also creating a finite flow, that will emit a single value and finish. That is why your test works.
However, in the second (real-life) example the flow is created from a ConflatedBroadcastChannel that is never closed. Therefore the collect function suspends the execution forever. To make the test work without blocking the thread forever, you need to make the flow finite too. I usually use the first() operator for this. Another option is to close the ConflatedBroadcastChannel but this usually means modifications to your code just because of the test which is not a good practice.
This is how your test would work with the first() operator
"when the flow contains values they are emitted" {
val detectedString = "123"
val vm = ScanViewModel()
runBlocking {
vm.sendDetectedValue(detectedString)
}
runBlocking {
vm.detectedFlow.first() shouldBe detectedString
}
}