I am running into difficulties using `ballast-test...
# ballast
m
I am running into difficulties using
ballast-test
to test
observeFlows()
. I suspect that I'm just missing where/how to trigger the flow updates. Details in đź§µ.
This is my code, with some proprietary bits replaced by
CODE BLOCK
comments:
Copy code
val testDispatcher = UnconfinedTestDispatcher(testCoroutineScheduler)

viewModelTest(
    inputHandler = MainScreenContract.Reducer(),
    eventHandler = MainScreenContract.EventProcessor {}
) {
    defaultInitialState { MainScreenContract.State() }

    scenario("initialization") {
        customizeConfiguration {
            it.dispatchers(
                eventsDispatcher = testDispatcher,
                inputsDispatcher = testDispatcher,
                sideJobsDispatcher = testDispatcher,
                interceptorDispatcher = testDispatcher
            )
        }

        running {
            +MainScreenContract.Input.Initialize
        }

        resultsIn {
            testCoroutineScheduler.advanceUntilIdle()

            // CODE BLOCK A: assert Ballast state for initial StateFlow content

            // CODE BLOCK B: update StateFlow with new content

            testCoroutineScheduler.advanceUntilIdle()

            // CODE BLOCK C: assert Ballast state for updated StateFlow content
        }
    }
}
Near as I can tell, nothing from
running()
happens until we enter
resultsIn()
. Code block A works fine, because I am observing a
StateFlow
, so my
InputHandler
immediately gets the existing state when I
observeFlows()
-- that happens even before my first
advanceUntilIdle()
call. I know that code block B is generally OK, in that I can set up my own observer of that
StateFlow
, and it detects the change that I make in code block B. However, despite the second
advanceUntilIdle()
call, my
map()
inside my
observeFlows()
lambda in my
InputHandler
does not get invoked for the updated
StateFlow
, and as a result my Ballast state remains unchanged. I did not see a test case that tested
observeFlows()
in the repo -- is there an example of this somewhere? Thanks!
c
The implementation of
observeFlows()
is pretty trivial, and I wouldn’t expect there to be any specific issue with that. Maybe there’s something in the flow
merge()
call that could be causing a problem, but I would be surprised. In general, I have found the tests for SideJobs to be fairly unreliable, due to the fact that they run in parallel, so their execution isn’t as tightly controlled as everything else in the VM. Additionally, they don’t get launched immediately, but instead are buffered through a channel, so the start of the sideJob is also somewhat unpredictable compared to normal Input processing. And unfortunately, I haven’t been able to get this fully resolved just yet, as some pretty significant refactoring on the internals is likely needed to get stronger guarantees of the sideJob start/execution. I did recently discover that the dispatchers should be manually set to the
testDispatcher
(as you have done) and that did greatly help the reliability of Ballast’s own tests. you might see if you can add some delays to the test case to see if that can help ensure the sideJob gets started before the
resultsIn { }
block gets called.
m
Delays did not help... at least, not directly. However, I stumbled onto something when testing your
merge()
concern. I created a separate
observeSingleFlow()
function to avoid the
merge()
:
Copy code
public suspend inline fun <
        reified Inputs : Any,
        Events : Any,
        State : Any> InputHandlerScope<Inputs, Events, State>.observeSingleFlow(
        key: String,
        input: Flow<Inputs>,
    ) {
        sideJob(
            key = key,
        ) {
            input
                .onEach { println("XXX ${System.currentTimeMillis()} $it"); postInput(it) }
                .onCompletion { println("XXX ${System.currentTimeMillis()} completion") }
                .launchIn(this)
        }
    }
On a whim, I added the
onCompletion()
call. And, shortly after
onEach()
for my initial state,
onCompletion()
is triggered:
Copy code
XXX 1735823527899 PresetsUpdate(presets=[])
XXX 1735823527943 completion
When I run this code in my real app, I don't get the
onCompletion()
callback while my viewmodel is active (and, FWIW, I receive state updates as expected). Additional logging and delays indicates that
onCompleted()
is called shortly after the
resultsIn()
lambda begins executing, and it seems to be triggered by the first
advanceUntilIdle()
call seen in my original code snippet. When are side jobs cancelled in the context of a Ballast test?
I wound up following a recipe of: • Fire off inputs and affect all observed flows inside the
running()
lambda • Validate the end results in
resultsIn()
, with
testCoroutineScheduler.advanceUntilIdle()
as the first line in
resultsIn()
I hadn't realized that the
resultsIn()
lambda had access to the full roster of states we went through (via
states
). I need to play around with event handling and confirm that I can weave that in as well, but otherwise I think I understand better how to play nicely with ballast-test. Thanks for the help!
hmmmm... feels like event testing is still a WIP -- if I get something working, would you like a PR?
👍 1
c
Afrer a test executes (when the
running { }
block returns), the ViewModel does a "graceful shutdown", which should ensure that all Inputs have been fully processed and all Events have been handled. Side jobs, as mentioned previously, are a bit tricky since they run in parallel to the main VM loop, start unpredictably, and may be infinite. So the graceful shutdown is supposed to give side jobs a short grace period to complete, after which their coroutine scope gets cancelled. After this graceful shutdown process is complete, the tests
resultsIn { }
block is called. This is the general process for how tests run. I haven't ever noticed any issues with normal Inputs or Events with tests and graceful shutdowns, but if the event originated from an Interceptor or SideJob, then it's possible that something isn't being tracked or managed as carefully as it's needs. For regular, real world applications it should still work fine, but tests need quite a bit more control to be reliable, that just isn't fully handled yet within Ballast. If you do find some solutions, I would greatly appreciate a PR for the fix! I'm currently working on updating it to Kotlin 2.x, but have some build tooling I've needed to replace first, which is taking some time (the docs site, and Kotest, which is nice but slow to update to new version of Kotlin). So I can't guarantee any time when the fixes will be in a release at this time
m
> I haven't ever noticed any issues with normal Inputs or Events with tests I do not see an API for testing events: •
BallastScenarioInputSequenceScope
, used for
running()
, only exposes functions related to inputs •
BallastScenarioScope
, used for
scenario()
, exposes
running()
but no equivalent that supports events AFAICT •
BallastTestSuiteScope
, used for
viewModelTest()
, exposes
scenario()
, but nothing that seems like it leads in the direction of testing events • I could just call
handleEvent()
on my
EventHandler
, but then I need an
EventHandlerScope
, and
EventHandlerScopeImpl
is
internal
to the library I see where
BallastScenarioInputSequenceScope
could be extended fairly easily to support events, and that was the direction I was considering pursuing. Am I missing something? UPDATE: I posted a PR with my take on the topic.