Mark Murphy
12/31/2024, 8:50 PMballast-test
to test observeFlows()
. I suspect that I'm just missing where/how to trigger the flow updates. Details in đź§µ.Mark Murphy
12/31/2024, 8:51 PMCODE BLOCK
comments:
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!Casey Brooks
12/31/2024, 9:50 PMobserveFlows()
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.Mark Murphy
01/02/2025, 1:25 PMmerge()
concern. I created a separate observeSingleFlow()
function to avoid the merge()
:
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:
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?Mark Murphy
01/02/2025, 8:35 PMrunning()
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!Mark Murphy
01/02/2025, 9:42 PMCasey Brooks
01/03/2025, 12:32 AMrunning { }
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 timeMark Murphy
01/03/2025, 1:09 PMBallastScenarioInputSequenceScope
, 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.