Mastering StateRestorationTester: Jetpack Compose UI Testing Guide

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 StateRestorationTester is created with createComposeRule().
  • The UI contains a counter displayed in a Text composable.
  • 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:

  • rememberSaveable is 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.