Search code examples
androidkotlinandroid-jetpack-composegui-testing

Updating TextField throws ComposeNotIdleException


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.


Solution

  • 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:

    • inside init block of MyViewModel, avoid reading the state: _textFieldState.value = TextFieldState(text = "initialText")
    • keep the view model initialization inside setContent and use the remember composable to have the same view model across recompositions