Search code examples
androidandroid-espressoandroid-testingkotlin-flowkotlin-stateflow

How to run a Test synchronously which uses a shareIn Flow?


Scenario

I am testing the emission of a StateFlow in myTest(). The test calls a function viewModel.initiateFlow() which kicks of a flow chain across 3 repositories and increments a buttons text.

Goal

I would like to have the test run sequentially, so that it runs the whole flow synchronously on the testDispatcher and waits for it to collected in ViewModel before continuing with runTest.

Issue

Currently the assertTextEquals is failing because the flow is ran asynchronously and does not have long enough to be collected and updated in MyViewModel().

Below is a diagram and corresponding code which best explains what is happening. FakeRepository2() is using shareIn() which I believe is causing the issue. The long-running code (simulated with delay) in onEach{} is being ran asynchronously on a separate default-dispacther. I am using GlobalScope() for this. I have tried passing in a testScope but in this case onEach{} is never ran. How can I fix this?

enter image description here

Code


@OptIn(ExperimentalCoroutinesApi::class)
@HiltAndroidTest
class MyTest {
    private val testScheduler = TestCoroutineScheduler()
    private val testDispatcher = StandardTestDispatcher(testScheduler)
    private val testScope = TestScope(testDispatcher)

    @get:Rule
    val hiltRule = HiltAndroidRule(this)

    @get:Rule
    val composeRule = createComposeRule()

    private lateinit var repository1: FakeRepository1
    private lateinit var repository2: FakeRepository2
    private lateinit var repository3: FakeRepository3
    private lateinit var viewModel: MyViewModel

    @Before
    fun setUp() {

        repository1 = FakeRepository1()
        repository2 = FakeRepository2(repository1, testScope)
        repository3 = FakeRepository3(repository2)

        viewModel = MyViewModel(repository1, repository3)

        composeRule.setContent {
            AppCompatTheme {
                MyComposableForTest(viewModel = viewModel)
            }
        }
    }

    @Test
    fun myTest() = testScope.runTest {

        composeRule.onRoot(useUnmergedTree = true).printToLog("MY_TAG")

        composeRule.onNodeWithContentDescription("Button One").assertIsDisplayed()

        //start the flow
        viewModel.initiateFlow()

        //assertion fails because Repository2 onEach{} block is taking long time to run and being ran asynchronously outside of test-Dispacther.
        composeRule.onNodeWithContentDescription("Button One")
            .assertTextEquals("1")

        //remaining test code

    }

}



class MyViewModel @Inject constructor(
    private val repository1: IRepository1,
    private val repository3: IRepository3
) : ViewModel() {

    private var _myInt = MutableStateFlow(0)
    val myInt = _myInt.asStateFlow()

    init {
        viewModelScope.launch{
            repository3.getFlow.collect{
                _myInt.value = it
                it.toString()
            }
        }
    }


    fun initiateFlow() = viewModelScope.launch {
        repository1.emitSharedFlow(_myInt.value)
    }

}


class FakeRepository1() : IRepository1 {

    override val _sharedFlow = MutableSharedFlow<Int>()
    override val sharedFlow = _sharedFlow.asSharedFlow()

    override suspend fun emitSharedFlow(myInt: Int) {
        _sharedFlow.emit(myInt)
    }
}


class FakeRepository2 @OptIn(ExperimentalCoroutinesApi::class) constructor(
    private val repository1: FakeRepository1,
    private val scope: TestScope
): IRepository2 {
    @OptIn(DelicateCoroutinesApi::class, ExperimentalCoroutinesApi::class)
    override val getFlow = repository1.sharedFlow
        .onEach {
            //doSomething
            delay(1000)
            delay(2000)
            delay(3000)
        }
        .shareIn(
            GlobalScope,
            SharingStarted.WhileSubscribed()
        )
}


class FakeRepository3(
    private val repository2: FakeRepository2
): IRepository3 {

    override val getFlow = repository2.getFlow
        .map {
            var increment = it
            increment += 1
            increment
        }
}


@AndroidEntryPoint
class MainActivity : ComponentActivity() {

    lateinit var viewModel: MyViewModel

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        viewModel =
            ViewModelProvider(
                this, ViewModelProvider.AndroidViewModelFactory.getInstance(this.application)
            )[MyViewModel::class.java]
        setContent {
            MyComposableForTest(viewModel)
        }

    }
}

@Composable
fun MyComposableForTest(viewModel: MyViewModel) {

    val buttonOne by viewModel.myInt.collectAsState()

    Column(
        modifier = Modifier
            .fillMaxSize()
            .semantics {
                contentDescription = "Button Column"
            },
        horizontalAlignment = Alignment.CenterHorizontally,
        verticalArrangement = Arrangement.Center,
    ) {

        Button(
            modifier = Modifier
                .semantics {
                    contentDescription = "Button One"
                },
            onClick = { viewModel.initiateFlow() }
        ) {
            Text(
                text = buttonOne.toString(),
                modifier = Modifier
                    .semantics {
                        contentDescription = "Button One Text"
                    }
            )
        }
    }

}


Solution

  • To ensure FakeRepository2.onEach{} runs synchronously I used UnconfinedTestDispatcher() to launch coroutines on current (testing) thread immediately as per Testing Kotlin Flows with shareIn().