Skip to content

How to test all intermediate emissions despite conflation #3120

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
lukas1 opened this issue Jan 4, 2022 · 25 comments
Closed

How to test all intermediate emissions despite conflation #3120

lukas1 opened this issue Jan 4, 2022 · 25 comments

Comments

@lukas1
Copy link

lukas1 commented Jan 4, 2022

Hi, I'm trying to test events posted to MutableStateFlow. I'd like to see that order of posting of those events is correct. However, documentation of StateFlow states:

Updates to the value are always conflated. So a slow collector skips fast updates, but always collects the most recently emitted value.

https://kotlin.github.io/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines.flow/-state-flow/

And so, this test:

    @Test
    fun mutableStateFlowTest() = runTest(UnconfinedTestDispatcher()) {
        val testedFlow = MutableStateFlow(0)

        val testedValues = mutableListOf<Int>()
        testedFlow
            .onEach { testedValues.add(it) }
            .launchIn(this)

        flowOf(Unit)
            .onStart {
                testedFlow.update { it + 1 }
            }
            .onCompletion { testedFlow.update { it + 1 } }
            .onEach {
                testedFlow.update { it + 1 }
            }
            .launchIn(this)

        assertEquals(
            listOf(0, 1, 2, 3),
            testedValues
        )
    }

fails. testedValues is [0, 3]. That is, it contains only two values first value and the last.

While this is understandable in the light of what the documentation says, I would still like to be able to check that all events were sent to the state flow in the order that I sent them.

But in the real application, I do like the conflation, because indeed it doesn't make sense to redraw UI after every single update to the state flow, if updates are faster than redrawing is.

The reason I'd like to check for the order of emitted events in tests is, if for nothing else, then at least for the fact, that if conflation can occur during tests, it makes tests unstable. A little change in code could mean, that some event will get conflated and the test will be broken, even if the change itself should not change which events are being emitted.

Is there any way to make the test above work? It works with the deprecated TestCoroutineDispatcher and runBlockingTest.

I have tried this, which works:

class ImmediateTestDispatcher : CoroutineDispatcher() {
    override fun dispatch(context: CoroutineContext, block: Runnable) {
        block.run()
    }
}

    @Test
    fun mutableStateFlowTest() = runBlocking(ImmediateTestDispatcher()) {
        val testedFlow = MutableStateFlow(0)

        val testedValues = mutableListOf<Int>()
        val job = testedFlow
            .onEach { testedValues.add(it) }
            .launchIn(this)

        flowOf(Unit)
            .onStart {
                testedFlow.update { it + 1 }
            }
            .onCompletion { testedFlow.update { it + 1 } }
            .onEach {
                testedFlow.update { it + 1 }
            }
            .launchIn(this)

        assertEquals(
            listOf(0, 1, 2, 3),
            testedValues
        )
        job.cancel()
    }

But in practice, this is not very practical approach.

As a background, the example above is a very simplified example of something, that occurs in tests of our application. With the old TestCoroutineDispatcher those tests work correctly, collecting all the events, but with the new test dispatchers from 1.6.0, tests are broken.

I don't want to rewrite every single one of the test cases to account for these skipped values (it's almost 200 test cases that are broken) and as I said, I do think that in context of tests, all events should be recorded anyway, because if I account for the skipped events now, maybe later I might make a change, that will cause some of those skipped events appear again. That would be very brittle.

Thanks.

@lukas1
Copy link
Author

lukas1 commented Jan 4, 2022

btw the ImmediateTestDispatcher approach does work with my actual test cases. But having that kind of Dispatcher doesn't work with use cases where delay() etc is used in the tested code. And implementing Delay interface is considered using the internal coroutines API, which is discouraged.

@mkar-dev
Copy link

mkar-dev commented Jan 4, 2022

I fidgeted a bit with it and from what I understand:

As UnconfinedTestDispatcher doesn't ensure any order of performing tasks, keeping everything under this one Unconfined dispatcher is expected to give you bad results here.

