Testing Navigation Compose: NavHost, Actions, and Back Stack

Quick answer: Test navigation through the UI, not by calling navigate() in the test. Make your NavHost accept a NavHostController, install ComposeNavigator on a TestNavHostController, click the same semantic control a user would, then assert the displayed screen or current typed route.

Navigation tests should protect user-visible contracts: the app starts in the right place, an action reaches the intended destination, Back returns predictably, and a top-level tab retains the expected stack. The official Compose-navigation testing guide recommends covering the NavHost, navigation actions passed to composables, and individual screens separately.

Add the test dependencies

Use Navigation’s testing artifact alongside the standard Compose UI test rule. Keep versions aligned with the Navigation and Compose versions already used by the app.

dependencies {
    androidTestImplementation("androidx.compose.ui:ui-test-junit4")
    androidTestImplementation("androidx.navigation:navigation-testing")
    debugImplementation("androidx.compose.ui:ui-test-manifest")
}

ui-test-manifest supplies a test activity for tests that use createComposeRule(). If a test uses createAndroidComposeRule<YourActivity>(), it launches the activity you provide instead. The Compose testing setup guide documents the distinction.

Make the NavHost injectable

Do not create the controller inside a NavHost that you need to test. Accept it as a parameter so production can pass rememberNavController() and the test can pass TestNavHostController.

@Composable
fun AppNavigation() {
    val navController = rememberNavController()
    AppNavHost(navController = navController)
}

@Composable
fun AppNavHost(
    navController: NavHostController,
) {
    NavHost(
        navController = navController,
        startDestination = Home,
    ) {
        composable<Home> {
            HomeScreen(
                onOpenProfile = { userId ->
                    navController.navigate(Profile(userId))
                },
            )
        }
        composable<Profile> { entry ->
            ProfileScreen(
                profile = entry.toRoute<Profile>(),
                onBack = navController::popBackStack,
            )
        }
    }
}

HomeScreen receives an intent callback instead of a NavController. That makes it possible to test the screen with a simple lambda, while the NavHost remains the one place that connects UI events to routes. This is the same navigation boundary used in Navigation Compose: Create Your First NavHost.

Create a TestNavHostController

TestNavHostController is a NavHostController tailored for Navigation tests. A Compose graph needs a ComposeNavigator added before it can navigate between composables.

import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.platform.LocalContext
import androidx.navigation.compose.ComposeNavigator
import androidx.navigation.testing.TestNavHostController
import org.junit.Before
import org.junit.Rule

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

    private lateinit var navController: TestNavHostController

    @Before
    fun setUp() {
        composeTestRule.setContent {
            navController = TestNavHostController(LocalContext.current).apply {
                navigatorProvider.addNavigator(ComposeNavigator())
            }
            AppNavHost(navController = navController)
        }
    }
}

The test calls setContent only to mount the real graph. Assertions come after it, once Compose has composed and synchronized the hierarchy.

Prefer a user action, then assert the result

Semantic labels are a durable test seam when they also describe a real control to accessibility services. This test proves both that the action is exposed and that the graph handles it.

import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.performClick
import androidx.navigation.NavDestination.Companion.hasRoute
import kotlin.test.assertTrue
import org.junit.Test

@Test
fun appNavHost_startsOnHome() {
    composeTestRule
        .onNodeWithContentDescription("Home screen")
        .assertIsDisplayed()
}

@Test
fun openProfile_navigatesToProfile() {
    composeTestRule
        .onNodeWithContentDescription("Open profile")
        .performClick()

    composeTestRule
        .onNodeWithContentDescription("Profile screen")
        .assertIsDisplayed()

    assertTrue(
        navController.currentBackStackEntry
            ?.destination
            ?.hasRoute<Profile>() == true,
    )
}

The visible-screen assertion is the primary check because it matches what the user experiences. The typed route assertion is useful as a focused graph contract. For a string-route graph, assert the current destination’s route instead; do not convert a type-safe app to strings merely for its tests. Type-Safe Navigation Compose with Kotlin Serialization explains the route model used here.

Test leaf screens without Navigation

Each screen should also have a small test that verifies its event without mounting a graph. This keeps failures specific: a broken button is a screen failure, while an incorrect route wiring is a NavHost failure.

import kotlin.test.assertEquals

@Test
fun profileCard_reportsSelectedFriend() {
    var selectedFriendId: String? = null

    composeTestRule.setContent {
        ProfileCard(
            friendId = "friend-42",
            onOpenFriend = { selectedFriendId = it },
        )
    }

    composeTestRule
        .onNodeWithContentDescription("Open friend profile")
        .performClick()

    composeTestRule.runOnIdle {
        assertEquals("friend-42", selectedFriendId)
    }
}

ProfileCard has no dependency on Navigation and can be previewed or reused without a controller. The graph test above is the only place that needs to prove that the callback becomes navController.navigate(Profile(friendId)).

Cover Back, arguments, and real app flows

Add tests for the requirements that are easy to break during a graph refactor:

RequirementTest approach
Start destinationMount the real AppNavHost and assert its start-screen semantics.
Destination actionClick the control, assert the destination UI, then assert its typed route when helpful.
Back behaviorNavigate through the UI, call the app’s visible Back action or system-back test hook, and assert the previous screen.
Typed argumentsNavigate from a real UI action and assert that the destination renders the expected ID-derived state.
Nested graph completionComplete the flow and verify its graph is no longer reachable by Back.
Multiple top-level stacksSwitch tabs through the UI and verify the expected child destination is restored.
Deep linkLaunch the corresponding intent in an instrumented test and assert the target screen and Back behavior.

For top-level tab restoration, see Bottom Navigation with Multiple Back Stacks. For external intent setup, see Deep Links with Navigation Compose.

Common mistakes

Passing NavController into every screen

It couples each screen test to a navigation implementation and makes a reusable component harder to test. Let the graph own navigate() and pass callbacks to UI.

Calling navigate directly in a navigation test

That proves the controller can change state but skips the button, semantics, event callback, and graph wiring that users rely on. Trigger the UI event whenever the behavior is user-driven.

Asserting only internal route strings

An internal route can be correct while the wrong screen renders, content descriptions are missing, or an action is inaccessible. Assert a user-visible result first; use route assertions as an additional precise check.

Testing every possible path in one giant test

Long tests obscure the failure and become brittle. Use small tests for screen events, a focused group for graph contracts, and a few end-to-end tests for the highest-value cross-feature flows.

FAQ

Do I need TestNavHostController for every Compose screen test?

No. Use it for NavHost and navigation-integration tests. A leaf composable that exposes callbacks can be tested with createComposeRule() and ordinary UI assertions.

It can help test graph-level route matching, but Android intent delivery and App Links need an instrumented test against the Activity as well. Test both when deep links are a product-critical entry point.

Should I mock NavController?

Usually no for a Compose graph. TestNavHostController exercises Navigation’s test implementation, while callback-based screens need no controller at all. Mock only a boundary that your app actually owns and cannot practically replace with the real test controller.