When calling AndroidComposeTestRule.performTextInput
on a TextField with initial state, ComposeNotIdleException
is thrown. Code to replicate the issue:
Production code:
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val viewModel = MyViewModel()
setContent {
MyAppTheme {
ScreenContent(viewModel)
}
}
}
}
@Composable
fun ScreenContent(viewModel: MyViewModel) {
TextField(
modifier = Modifier.semantics { contentDescription = "TextField" },
value = viewModel.textFieldState.value.text,
onValueChange = { viewModel.updateTextState(it) },
)
}
class MyViewModel : ViewModel() {
private val _textFieldState = mutableStateOf(TextFieldState())
val textFieldState = _textFieldState
init {
viewModelScope.launch {
_textFieldState.value = _textFieldState.value.copy(text = "initialText")
}
}
fun updateTextState(newText: String) {
_textFieldState.value = _textFieldState.value.copy(text = newText)
}
}
data class TextFieldState(
val text: String = "",
val showError: Boolean = false,
)
Test code:
@RunWith(AndroidJUnit4::class)
class ExampleInstrumentedTest {
@get:Rule
val composeRule = createAndroidComposeRule<ComponentActivity>()
@Test
fun testIdlingTextField() {
composeRule.setContent { ScreenContent(MyViewModel()) }
composeRule.onNodeWithContentDescription("TextField").performTextInput("newText")
composeRule.onNodeWithText("newText").assertIsDisplayed()
}
}
The above test passes when I'm just using a simple String instead of TextFieldState
. I would really appreciate it if someone could give me an explanation of why this is happening.
After investigating the problem further, it turned out that the issue was the initialization of the view model. Just initialize the view model outside composeRule's setContent
:
@Test
fun testIdlingTextField() {
val viewModel: MyViewModel = MyViewModel()
composeRule.setContent { ScreenContent(viewModel) }
composeRule.onNodeWithContentDescription("TextField").performTextInput("newText")
composeRule.onNodeWithText("newText").assertIsDisplayed()
}
Explanation: according to the official doc, Compose keeps track of state changes by checking which composables read that state. When the view model is created inside the setContent
composable, it reads _textFieldState
(inside the init
block of MyViewModel
). So when performTextInput
changes the _textFieldState
, the setContent
gets recomposed and a new view model gets created again, causing the infinite recomposition.
PS: Other solutions to solve this problem are:
init
block of MyViewModel, avoid reading the state: _textFieldState.value = TextFieldState(text = "initialText")
setContent
and use the remember
composable to have the same view model across recompositions