Like Dispatchers.Unconfined, this one does not provide guarantees about the execution order when several coroutines are queued in this dispatcher.

But it also is supposed to resume its coroutines immediately without going through dispatch, you actually also have an example of using it for StateFlow testing in documentation of UnconfinedTestDispatcher

Another typical use case for this dispatcher is launching child coroutines that are resumed immediately, without going through a dispatch; this can be helpful for testing Channel and StateFlow usages.

So I suppose what you want to do is run the collection of the flow with an UnconfinedTestDispatcher, but the rest of the test, the code that is emitting, under a StandardTestDispatcher that lets you precisely control the order of execution

That would leave you with something like this:

    fun mutableStateFlowTest() = runTest(StandardTestDispatcher()) {
        val testedFlow = MutableStateFlow(0)
        val testedValues = mutableListOf<Int>()

        val job = testedFlow
            .onEach { testedValues.add(it) }
            .launchIn(this + UnconfinedTestDispatcher(testScheduler))

        flowOf(Unit)
            .onStart { testedFlow.update { it + 1 } }
            .onCompletion { testedFlow.update { it + 1 } }
            .onEach { testedFlow.update { it + 1 } }
            .launchIn(this)

        runCurrent()

        assertEquals(
            listOf(0, 1, 2, 3),
            testedValues
        )

        job.cancel()
    }

I'm not awfully familiar with StateFlow and new testing mechanics yet, so somebody stronger in this topic could elaborate on the importance of sharing the testScheduler in that scenario (I'll see more about it tomorrow too :) )

@lukas1
Copy link
Author

lukas1 commented Jan 5, 2022

Ok I understand your suggestion, but if that's the case, I simply don't want to stop using TestCoroutineDispatcher and I am greatly unhappy about it being deprecated then.

I'm sure there are some confusing scenarios with the TestCoroutineDispatcher, but for the usual case it produces much better results.

Right now I'd have to redo almost 200 test cases in our code base to fit the new APIs somewhat, making tests unreadable (there would have to be way more runCurrent() statements, one will not cut it in many cases.

And what do I get back for the rework? Nothing.

And I'd be rewriting it into something that uses experimental APIs, so that I get the joy of rewriting those 200 test cases again, if the team decides to change the API again.

@mkar-dev
Copy link

mkar-dev commented Jan 5, 2022

I understand your frustration, I'm somewhat guilty of using experimental api in production too, but we were explicitly warned about it being experimental

If you re satisfied with old api, I'd recommend you just stick to it despite being deprecated.

Regarding multiple calls of runCurrent() there's a chance what could help you is advanceUntilIdle() of TestCoroutineScheduler

@ReginFell
Copy link

ReginFell commented Jan 10, 2022

We also encounter the same issue with 1.6.0.

val progressFlow = MutableStateFlow(false)

fun invokeApiCall() {
  viewModelScope.launch {
      progressFlow.value = true 
      //invokeMockedApi without any delay
      progressFlow.value = false
  }
}

when you collect, you receive only false, when it used to collect false, true, false. So it's not clear how to test intermid states

@lukas1
Copy link
Author

lukas1 commented Jan 10, 2022

@ReginFell

If you don't need to handle delays, check out the implementation of ImmediateCoroutineDispatcher in my first comment. If you set it as main dispatcher, using Dispatchers.setMain(ImmediateCoroutineDispatcher()), it will work.

