Jetpack Compose: Navigation Testing with TestNavHostController

In Jetpack Compose, navigation plays a pivotal role in managing the application’s flow between different screens. The TestNavHostController is an invaluable tool for testing navigation within your Compose applications. It enables you to programmatically control and verify navigation actions in a controlled environment. This post will guide you through the intricacies of using TestNavHostController to perform robust navigation testing in your Jetpack Compose projects.

What is TestNavHostController?

TestNavHostController is a testing utility provided by the androidx.compose.ui:ui-test-junit4 artifact, designed specifically for testing navigation in Jetpack Compose applications. Unlike a regular NavHostController, it allows you to:

  • Programmatically Control Navigation: Push and pop routes directly.
  • Inspect Navigation State: Verify the current back stack and the current route.
  • Isolate Testing: Avoid real UI interaction, making tests faster and more reliable.

Why Use TestNavHostController for Navigation Testing?

Testing navigation thoroughly ensures your users can smoothly move between different parts of your app. Here’s why TestNavHostController is essential:

  • Reliability: Ensures that navigation behaves as expected, especially after refactoring.
  • Efficiency: Quickly test complex navigation flows without manual UI interaction.
  • Consistency: Provides consistent test results by avoiding unpredictable UI behavior.

How to Implement Navigation Testing with TestNavHostController

Let’s dive into how you can implement navigation testing using TestNavHostController in your Jetpack Compose application.

Step 1: Add Dependencies

First, add the necessary dependencies to your build.gradle file:

dependencies {
    // UI Test Dependencies
    androidTestImplementation("androidx.compose.ui:ui-test-junit4:1.5.4")
    debugImplementation("androidx.compose.ui:ui-tooling:1.5.4")
    debugImplementation("androidx.compose.ui:ui-test-manifest:1.5.4")

    // Navigation Compose
    implementation("androidx.navigation:navigation-compose:2.7.6")

    // JUnit
    testImplementation("junit:junit:4.13.2")
    androidTestImplementation("androidx.test.ext:junit:1.1.5")
    androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1")
}

Ensure that you sync the Gradle file after adding the dependencies.

Step 2: Define Navigation Graph

Create a simple navigation graph for testing. Here’s an example using Compose navigation:


import androidx.compose.runtime.Composable
import androidx.navigation.compose.rememberNavController
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.compose.material.Text
import androidx.compose.ui.test.junit4.createComposeRule
import org.junit.Rule
import androidx.navigation.NavController

// Define destinations
const val ScreenA = "screenA"
const val ScreenB = "screenB"

@Composable
fun AppNavigation(navController: NavController) {
    NavHost(navController = navController, startDestination = ScreenA) {
        composable(ScreenA) {
            Text("Screen A")
        }
        composable(ScreenB) {
            Text("Screen B")
        }
    }
}

Step 3: Set Up TestNavHostController in Your Test

Create a test class and use createComposeRule to set up the Compose environment. Initialize TestNavHostController within the test.


import androidx.compose.ui.test.junit4.createComposeRule
import androidx.navigation.testing.TestNavHostController
import androidx.compose.ui.platform.LocalContext
import org.junit.Rule
import org.junit.Test
import org.junit.Assert.assertEquals

class NavigationTest {
    @get:Rule
    val composeTestRule = createComposeRule()

    @Test
    fun testNavigation() {
        lateinit var navController: TestNavHostController

        composeTestRule.setContent {
            navController = TestNavHostController(LocalContext.current)
            AppNavigation(navController = navController)
        }

        // Initial route should be ScreenA
        assertEquals(ScreenA, navController.currentBackStackEntry?.destination?.route)
    }
}

Step 4: Implement Navigation Actions

Modify your navigation structure to include buttons or actions that trigger navigation events. For instance, add a button in Screen A to navigate to Screen B:


import androidx.compose.material.Button
import androidx.compose.runtime.Composable
import androidx.navigation.NavController

@Composable
fun ScreenAView(navController: NavController) {
    Button(onClick = { navController.navigate(ScreenB) }) {
        Text("Go to Screen B")
    }
}

