Compose Testing: ViewModel With Robolectric Demystified

by Andrew McMorgan 56 views

Hey guys! Diving into the world of Android Jetpack Compose testing can feel like stepping into a whole new dimension, especially when you're trying to wrangle ViewModels and dependencies. But fear not! We're going to break down how to write a Compose test with a ViewModel, specifically when you're using Robolectric. So, grab your favorite caffeinated beverage, and let's get started!

Understanding the Challenge of Testing Compose with ViewModels

When it comes to testing Compose, one of the common challenges is dealing with ViewModels and their dependencies. In a typical Android app, your UI, built with Compose, often interacts with a ViewModel to handle business logic and data. The ViewModel, in turn, might depend on other components like repositories or use cases. This intricate web of dependencies makes testing a bit tricky because you want to isolate your UI code and test it in a controlled environment. This is where tools like Robolectric come in handy, allowing you to run Android tests on your development machine without needing an emulator or a real device.

The Core Issue: Dependency Injection

The main hurdle in testing Compose with ViewModels lies in dependency injection. If your ViewModel has dependencies, you need a way to provide those dependencies in your test environment. Frameworks like Hilt simplify this in your app code, but in tests, you might need a more manual approach to control the dependencies and mock them as needed. This ensures that your tests are predictable and focused solely on the Compose code you're testing.

Why Robolectric for Compose Testing?

Robolectric provides a fantastic environment for Compose testing because it allows you to run your tests on the JVM. This means faster test execution times compared to running tests on an emulator or a real device. It also provides shadow implementations of Android framework classes, which means you can interact with Android components in your tests without the overhead of a full Android environment. For Compose, this is particularly useful as it lets you test your UI logic and interactions without the complexities of device-specific behaviors.

Setting Up Your Test Environment

Before we dive into the code, let's set up our testing environment. This involves adding the necessary dependencies and configuring Robolectric. Make sure you have the following dependencies in your build.gradle.kts file:

dependencies {
    testImplementation("junit:junit:4.13.2")
    testImplementation("org.robolectric:robolectric:4.10.3")
    testImplementation("androidx.compose.ui:ui-test-junit4:1.6.4")
    debugImplementation("androidx.compose.ui:ui-tooling:1.6.4")
    debugImplementation("androidx.compose.ui:ui-test-manifest:1.6.4")
    testImplementation("androidx.test:core-ktx:1.5.0")
    testImplementation("androidx.test.ext:junit-ktx:1.1.5")
    testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.8.0")
    // Hilt dependencies
    implementation("com.google.dagger:hilt-android:2.51")
    kapt("com.google.dagger:hilt-android-compiler:2.51")
    testImplementation("com.google.dagger:hilt-android-testing:2.51")
    kaptTest("com.google.dagger:hilt-android-compiler:2.51")
    // Mockito
    testImplementation("org.mockito.kotlin:mockito-kotlin:5.2.1")
    testImplementation("org.mockito:mockito-inline:5.2.0")
}

Key Dependencies Explained

  • JUnit: The foundational testing framework for Java and Kotlin.
  • Robolectric: Enables running Android tests within the JVM.
  • androidx.compose.ui:ui-test-junit4: Provides APIs for testing Compose UIs.
  • androidx.test:core-ktx & androidx.test.ext:junit-ktx: Core testing libraries and JUnit extensions.
  • kotlinx-coroutines-test: Utilities for testing Kotlin coroutines.
  • Hilt dependencies: For dependency injection, including testing artifacts.
  • Mockito: A mocking framework for creating mock dependencies.

With these dependencies added, you're ready to configure your test runner. In your build.gradle.kts file, add the following to your testOptions:

android {
    testOptions {
        unitTests {
            includeAndroidResources = true
        }
    }
}

This configuration tells Robolectric to include Android resources in your tests, which is essential for Compose testing.

Writing Your First Compose Test with a ViewModel

Let's dive into writing a test for a simple Compose component that uses a ViewModel. Suppose you have a MyParentLayout Composable that utilizes MyParentViewModel:

@Composable
fun MyParentLayout(viewModel: MyParentViewModel = hiltViewModel()) {
    MyParentImpl(viewModel = viewModel)
}

@Composable
fun MyParentImpl(viewModel: MyParentViewModel) {
    val state = viewModel.uiState.collectAsState()
    Column {
        Text("Value: ${state.value.data}")
        Button(onClick = { viewModel.updateData() }) {
            Text("Update Data")
        }
    }
}

data class MyUiState(val data: String = "Initial Data")

class MyParentViewModel @Inject constructor(
    private val repository: MyRepository
) : ViewModel() {

    private val _uiState = MutableStateFlow(MyUiState())
    val uiState: StateFlow<MyUiState> = _uiState.asStateFlow()

    fun updateData() {
        viewModelScope.launch {
            val newData = repository.getData()
            _uiState.value = MyUiState(newData)
        }
    }
}

class MyRepository @Inject constructor() {
    suspend fun getData(): String {
        // Simulate network call or database access
        delay(100)
        return "Updated Data"
    }
}

In this example, MyParentLayout uses MyParentViewModel, which retrieves data from MyRepository. Now, let's write a test for this.

Creating the Test Class

First, create a test class. You'll need to annotate it with @HiltAndroidTest and @RunWith(RobolectricTestRunner::class) to enable Hilt and Robolectric:

@HiltAndroidTest
@RunWith(RobolectricTestRunner::class)
class MyParentLayoutTest {

    @get:Rule
    val composeTestRule = createComposeRule()

    @BindValue @JvmField
    val repository: MyRepository = mock()

    @Before
    fun setup() {
        HiltAndroidRule(this).apply { this.inject() }
    }

    @Test
    fun `test MyParentLayout updates data correctly`() {
        // Test logic here
    }
}

Understanding the Test Setup

  • @HiltAndroidTest: This annotation tells Hilt to perform dependency injection for the test.
  • @RunWith(RobolectricTestRunner::class): This annotation tells JUnit to use Robolectric to run the tests.
  • composeTestRule: This rule is provided by androidx.compose.ui:ui-test-junit4 and is essential for testing Compose layouts.
  • @BindValue @JvmField val repository: MyRepository = mock(): This is a crucial part. We're using Hilt's @BindValue to provide a mock implementation of MyRepository. This allows us to control the behavior of the repository in our test.
  • HiltAndroidRule: This rule initializes Hilt for the test and performs injection.

Writing the Test Logic

Now, let's write the actual test logic. We'll mock the MyRepository to return a specific value and then verify that the UI updates correctly.

@Test
fun `test MyParentLayout updates data correctly`() = runTest {
    val mockData = "Mocked Data"
    whenever(repository.getData()).thenReturn(mockData)

    composeTestRule.setContent { MyParentLayout() }

    composeTestRule.onNodeWithText("Value: Initial Data").assertIsDisplayed()
    composeTestRule.onNodeWithText("Update Data").performClick()

    advanceUntilIdle()

    composeTestRule.onNodeWithText("Value: $mockData").assertIsDisplayed()
}

Breaking Down the Test Logic

  • runTest: This is part of kotlinx-coroutines-test and is used to test coroutines in a controlled manner.
  • `val mockData =