Jacob Rhoda
05/23/2025, 8:42 PMclass LoginViewModel(
private val authController: AuthenticationController,
) : ViewModelContainerHost<LoginState, LoginSideEffect>() {
override val container: Container<LoginState, LoginSideEffect> =
container(
initialState = LoginState.UNINITIALIZED,
) {
observeAuthState()
}
private fun observeAuthState() =
intent(registerIdling = false) {
repeatOnSubscription {
authController.authState
.collect { state ->
reduce {
when (state) {
AuthState.UNINITIALIZED -> LoginState.UNINITIALIZED
AuthState.UNAUTHORIZED -> LoginState.UNAUTHORIZED
AuthState.AUTHORIZED -> LoginState.AUTHORIZED
AuthState.ERROR -> LoginState.ERROR
}
}
}
}
}
}
LoginViewModelTests.kt
class LoginViewModelTests {
val authService =
mock<AuthService>(MockMode.strict) {
}
@Test
fun testConstructorAuthorized() =
runTest {
everySuspend { authService.isAuthorized() } returns true
val authController = AuthenticationController(Logger, authService)
LoginViewModel(authController).test(this, LoginState.UNINITIALIZED) {
runOnCreate()
expectState(LoginState.AUTHORIZED)
}
}
}
Result:
Info: Updating auth state to AUTHORIZED
Timed out waiting for remaining intents to complete for 1s
org.orbitmvi.orbit.test.OrbitTimeoutCancellationException: Timed out waiting for remaining intents to complete for 1s
...
The weird thing is that if I expectState(LoginState.UNAUTHORIZED)
, then it fails because it gets AUTHORIZED
.appmattus
05/24/2025, 12:34 PMappmattus
05/24/2025, 12:47 PMobserveAuthState()
function is nesting intent
blocks. this ends up being bad, one for testing as you're experiencing but also from a structured concurrency point of view - it is like nesting viewModelScope.launch
.
i think ultimately we need to re-write some of our documentation around repeatOnSubscription as the example feels mis-leading.
in terms of solving, the simplest would be to replace intent with subIntent - subIntent uses the same coroutine scope. however, its worth noting if you have espresso tests then currently there's no way to disable idling resource supportJacob Rhoda
05/24/2025, 7:18 PMobserveAuthState
intent might be a holdover from v8. I have been a bit confused by how to collect flows β I didn't realize that the creation block was an intent. I also am unclear what the idling resource is intended to do β your docs have an example both with it, and without it, and the API docs don't make it clear exactly what that parameter does. (Not trying to complain β just giving feedback.)
In terms of changing it from an intent
to a subIntent
I did try that, but to no avail. (It's a normal unit test, not an espresso test.) If I add println
to tell me when different closures are being called, it indicates that the reduce
function is called. But then the test never receives the AUTHORIZED
state and times out.
class LoginViewModel(
private val authController: AuthenticationController,
) : ViewModelContainerHost<LoginState, LoginSideEffect>() {
override val container: Container<LoginState, LoginSideEffect> =
container(
initialState = LoginState.UNINITIALIZED,
) {
println("Container created")
observeAuthState()
}
private suspend fun observeAuthState() =
subIntent {
println("Sub intent started")
authController.authState
.collect { state ->
println("Received auth state $state")
reduce {
println("Reducing for auth state $state")
when (state) {
AuthState.UNINITIALIZED -> LoginState.UNINITIALIZED
AuthState.UNAUTHORIZED -> LoginState.UNAUTHORIZED
AuthState.AUTHORIZED -> LoginState.AUTHORIZED
AuthState.ERROR -> LoginState.ERROR
}
}
}
}
Jacob Rhoda
05/24/2025, 7:19 PMCreating auth controller
Info: Updating auth state to AUTHORIZED
Creating login view model
Testing login view model
Running on create
Expecting state UNINITIALIZED
Received state UNINITIALIZED
Expecting state AUTHORIZED
Container created
Sub intent started
Received auth state AUTHORIZED
Reducing for auth state AUTHORIZED
Mikolaj Leszczynski
05/26/2025, 12:22 PMMikolaj Leszczynski
05/26/2025, 12:24 PMauthController.authState
- the intent will naturally complete once it completes
2. Use subIntent
like you're doing here. Capture the job : val job = runOnCreate()
and job.cancel
it at the end of the test.
3. Use cancelAndIgnoreRemainingItems()
at the end of the testMikolaj Leszczynski
05/26/2025, 12:25 PMContainerHost
easier and more consistentJacob Rhoda
05/26/2025, 12:26 PMMikolaj Leszczynski
05/26/2025, 8:28 PMJacob Rhoda
05/27/2025, 1:33 PMMikolaj Leszczynski
05/27/2025, 1:35 PMJacob Rhoda
05/27/2025, 1:38 PMMikolaj Leszczynski
05/27/2025, 1:44 PM