This is not documented anywhere and it is not the official recommended way to do this (I don't know what is the official way if there even is any in the first place), but it definitely works.

Unfortunately this will not work if you have delays in your coroutines, because this dispatcher does not handle delays. One could try to implement delays by themselves, but the IDE will warn you that you're trying to use internal coroutine API's which is discouraged.

My view is, that either the TestCoroutineDispatcher should not be deprecated or we need another type of dispatcher apart from the provided StandardTestDispatcher and UnconfinedTestDispatcher, one that will immediately execute blocks.

Alternatively you have to change your tests to account for conflation (documentation of StateFlow clearly states, that conflation is to be expected), but that leads to brittle test cases, as it is not going to be easily predictable when conflation will occur and when it will not.

I think something like TestCoroutineDispatcher is simply needed.

@qwwdfsad qwwdfsad added the flow label Jan 10, 2022
@lukas1
Copy link
Author

lukas1 commented Jan 10, 2022

Also one more thing.

While the example by @Bezkarpie works, it does not reflect reality of at least our code base, and probably also not other codebases.

In @Bezkarpie example the state flow needs to be collected by UnconfinedTestDispatcher(), while the flow that fills data into the state flow needs to run on StandardTestDispatcher.

I couldn't get it to work any other way (this could be my own failing entirely).

Often that's not what we want.

Since in at least in context of android development, we rely on viewModelScope, which internally uses Dispatchers.Main.immediate.

If we're also using things, such as asLiveData(), which internally also relies on Dispatchers.Main.immediate, basically it is impossible/impractical to have separate dispatcher for collecting the State Flow (collected as LiveData using asLiveData()) and a dispatcher for launching coroutines in view model, that fill the state flow.

It would mean that every single coroutine that is launched in view model using viewModelScope.launch {} would have to be updated to launch the coroutine from an injected StandardTestDispatcher (and only StandardTestDispatcher, UnconfinedTestDispatcher will not work).

But that's not how we want to have our code structured, it's a lot of boilerplate and it shouldn't be necessary. It wasn't necessary with the old test APIs.

@NitroG42
Copy link

NitroG42 commented Jan 10, 2022

Is the 1.6.0 implementation of TestCoroutineDispatcher still working for you ?
I have a "simple" test for an Android app, which unfortunatelly (because i'm not sure how to do it otherwise) is using viewModelScope, and I have a different behavior between 1.5.2 (success) and 1.6.0 (failure).
I'm not using delay either.
Working on a sample right now.

EDIT : I also have come to the same conclusion about the test of viewmodel in Android, with this new API (which I had nothing against)

@joffrey-bion
Copy link
Contributor

joffrey-bion commented Jan 10, 2022

Is there any way to make the test above work? It works with the deprecated TestCoroutineDispatcher and runBlockingTest.

@lukas1 I don't quite understand the disappointment here, nor the need for the old behaviour. The tests as written here don't seem deterministic. You are launching 2 coroutines, not waiting for anything, and then hoping to have the results in a list that you update concurrently.

Making this test work by dispatcher magic (unconfined, immediate, and the likes) really doesn't seem like the right way to approach the problem IMO. The test just doesn't read well in the first place. If I see launch I don't rely on immediate execution in tests anymore than in production.

If you want to force other coroutines execution, you should use facilities like runCurrent and advanceUntilIdle as part of the test, which makes it read better because we see what's going on.

A little change in code could mean, that some event will get conflated and the test will be broken, even if the change itself should not change which events are being emitted.
(...)
if I account for the skipped events now, maybe later I might make a change, that will cause some of those skipped events appear again. That would be very brittle.

Relying on special dispatcher implementations is just as brittle as that. Adding suspension points in production code could change the behaviour too and break those tests. I'm not arguing that you should account for skipped events, I'm saying that you should make sure all events happen by inserting runCurrent or advanceUntilIdle in appropriate places, or just test the last state (which is after all what state flow is about) - and the current state of tests is not doing that.

@lukas1
Copy link
Author

lukas1 commented Jan 10, 2022

you should make sure all events happen by inserting runCurrent or advanceUntilIdle

This does not work however; If you have example such as the one provided by @ReginFell , using runCurrent() will conflate the state to the last state. So StandardTestDispatcher does not help.

or just test the last state

Again, this is not appropriate. Again the example by @ReginFell shows precisely why we don't want to just test for the last state; We want to make sure that the state update happens. That it is not accidentally skipped by wrong implementation.

It's not possible/practical to add suspension points before EVERY update to the state flow.

Relying on special dispatcher implementations is just as brittle as that.

I disagree. Relying on documented dispatcher behaviour is precisely what we should do. There should be a specific dispatcher that guarantees order of execution as if the code was completely blocking. If that were the case, there'd be no confusion about order of execution or anything.

The test just doesn't read well in the first place.

Actually it reads much better than suggested alternatives with using runCurrent(). Because the resulting code is pretty much the same, but additional dance around dispatchers is needed, which was NOT needed before. The tests up until now were very easy to read.

@lukas1
Copy link
Author

lukas1 commented Jan 10, 2022

@NitroG42 I also had some such tests, that stopped working even with the deprecated TestCoroutineScope. I think the issue in those failing tests was when trying to read latest value from StateFlow.

I plan to study these cases more closely, as I do not know what exactly is going on there, why it fails, but at least for now I was able to fix those cases by using the ImmediateCoroutineDispatcher that I have posted in this thread. I'm not sure if it's the right solution, but since it works, that's what I ended up with for now. I want to research this further though.

@joffrey-bion
Copy link
Contributor

This does not work however; If you have example such as the one provided by @ReginFell , using runCurrent() will conflate the state to the last state

I'm talking about running coroutines between state updates, not doing a single runCurrent in the wild. This means crafting test cases with detailed state updates.

We want to make sure that the state update happens. That it is not accidentally skipped by wrong implementation.

This really depends. Most of the time doing this is like testing StateFlow itself, which shouldn't be the point of the tests.

It's not possible/practical to add suspension points before EVERY update to the state flow.

It's exactly what should be done in a test that is self-explanatory. If you want to test updates at every step, the test should read as such. update + advanceUntilIdle + verify - pretty basic.

I guess it would help to see the real life application of those tests, because these hypothetical tests seem strange (at least to me). For instance this part to drive the test case seem unnecessarily complicated:

        flowOf(Unit)
            .onStart {
                testedFlow.update { it + 1 }
            }
            .onCompletion { testedFlow.update { it + 1 } }
            .onEach {
                testedFlow.update { it + 1 }
            }
            .launchIn(this)

And I'm pretty sure it's written like this here because in your real test you can't just call testFlow.update { ... } a few times, which would 100% be my recommendation when seeing the test as it is now. I have trouble seeing which part of your test is actually the subject under test and which part defines the test case.

@mkar-dev
Copy link

@lukas1 @ReginFell
Honestly, I can't see where's the impossible to solve issue you are experiencing.
I checked @ReginFell example, which doesn't unfortunately include an example for failing test - it would be really helpful if you include failing test code

Anyway, I ended up with something like this, I also checked some simple delays and it works ok:

@Before
    fun setUp() {
        Dispatchers.setMain(StandardTestDispatcher())
    }

  @After
  fun tearDown() {
      Dispatchers.resetMain()
  }

  @Test
  fun testVM() = runTest {
      val viewModel = ExampleViewModel()
      val results = mutableListOf<Boolean>()

      val job = viewModel.progressFlow
          .onEach { results.add(it) }
          .launchIn(this + UnconfinedTestDispatcher(testScheduler))

      viewModel.invokeApiCall()

      advanceUntilIdle()

      assertEquals(
          listOf(false, true, false),
          results
      )

      job.cancel()
  }

  class ExampleViewModel : ViewModel() {
      val progressFlow = MutableStateFlow(false)

      fun invokeApiCall() {
          viewModelScope.launch {
              delay(2000)
              progressFlow.value = true
              delay(2000)
              progressFlow.value = false
          }
      }
  }

And it works just fine.
I agree that it could be prettier, you might try to write some wrapping code to reduce boilerplate
But it does work as far as I am concerned
With the same exact solution as I proposed previously, without any need to pass a dispatcher to launch inside the viewModel (which is a standard since a long time as far as I am concerned anyway)

If you re experiencing any issues that can not be solved with it, please, provide a full minimal example including the test and tested code
I'm happy to help and explore the topic as I'm curious myself
But I have to see something that's not working with current mechanics first

@lukas1
Copy link
Author

lukas1 commented Jan 10, 2022

@Bezkarpie

this works because you added delay into ExampleViewModel. Take it out and see how it works.

Also, add another property val progressFlowAsLiveData = progressFlow.asLiveData() and test values from that

@mkar-dev
Copy link

Please, put this in your IDE and check it first before making such statements.

@NitroG42
Copy link

Just did a sample project about the Android ViewModel issue : https://github.com/NitroG42/Coroutine160Test
I think it also fits here as I'm not sure how to test all the emissions with the new API (I will use this project as a trial to make the test works, but in my trial it always ended with different issues).

@lukas1
Copy link
Author

lukas1 commented Jan 10, 2022

@Bezkarpie

I apologize, I should have tested it before making the comment. I responded in a haste.

That said, I have created an example where the approach using StandardTestDispatcher fails, but TestCoroutineDispatcher works.

It's a very simplified example of something that happens in our application a lot.

Again, it is possible, that I'm using the new APIs wrong.

Here's the example:

class DummyTest {
    @Test
    fun dummyTest() {
        ArchTaskExecutor.getInstance()
            .setDelegate(object : TaskExecutor() {
                override fun executeOnDiskIO(runnable: Runnable) = runnable.run()

                override fun postToMainThread(runnable: Runnable) = runnable.run()

                override fun isMainThread(): Boolean = true
            })
        val testScheduler = TestCoroutineScheduler()
        Dispatchers.setMain(StandardTestDispatcher(testScheduler))
        //Dispatchers.setMain(UnconfinedTestDispatcher(testScheduler))
        //Dispatchers.setMain(TestCoroutineDispatcher())

        val collectedStates = mutableListOf<DummyViewModel.State>()

        val viewModel = DummyViewModel(flowOf(3))

        viewModel.stateAsLiveData.observeForever {
            collectedStates.add(it)
        }

        viewModel.runTest()
        testScheduler.runCurrent()

        assertEquals(
            listOf(
                DummyViewModel.State(),
                DummyViewModel.State(
                    isLoading = true,
                    data = null
                ),
                DummyViewModel.State(
                    isLoading = true,
                    data = 3
                ),
                DummyViewModel.State(
                    isLoading = false,
                    data = 3
                ),
            ),
            collectedStates
        )

        Dispatchers.resetMain()
    }


    class DummyViewModel(
        private val dataFlow: Flow<Int>
    ) : ViewModel() {
        data class State(
            val isLoading: Boolean = false,
            val data: Int? = null
        )

        private val stateFlow = MutableStateFlow(State())
        val stateAsLiveData = stateFlow.asLiveData()

        fun runTest() {
            dataFlow
                .onStart { stateFlow.update { it.copy(isLoading = true) } }
                .onCompletion { stateFlow.update { it.copy(isLoading = false) } }
                .onEach { current -> stateFlow.update { it.copy(data = current) } }
                .launchIn(viewModelScope)
        }
    }
}

This test works fine if you comment out the line with StandardTestDispatcher set as Main dispatcher and uncomment instead the line that uses TestCoroutineDispatcher. I've also tried UnconfinedTestDispatcher although I had no reason to think that that approach would work.

Let me know if you have some ideas how to make my example work, please. In the meantime I will try to also come up with some solution.

Thank you for your time.

@lukas1
Copy link
Author

lukas1 commented Jan 10, 2022

@Bezkarpie btw indeed you approach works if testing the StateFlow directly. But with the asLiveData() in the mix, it does not work.

@lukas1
Copy link
Author

lukas1 commented Jan 10, 2022

@joffrey-bion

I guess it would help to see the real life application of those tests

Please take a look at my example code above. Maybe you have some idea how to structure this test better with StandardTestDispatcher that would avoid issues with conflation? Thanks.

@lukas1
Copy link
Author

lukas1 commented Jan 10, 2022

after some trial and error, I was able to make this work:

class DummyTest {
    @Test
    fun dummyTest() {
        ArchTaskExecutor.getInstance()
            .setDelegate(object : TaskExecutor() {
                override fun executeOnDiskIO(runnable: Runnable) = runnable.run()

                override fun postToMainThread(runnable: Runnable) = runnable.run()

                override fun isMainThread(): Boolean = true
            })
        val testScheduler = TestCoroutineScheduler()
        Dispatchers.setMain(StandardTestDispatcher(testScheduler))
        //Dispatchers.setMain(UnconfinedTestDispatcher(testScheduler))
        //Dispatchers.setMain(TestCoroutineDispatcher())

        val collectedStates = mutableListOf<DummyViewModel.State>()

        val viewModel = DummyViewModel(
            flow {
                delay(100)
                emit(3)
                delay(100)
            }
        )

        viewModel.stateAsLiveData.observeForever {
            collectedStates.add(it)
        }

        viewModel.runTest()

        testScheduler.advanceUntilIdle()

        assertEquals(
            listOf(
                DummyViewModel.State(),
                DummyViewModel.State(
                    isLoading = true,
                    data = null
                ),
                DummyViewModel.State(
                    isLoading = true,
                    data = 3
                ),
                DummyViewModel.State(
                    isLoading = false,
                    data = 3
                ),
            ),
            collectedStates
        )

        Dispatchers.resetMain()
    }

    class DummyViewModel(
        private val dataFlow: Flow<Int>
    ) : ViewModel() {
        data class State(
            val isLoading: Boolean = false,
            val data: Int? = null
        )

        private val stateFlow = MutableStateFlow(State())
        val stateAsLiveData = stateFlow.asLiveData()

        fun runTest() {
            dataFlow
                .onStart { stateFlow.update { it.copy(isLoading = true) } }
                .onCompletion { stateFlow.update { it.copy(isLoading = false) } }
                .onEach { current -> stateFlow.update { it.copy(data = current) } }
                .launchIn(viewModelScope)
        }
    }
}

The relevant changed part is actually this:

        val viewModel = DummyViewModel(
            flow {
                delay(100)
                emit(3)
                delay(100)
            }
        )

Adding a delay before and after emit. It's a bit weird, but it does simulate at least somewhat real life situations. So maybe this is the solution.

I find it ugly though, so whenever possible, I'd probably stick with my ImmediateCoroutineDispatcher, as it is much simpler to use. What do you guys think?

@joffrey-bion
Copy link
Contributor

joffrey-bion commented Jan 10, 2022

Thanks for sharing actual code, I guess I would use the test flow provided as dataFlow to control the test here.

For instance, use a channel to control when the source flow emits and completes:

class MockSourceFlow<T> {

    private val channel = Channel<T>()

    val flow = channel.consumeAsFlow()

    suspend fun simulateData(value: T) {
        channel.send(value)
    }

    fun simulateCompletion(throwable: Throwable? = null) {
        channel.close(throwable)
    }
}

And then force other coroutines to run before checking intermediate state updates:

Click to expand the test implementation
class DummyTest {

    @OptIn(ExperimentalCoroutinesApi::class)
    @Test
    fun dummyTest() = runTest {
        ArchTaskExecutor.getInstance().setDelegate(object : TaskExecutor() {
            override fun executeOnDiskIO(runnable: Runnable) = runnable.run()
            override fun postToMainThread(runnable: Runnable) = runnable.run()
            override fun isMainThread(): Boolean = true
        })
        Dispatchers.setMain(StandardTestDispatcher(testScheduler))

        val testDataSource = MockSourceFlow<Int>()
        val viewModel = DummyViewModel(testDataSource.flow)
        viewModel.stateAsLiveData.observeForever {} // ensures the livedata actually runs

        advanceUntilIdle()
        assertEquals(DummyViewModel.State(), viewModel.stateAsLiveData.value)

        viewModel.runTest()
        advanceUntilIdle()

        val expectedLoadingStateNoValue = DummyViewModel.State(
            isLoading = true,
            data = null
        )
        assertEquals(expectedLoadingStateNoValue, viewModel.stateAsLiveData.value)

        testDataSource.simulateData(3)
        advanceUntilIdle()

        val expectedLoadingStateWithValue = DummyViewModel.State(
            isLoading = true,
            data = 3
        )
        assertEquals(expectedLoadingStateWithValue, viewModel.stateAsLiveData.value)

        testDataSource.simulateCompletion()
        advanceUntilIdle()

        val expectedCompletedStateWithValue = DummyViewModel.State(
            isLoading = false,
            data = 3
        )
        assertEquals(expectedCompletedStateWithValue, viewModel.stateAsLiveData.value)

        Dispatchers.resetMain()
    }


    class DummyViewModel(
        private val dataFlow: Flow<Int>
    ) : ViewModel() {
        data class State(
            val isLoading: Boolean = false,
            val data: Int? = null
        )

        private val stateFlow = MutableStateFlow(State())
        val stateAsLiveData = stateFlow.asLiveData()

        fun runTest() {
            dataFlow
                .onStart { stateFlow.update { it.copy(isLoading = true) } }
                .onCompletion { stateFlow.update { it.copy(isLoading = false) } }
                .onEach { current -> stateFlow.update { it.copy(data = current) } }
                .launchIn(viewModelScope)
        }
    }
}

This is what I mean by not brittle. I don't believe you should use immediate dispatchers to work around the expected behaviour of state flows, but rather make it explicit in the test that what you want is to assume other coroutines have progressed at this point in the test, and then assert. This also allows you to verify that the value you're expecting in the live data is actually there when you expect it to be.

@lukas1
Copy link
Author

lukas1 commented Jan 10, 2022

Indeed this approach does seem to make sense, although it is quite verbose.

@joffrey-bion
Copy link
Contributor

joffrey-bion commented Jan 10, 2022

It is slightly longer indeed, but it also is more accurate. It will assert the proper things at the proper time, and when it fails you'll see exactly at which point, which can't be said with the single-list-assert approach.

@dkhalanskyjb
Copy link
Collaborator

Hi! I'm the maintainer of the test module, so direct the criticisms here.

Thank you @joffrey-bion for handling this! Everything's pretty much as you say.

An official way to test channels, MutableStateFlow, Flow, etc., without conflating/dropping intermediate values is by launching the collecting side in UnconfinedTestDispatcher(testScheduler), like @Bezkarpie suggested. This way, the sending operations will "themselves" ensure the delivery of the elements to the collecting side, and the conflation does not happen. If you needed to reliably observe immediate values in production, you would need to do roughly the same with Dispatchers.Unconfined or Dispatchers.Main.immediate.

Adding delays to emulate context switches between different coroutines is also a fine approach, as is using advanceUntilIdle or advanceTimeBy to strategically ensure that the other coroutines finished their current portion of the work.

A guiding principle for the new API was that tests should somehow reflect how the code would behave in production. In contrast, no actual dispatchers (I think?) behave exactly like TestCoroutineDispatcher does. This led to code working or not working in tests not reflecting how it would run in production, so many people avoided the test module in favor of runBlocking (though not being able to use withContext from runBlockingTest also contributed to this).

The code in the opening post now fails, and this is intentional, because, if we look at it with fresh eyes, it should fail: one wouldn't observe this behavior in real life consistently, and one wouldn't observe it at all unless in a multithreaded dispatcher.

All that said, we're open to introducing something with the behavior equivalent to TestCoroutineDispatcher if there are good reasons to keep it, but we haven't seen good arguments in its favor yet.

@dkhalanskyjb dkhalanskyjb changed the title How to test all emissions from MutableStateFlow How to test all intermediate emissions despite conflation Jan 14, 2022
@dkhalanskyjb
Copy link
Collaborator

The master issue for this topic is now #3939, closing this one in its favor.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests

7 participants