CodeRed
10/17/2025, 9:56 AMTextFieldState and snapshotFlow inside a ViewModel unit test as well as an assessment of my implementation.
My ViewModel is using snapshotFlow on TextFieldState.text to derive error state for the text field it is associated with.
However in unit tests snapshotFlow is only emitting the initial value but does not emit on changes of TextFieldState.text.
data class UiState(
val textState: TextFieldState,
// Will be displayed as supportingText
val textError: String?,
)
class MyViewModel : ViewModel() {
private val _uiState = MutableStateFlow<UiState>(...)
val uiState = _uiState.asStateFlow()
init {
viewModelScope.launch {
val state = _uiState.value
snapshotFlow { state.textState.text }
.debounce(500L) // Debounce to give the user time to finish his writing
.collectLatest {
if (!isValid(it)) {
_uiState.update {
it.copy(textError = "Error")
}
}
}
}
}
}
class MyViewModelTest {
@Test
fun errorTest() = runTest {
val viewModel = MyViewModel()
var state = viewModel.uiState.value
state.textState.setTextAndPlaceCursorAtEnd("Invalid text")
advanceUntilIdle()
state = viewModel.uiState.value
assertThat(state.textError).isEqual("Error")
}
}CodeRed
10/17/2025, 12:18 PMSnapshot.sendApplyNotifications after changes to state helps but it seems tedious and error prone to call on every change of state in unit test.
I copied the implementation of from GlobalSnapshotManager [2] into a JUnit 5 Extension that seems to work to notify the Snapshot system on state changes.
Unfortunately I have problems understanding how Snapshot.registerGlobalWriteObserver mechanism interoperates with state changes from test, to be sure that I have the right implementation.
@OptIn(ExperimentalCoroutinesApi::class)
class SnapshotSchedulerExtension(
private val dispatcher: TestDispatcher,
) : BeforeEachCallback, AfterEachCallback {
private val started = AtomicBoolean(false)
private val sent = AtomicBoolean(false)
private var observerHandle: ObserverHandle? = null
override fun beforeEach(context: ExtensionContext?) {
if (started.compareAndSet(false, true)) {
val channel = Channel<Unit>(1)
CoroutineScope(dispatcher).launch {
channel.consumeEach {
sent.set(false)
Snapshot.sendApplyNotifications()
}
}
observerHandle = Snapshot.registerGlobalWriteObserver {
if (sent.compareAndSet(false, true)) {
channel.trySend(Unit)
}
}
}
}
override fun afterEach(context: ExtensionContext?) {
observerHandle?.dispose()
observerHandle = null
started.set(false)
sent.set(false)
}
}
[1] https://kotlinlang.slack.com/archives/CJLTWPH7S/p1671685168586409
[2] https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:compose/[…]androidx/compose/ui/platform/GlobalSnapshotManager.android.kt