@Composable
fun AppNavigation(navController: NavController) {
    NavHost(navController = navController, startDestination = ScreenA) {
        composable(ScreenA) {
            ScreenAView(navController = navController)
        }
        composable(ScreenB) {
            Text("Screen B")
        }
    }
}

Step 5: Test Navigation Actions

Update your test class to verify that the navigation action correctly navigates to the target screen.


import androidx.compose.ui.test.junit4.createComposeRule
import androidx.navigation.testing.TestNavHostController
import androidx.compose.ui.platform.LocalContext
import org.junit.Rule
import org.junit.Test
import org.junit.Assert.assertEquals
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.onNodeWithText

class NavigationTest {
    @get:Rule
    val composeTestRule = createComposeRule()

    @Test
    fun testNavigationToScreenB() {
        lateinit var navController: TestNavHostController

        composeTestRule.setContent {
            navController = TestNavHostController(LocalContext.current)
            AppNavigation(navController = navController)
        }

        // Click the button to navigate to ScreenB
        composeTestRule.onNodeWithText("Go to Screen B").performClick()

        // Verify that we navigated to ScreenB
        assertEquals(ScreenB, navController.currentBackStackEntry?.destination?.route)
    }
}

This test now clicks the button that triggers navigation and asserts that the TestNavHostController has correctly updated the current route to ScreenB.

Advanced Navigation Testing

Let’s explore advanced navigation testing techniques to cover more complex scenarios.

Testing Navigation with Arguments

If your routes include arguments, you can verify that the correct arguments are passed during navigation.


// Updated Screen definitions
const val ScreenC = "screenC/{argument}"

@Composable
fun ScreenCView(argument: String) {
    Text("Screen C with argument: $argument")
}

@Composable
fun AppNavigation(navController: NavController) {
    NavHost(navController = navController, startDestination = ScreenA) {
        composable(ScreenA) {
            Button(onClick = { navController.navigate("screenC/testArgument") }) {
                Text("Go to Screen C")
            }
        }
        composable(ScreenC) { backStackEntry ->
            val argument = backStackEntry.arguments?.getString("argument")
            argument?.let {
                ScreenCView(argument = it)
            }
        }
    }
}

Test that the argument is correctly passed:


class NavigationTest {
    @get:Rule
    val composeTestRule = createComposeRule()

    @Test
    fun testNavigationToScreenCWithArgument() {
        lateinit var navController: TestNavHostController

        composeTestRule.setContent {
            navController = TestNavHostController(LocalContext.current)
            AppNavigation(navController = navController)
        }

        composeTestRule.onNodeWithText("Go to Screen C").performClick()

        // Verify that we navigated to ScreenC with the correct argument
        assertEquals("screenC/testArgument", navController.currentBackStackEntry?.destination?.route)
    }
}

Testing Pop Navigation

Testing backward navigation is crucial. Verify that calling navController.popBackStack() behaves as expected.


@Test
fun testPopNavigation() {
    lateinit var navController: TestNavHostController

    composeTestRule.setContent {
        navController = TestNavHostController(LocalContext.current)
        AppNavigation(navController = navController)
    }

    // Navigate to ScreenB
    composeTestRule.onNodeWithText("Go to Screen B").performClick()
    assertEquals(ScreenB, navController.currentBackStackEntry?.destination?.route)

    // Pop back to ScreenA
    navController.popBackStack()
    assertEquals(ScreenA, navController.currentBackStackEntry?.destination?.route)
}

Best Practices for Navigation Testing

Adhering to best practices ensures that your navigation tests are reliable, maintainable, and effective:

  • Keep Tests Focused: Each test should focus on a single navigation scenario to simplify debugging.
  • Use Descriptive Names: Use clear, descriptive names for your tests to convey their purpose.
  • Isolate Components: Mock or stub any external dependencies to isolate the navigation logic.
  • Test Edge Cases: Consider edge cases, such as navigating with invalid arguments or handling deep links.
  • Run Tests Regularly: Integrate navigation tests into your CI/CD pipeline to catch issues early.

Conclusion

Using TestNavHostController, testing navigation in Jetpack Compose becomes straightforward and efficient. By incorporating robust navigation testing into your development workflow, you can ensure that your application provides a smooth and reliable user experience. Understanding and implementing the techniques discussed will significantly improve the quality and stability of your Compose applications. Happy testing!