Jetpack Compose has revolutionized Android UI development with its declarative approach. One crucial aspect of building robust Compose applications is ensuring state restoration, particularly across configuration changes like screen rotations. The StateRestorationTester class in androidx.compose.ui:ui-test-junit4 provides an invaluable tool for validating state persistence and restoration behavior. In this post, we’ll dive into what StateRestorationTester is, why it’s important, and how to effectively use it with detailed code samples.
What is StateRestorationTester?
StateRestorationTester is a test utility specifically designed to simulate state restoration scenarios in Jetpack Compose UI tests. It can be used to wrap a Composable under test and automate triggering a state restoration, typically mimicking configuration changes. The StateRestorationTester allows developers to confirm that UI states survive such events as expected, avoiding unexpected data loss or UI inconsistencies.
Why is State Restoration Important?
- User Experience: Maintains UI state across configuration changes, offering a seamless user experience.
- Data Integrity: Prevents data loss or UI resets, ensuring data displayed to users remain consistent and correct.
- Application Reliability: Handles configuration changes gracefully, avoiding unexpected application behavior or crashes.
- Testability: Facilitates writing reliable UI tests that validate state restoration.
Setting Up Your Project
Before diving into examples, make sure you have the necessary dependencies added to your build.gradle file:
dependencies {
androidTestImplementation("androidx.compose.ui:ui-test-junit4:1.6.1")
debugImplementation("androidx.compose.ui:ui-tooling:1.6.1")
debugImplementation("androidx.compose.ui:ui-test-manifest:1.6.1")
// Add Compose dependencies
implementation("androidx.compose.ui:ui:1.6.1")
implementation("androidx.compose.material:material:1.6.1")
implementation("androidx.compose.runtime:runtime-livedata:1.6.1")
androidTestImplementation("androidx.test:runner:1.5.2")
androidTestImplementation("androidx.test:rules:1.5.0")
}
Ensure the versions match the latest available stable release. As of writing, version 1.6.1 is used, but remember to check for updates.
Basic Usage of StateRestorationTester
Here’s a basic example to get you started:
import androidx.compose.material.Text
import androidx.compose.runtime.*
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.junit4.StateRestorationTester
import org.junit.Rule
import org.junit.Test
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
class StateRestorationTest {
@get:Rule
val composeTestRule = createComposeRule()
@Test
fun testStateRestoration() {
val restorationTester = StateRestorationTester(composeTestRule)
var counter by mutableStateOf(0)
composeTestRule.setContent {
Text("Counter: $counter")
}
restorationTester.run {
composeTestRule.onNodeWithText("Counter: 0").assertExists()
counter = 5
composeTestRule.onNodeWithText("Counter: 5").assertExists()
restore()
composeTestRule.onNodeWithText("Counter: 5").assertExists() // Check restored value
}
}
}
In this example:
- A
StateRestorationTesteris created withcreateComposeRule(). - The UI contains a counter displayed in a
Textcomposable. - The
restore()function simulates state restoration, checking that the value persists across the restoration.
Testing Complex UI State
Let’s delve into a more comprehensive scenario, involving user interaction and complex states:
import androidx.compose.material.Button
import androidx.compose.material.Text
import androidx.compose.runtime.*
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.junit4.StateRestorationTester
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import org.junit.Rule
import org.junit.Test
class StateRestorationInteractionTest {
@get:Rule
val composeTestRule = createComposeRule()
@Test
fun testCounterStateRestoration() {
val restorationTester = StateRestorationTester(composeTestRule)
var counter by rememberSaveable { mutableStateOf(0) }
composeTestRule.setContent {
Button(onClick = { counter++ }) {
Text("Increment")
}
Text("Counter: $counter")
}
restorationTester.run {
composeTestRule.onNodeWithText("Counter: 0").assertExists()
composeTestRule.onNodeWithText("Increment").performClick()
composeTestRule.onNodeWithText("Counter: 1").assertExists()
restore()
composeTestRule.onNodeWithText("Counter: 1").assertExists()
}
}
}
Here, we’ve included user interaction. Clicking the “Increment” button increases the counter, and StateRestorationTester ensures that the state (updated count value) is restored correctly.
Understanding rememberSaveable
The composable function rememberSaveable plays a pivotal role in saving UI state. It automatically saves and restores the state even after activity recreation. In our enhanced example:
import androidx.compose.material.Button
import androidx.compose.material.Text
import androidx.compose.runtime.*
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.junit4.StateRestorationTester
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import org.junit.Rule
import org.junit.Test
class StateRestorationRememberSaveableTest {
@get:Rule
val composeTestRule = createComposeRule()
@Test
fun testCounterStateRestorationUsingRememberSaveable() {
val restorationTester = StateRestorationTester(composeTestRule)
composeTestRule.setContent {
var counter by rememberSaveable { mutableStateOf(0) }
Button(onClick = { counter++ }) {
Text("Increment")
}
Text("Counter: $counter")
}
restorationTester.run {
composeTestRule.onNodeWithText("Counter: 0").assertExists()
composeTestRule.onNodeWithText("Increment").performClick()
composeTestRule.onNodeWithText("Counter: 1").assertExists()
restore()
composeTestRule.onNodeWithText("Counter: 1").assertExists()
}
}
}
Key observations:
rememberSaveableis utilized to automatically save and restore the state across configuration changes.- Interactions via UI tests ensure state persistence following user input.
Advanced Usage Scenarios
1. Custom Saver Objects
Sometimes, you might need to serialize custom data types. In these cases, Saver objects can be passed to rememberSaveable.
import androidx.compose.material.Text
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.Saver
import androidx.compose.runtime.saveable.listSaver
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.junit4.StateRestorationTester
import org.junit.Rule
import org.junit.Test
data class CustomState(val name: String, val age: Int)
val customStateSaver: Saver = listSaver(
save = { listOf(it.name, it.age) },
restore = { CustomState(it[0] as String, it[1] as Int) }
)
class CustomSaverStateRestorationTest {
@get:Rule
val composeTestRule = createComposeRule()
@Test
fun testCustomStateRestoration() {
val restorationTester = StateRestorationTester(composeTestRule)
composeTestRule.setContent {
var customState by rememberSaveable(stateSaver = customStateSaver) {
mutableStateOf(CustomState("Alice", 30))
}
Text("Name: ${customState.name}, Age: ${customState.age}")
}
restorationTester.run {
composeTestRule.onNodeWithText("Name: Alice, Age: 30").assertExists()
restore()
composeTestRule.onNodeWithText("Name: Alice, Age: 30").assertExists()
}
}
}
2. ViewModel State Restoration
State restoration also extends to scenarios involving ViewModel. Here is an example using LiveData with ViewModel:
import androidx.compose.material.Text
import androidx.compose.runtime.*
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.junit4.StateRestorationTester
import org.junit.Rule
import org.junit.Test
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewmodel.compose.viewModel
class MyViewModel : ViewModel() {
private val _counter = MutableLiveData(0)
val counter: LiveData = _counter
fun increment() {
_counter.value = (_counter.value ?: 0) + 1
}
}
class ViewModelStateRestorationTest {
@get:Rule
val composeTestRule = createComposeRule()
@Test
fun testViewModelStateRestoration() {
val restorationTester = StateRestorationTester(composeTestRule)
composeTestRule.setContent {
val viewModel: MyViewModel = viewModel()
val counter by viewModel.counter.observeAsState(initial = 0)
Text("Counter: $counter")
}
restorationTester.run {
composeTestRule.onNodeWithText("Counter: 0").assertExists()
restore()
composeTestRule.onNodeWithText("Counter: 0").assertExists()
}
}
}
Best Practices and Tips
- Always use
rememberSaveable: When you need to save UI state across configuration changes. - Comprehensive Testing: Write tests covering various scenarios and complex states to guarantee robustness.
- Follow Compose Guidelines: Stick to Jetpack Compose best practices for managing states efficiently.
Conclusion
The StateRestorationTester tool in Jetpack Compose offers a robust solution for validating UI state restoration. By simulating configuration changes and employing best practices with rememberSaveable, developers can ensure reliable, seamless experiences in Android Compose applications. Properly testing state restoration guarantees applications behave predictably and professionally, enhancing overall user satisfaction.