Blog Infos
Author
Published
Topics
, , , ,
Published

An AI-generated illustration of Jetpack Compose navigation in Android development, designed in the style of Google’s Android developer documentation. The image features a structured flowchart with mobile screens connected by arrows, demonstrating navigation paths. UI elements follow Material Design 3 guidelines, maintaining a clean and minimalistic aesthetic.

Navigation is a core functionality of all mobile apps, and the tedious work lies in manually testing every possible navigation path from a single screen, which can be both time-consuming and error-prone. One of the most useful new features is type-safe navigation, introduced in Navigation Component 2.8.0 through the use of serializable objects. However, integrating this feature requires some migration steps, which we will cover in this guide. This migration process will also illustrate the benefits of having automated navigation tests in place. However, integrating this new feature may require small or significant changes, depending on the app’s current implementation.

In this article, we will:

  1. Implement navigation using the “old way.”
  2. Develop navigation tests.
  3. Migrate to the “new way” with type-safe navigation.

Note: Even though type-safe navigation is available, the “old way” is still supported in newer library versions. For this guide, we will use Navigation Component 2.8.6 for both approaches.

Implementing the navigation
Dependencies

Ensure the following dependencies are added to your project. The navigation testing library is particularly important as it provides TestNavHostController for testing.

dependencies {
// Specify compose BoM
val composeBom = platform("androidx.compose:compose-bom:2025.01.01")
implementation(composeBom)
androidTestImplementation(composeBom)
// Implement navigation and navigation testing
implementation("androidx.navigation:navigation-compose::2.8.6")
androidTestImplementation("androidx.navigation:navigation-testing:2.8.6")
// Implement junit and compose junit
androidTestImplementation("androidx.test.ext:junit:1.2.1")
androidTestImplementation("androidx.compose.ui:ui-test-junit4")
// Add Kotlin test library
androidTestImplementation(kotlin("test"))
// Other dependencies
..
}
Defining Navigation Routes

Navigation routes are defined as string constants:

object NavigationRoutes {
const val BOOK_SEARCH = "book_search_screen"
const val BOOK_DETAIL = "book_detail_screen"
const val SHOPPING_CART = "shopping_cart_screen"
const val PAYMENT = "payment_screen"
const val USER = "user_profile_screen"
}
Setting Up the Navigation Graph

Each composable screen is associated with a route in the navigation graph. Additionally, callback functions are defined to handle navigation:

import androidx.compose.runtime.Composable
import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import com.example.obook.ui.screen.book.detail.BookDetailScreen
import com.example.obook.ui.screen.book.search.BookSearchScreen
import com.example.obook.ui.screen.payment.PaymentScreen
import com.example.obook.ui.screen.shoppingcart.ShoppingCartScreen
import com.example.obook.ui.screen.user.UserProfileScreen
@Composable
internal fun NavigationGraph(
navController: NavHostController
) {
NavHost(navController = navController, startDestination = NavigationRoutes.BOOK_SEARCH) {
composable(
route = NavigationRoutes.BOOK_SEARCH
) {
BookSearchScreen(onBookSelected = { navController.navigate(route = NavigationRoutes.BOOK_DETAIL) })
}
composable(
route = NavigationRoutes.BOOK_DETAIL
) {
val isbn = it.arguments?.getString("isbn") ?: ""
BookDetailScreen(
isbn = isbn,
onNavigateToCart = { navController.navigate(route = NavigationRoutes.SHOPPING_CART) }
)
}
composable(
route = NavigationRoutes.SHOPPING_CART
) {
ShoppingCartScreen(
onNavigateToPayment = { navController.navigate(route = NavigationRoutes.PAYMENT) }
)
}
composable(
route = NavigationRoutes.USER
) {
UserProfileScreen()
}
composable(
route = NavigationRoutes.PAYMENT
) {
PaymentScreen()
}
}
}

Old version navigation graph

Integrating with the Scaffold Menu

These routes are also used to populate the navigation menu in the Scaffold. In this example, the Adaptive Library automatically handles menu type and positioning. If you want more information, refer to this article.

package com.example.obook.ui.component.scaffold
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.safeDrawingPadding
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo
import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteScaffold
import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteScaffoldDefaults
import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteType
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.PreviewScreenSizes
import androidx.navigation.NavHostController
import androidx.navigation.compose.rememberNavController
import com.example.obook.ui.component.menu.MenuIcon
import com.example.obook.ui.component.menu.getMenuItems
import com.example.obook.ui.navigation.NavigationGraph
import com.example.obook.ui.navigation.NavigationRoutes
import com.example.obook.ui.preview.getNavigationSuiteType
import com.example.obook.ui.theme.OBookTheme
@Composable
fun OBookScaffold(
navController: NavHostController = rememberNavController(),
layoutType: NavigationSuiteType = NavigationSuiteScaffoldDefaults.calculateFromAdaptiveInfo(
currentWindowAdaptiveInfo()
)
) {
var selectedIndex by remember { mutableIntStateOf(0) }
val menuItems = getMenuItems(
onNavigateToBook = {
navController.navigate(route = NavigationRoutes.BOOK_SEARCH)
selectedIndex = it
},
onNavigateToCart = {
navController.navigate(route = NavigationRoutes.SHOPPING_CART)
selectedIndex = it
},
onNavigateToUser = {
navController.navigate(route = NavigationRoutes.USER)
selectedIndex = it
}
)
NavigationSuiteScaffold(
layoutType = layoutType,
navigationSuiteItems = {
menuItems.forEachIndexed { index, navItem ->
item(
icon = { MenuIcon(icon = navItem.icon, label = navItem.label) },
label = { Text(navItem.label) },
selected = selectedIndex == index,
onClick = { navItem.navigationCallback(index) }
)
}
},
modifier = Modifier.fillMaxSize().safeDrawingPadding()
) {
NavigationGraph(navController = navController)
}
}
@PreviewScreenSizes
@Composable
internal fun PreviewOBookScaffold() {
OBookTheme {
Surface {
OBookScaffold(layoutType = getNavigationSuiteType())
}
}
}
Testing the navigation

Now, let’s write tests to verify that navigation works correctly from both the menu and screen callbacks.

Note: Within composeTestRule.setContent, string resources are used instead of test tags to identify UI nodes, such as buttons.

package com.example.obook.ui.component
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onAllNodesWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.tooling.preview.datasource.LoremIpsum
import androidx.navigation.compose.ComposeNavigator
import androidx.navigation.testing.TestNavHostController
import com.example.obook.R
import com.example.obook.domain.book.model.Book
import com.example.obook.ui.component.scaffold.OBookScaffold
import com.example.obook.ui.navigation.NavigationRoutes
import com.example.obook.ui.screen.book.search.bookCardTestTag
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import kotlin.test.assertTrue
class NavigationTest {
@get:Rule
val composeTestRule = createComposeRule()
private lateinit var navController: TestNavHostController
private lateinit var booksMenuItemLabel: String
private lateinit var shoppingCartMenuItemLabel: String
private lateinit var userMenuItemLabel: String
private lateinit var buyNowLabel: String
private lateinit var goToPaymentLabel: String
// Fakes
private val fakeBook = Book(
isbn = "9788411191159",
title = "Title",
author = "Author Name",
version = "English version",
score = 5,
summary = LoremIpsum(words = 50).toString(),
coverImageUrl = "https://imagessl9.casadellibro.com/a/l/s7/59/9788411191159.webp",
available = true,
originalPrice = 20.45,
promotion = null
)
@Before
fun setup() {
composeTestRule.setContent {
booksMenuItemLabel = stringResource(R.string.books)
shoppingCartMenuItemLabel = stringResource(R.string.cart)
userMenuItemLabel = stringResource(R.string.user)
buyNowLabel = stringResource(R.string.buy_now)
goToPaymentLabel = stringResource(R.string.go_to_payment)
navController = TestNavHostController(LocalContext.current)
navController.navigatorProvider.addNavigator(ComposeNavigator())
OBookScaffold(navController = navController)
}
}
@Test
fun verifyStartDestinationIsBookSearch() {
assertTrue { navController.currentDestination?.route == NavigationRoutes.BOOK_SEARCH }
}
// region BottomBar
@Test
fun bottomBar_onClickBooksMenuItem_verifyDestinationIsBookSearch() {
composeTestRule.onNodeWithText(booksMenuItemLabel).performClick()
assertTrue { navController.currentDestination?.route == NavigationRoutes.BOOK_SEARCH }
}
@Test
fun bottomBar_onClickShoppingCartMenuItem_verifyDestinationIsShoppingCart() {
composeTestRule.onNodeWithText(shoppingCartMenuItemLabel).performClick()
assertTrue { navController.currentDestination?.route == NavigationRoutes.SHOPPING_CART }
}
@Test
fun bottomBar_onClickUserMenuItem_verifyDestinationIsUserProfile() {
composeTestRule.onNodeWithText(userMenuItemLabel).performClick()
assertTrue { navController.currentDestination?.route == NavigationRoutes.USER }
}
// endregion
// region Books
@Test
fun whenUserIsInBookSearch_andClickOnABook_verifyDestinationIsBookDetail() {
// User is in BookSearch
composeTestRule.runOnUiThread {
navController.setCurrentDestination(NavigationRoutes.BOOK_SEARCH)
}
// User clicks on the first book
composeTestRule.onAllNodesWithTag(bookCardTestTag)[0].performClick()
assertTrue { navController.currentDestination?.route == NavigationRoutes.BOOK_DETAIL }
}
@Test
fun whenUserIsInBookDetail_andClickOnByNow_verifyDestinationIsShoppingCart() {
// User is in Detail
composeTestRule.runOnUiThread {
navController.setCurrentDestination(NavigationRoutes.BOOK_DETAIL)
}
// User clicks on buy now
composeTestRule.onNodeWithText(buyNowLabel).performClick()
assertTrue { navController.currentDestination?.route == NavigationRoutes.SHOPPING_CART }
}
// endregion
// region Shopping Cart
@Test
fun whenUserIsInShoppingCart_andClickGoToPayment_verifyDestinationIsPayments() {
// User is in BookSearch
composeTestRule.runOnUiThread {
navController.setCurrentDestination(NavigationRoutes.SHOPPING_CART)
}
// User clicks on the first book
composeTestRule.onNodeWithText(goToPaymentLabel).performClick()
assertTrue { navController.currentDestination?.route == NavigationRoutes.PAYMENT }
}
// endregion
}

By running this test, we can confirm that navigation is working correctly!

Old version passed tests

Migrating to Type-Safe Navigation

As mentioned earlier, navigation will be upgraded to leverage type-safe features.

Step 1: Enable Serialization Plugin

Ensure the serialization plugin is included in build.gradle.kts (if not already available):

kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }

 

plugins {
    alias(libs.plugins.kotlin.serialization) apply false
    ...
}

 

Step 2: Apply Plugin in Module

Ensure the serialization plugin is applied in the application module and any other module where it is needed.

plugins {
    alias(libs.plugins.kotlin.serialization)
    ...
}
Step 3: Migrate Routes to Serializable Objects

Instead of using string constants, define routes as serializable objects:

package com.example.obook.ui.navigation
import kotlinx.serialization.Serializable
@Serializable
object BookSearch
@Serializable
data class BookDetail(val isbn: String)
@Serializable
object ShoppingCart
@Serializable
object Payment
@Serializable
object UserProfile
Step 4: Update the Navigation Graph

Modify the navigation graph to use serializable objects:

package com.example.obook.ui.navigation
import androidx.compose.runtime.Composable
import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import com.example.obook.ui.screen.book.detail.BookDetailScreen
import com.example.obook.ui.screen.book.search.BookSearchScreen
import com.example.obook.ui.screen.payment.PaymentScreen
import com.example.obook.ui.screen.shoppingcart.ShoppingCartScreen
import com.example.obook.ui.screen.user.UserProfileScreen
@Composable
internal fun NavigationGraph(
navController: NavHostController
) {
NavHost(navController = navController, startDestination = BookSearch) {
composable<BookSearch> {
BookSearchScreen(onBookSelected = { navController.navigate(BookDetail(isbn = it.isbn)) })
}
composable<BookDetail> {
val isbn = it.arguments?.getString("isbn") ?: ""
BookDetailScreen(
isbn = isbn,
onNavigateToCart = { navController.navigate(ShoppingCart) }
)
}
composable<ShoppingCart> {
ShoppingCartScreen(
onNavigateToPayment = { navController.navigate(Payment) }
)
}
composable<UserProfile> {
UserProfileScreen()
}
composable<Payment> {
PaymentScreen()
}
}
}
Step 5: Update the Scaffold Menu

Ensure the menu items use the updated navigation approach:

val menuItems = getMenuItems(
onNavigateToBook = {
navController.navigate(BookSearch)
selectedIndex = it
},
onNavigateToCart = {
navController.navigate(ShoppingCart)
selectedIndex = it
},
onNavigateToUser = {
navController.navigate(UserProfile)
selectedIndex = it
}
)
Step 6: Update Navigation Assertions in Tests

Instead of currentDestination, use currentBackStackEntry:

// Change this
assertTrue { navController.currentDestination?.route == NavigationRoutes.BOOK_SEARCH }

// For this
assertTrue { navController.currentBackStackEntry?.destination?.hasRoute<BookSearch>() == true }
Step 7: Modify setCurrentDestination

setCurrentDestination in TestNavHostController does not support serializable objects, so navigation functions must be used instead:

// Change this
composeTestRule.runOnUiThread {
  navController.setCurrentDestination(NavigationRoutes.BOOK_SEARCH)
}

// For this
composeTestRule.runOnUiThread {
  navController.navigate(BookSearch)
}
Full Test Code

Here is the complete test code to verify navigation:

package com.example.obook.ui.component
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onAllNodesWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.tooling.preview.datasource.LoremIpsum
import androidx.navigation.NavDestination.Companion.hasRoute
import androidx.navigation.compose.ComposeNavigator
import androidx.navigation.testing.TestNavHostController
import com.example.obook.R
import com.example.obook.domain.book.model.Book
import com.example.obook.ui.component.scaffold.OBookScaffold
import com.example.obook.ui.navigation.BookDetail
import com.example.obook.ui.navigation.BookSearch
import com.example.obook.ui.navigation.Payment
import com.example.obook.ui.navigation.ShoppingCart
import com.example.obook.ui.navigation.UserProfile
import com.example.obook.ui.screen.book.search.bookCardTestTag
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import kotlin.test.assertTrue
class NavigationTest {
@get:Rule
val composeTestRule = createComposeRule()
private lateinit var navController: TestNavHostController
private lateinit var booksMenuItemLabel: String
private lateinit var shoppingCartMenuItemLabel: String
private lateinit var userMenuItemLabel: String
private lateinit var buyNowLabel: String
private lateinit var goToPaymentLabel: String
// Fakes
private val fakeBook = Book(
isbn = "9788411191159",
title = "Title",
author = "Author Name",
version = "English version",
score = 5,
summary = LoremIpsum(words = 50).toString(),
coverImageUrl = "https://imagessl9.casadellibro.com/a/l/s7/59/9788411191159.webp",
available = true,
originalPrice = 20.45,
promotion = null
)
@Before
fun setup() {
composeTestRule.setContent {
booksMenuItemLabel = stringResource(R.string.books)
shoppingCartMenuItemLabel = stringResource(R.string.cart)
userMenuItemLabel = stringResource(R.string.user)
buyNowLabel = stringResource(R.string.buy_now)
goToPaymentLabel = stringResource(R.string.go_to_payment)
navController = TestNavHostController(LocalContext.current)
navController.navigatorProvider.addNavigator(ComposeNavigator())
OBookScaffold(navController = navController)
}
}
@Test
fun verifyStartDestinationIsBookSearch() {
assertTrue { navController.currentBackStackEntry?.destination?.hasRoute<BookSearch>() == true }
}
// region BottomBar
@Test
fun bottomBar_onClickBooksMenuItem_verifyDestinationIsBookSearch() {
composeTestRule.onNodeWithText(booksMenuItemLabel).performClick()
assertTrue { navController.currentBackStackEntry?.destination?.hasRoute<BookSearch>() == true }
}
@Test
fun bottomBar_onClickShoppingCartMenuItem_verifyDestinationIsShoppingCart() {
composeTestRule.onNodeWithText(shoppingCartMenuItemLabel).performClick()
assertTrue { navController.currentBackStackEntry?.destination?.hasRoute<ShoppingCart>() == true }
}
@Test
fun bottomBar_onClickUserMenuItem_verifyDestinationIsUserProfile() {
composeTestRule.onNodeWithText(userMenuItemLabel).performClick()
assertTrue { navController.currentBackStackEntry?.destination?.hasRoute<UserProfile>() == true }
}
// endregion
// region Books
@Test
fun whenUserIsInBookSearch_andClickOnABook_verifyDestinationIsBookDetail() {
// User is in BookSearch
composeTestRule.runOnUiThread {
navController.navigate(BookSearch)
}
// User clicks on the first book
composeTestRule.onAllNodesWithTag(bookCardTestTag)[0].performClick()
assertTrue { navController.currentBackStackEntry?.destination?.hasRoute<BookDetail>() == true }
}
@Test
fun whenUserIsInBookDetail_andClickOnByNow_verifyDestinationIsShoppingCart() {
// User is in Detail
composeTestRule.runOnUiThread {
navController.navigate(BookDetail(isbn = fakeBook.isbn))
}
// User clicks on buy now
composeTestRule.onNodeWithText(buyNowLabel).performClick()
assertTrue { navController.currentBackStackEntry?.destination?.hasRoute<ShoppingCart>() == true }
}
// endregion
// region Shopping Cart
@Test
fun whenUserIsInShoppingCart_andClickGoToPayment_verifyDestinationIsPayments() {
// User is in BookSearch
composeTestRule.runOnUiThread {
navController.navigate(ShoppingCart)
}
// User clicks on the first book
composeTestRule.onNodeWithText(goToPaymentLabel).performClick()
assertTrue { navController.currentBackStackEntry?.destination?.hasRoute<Payment>() == true }
}
// endregion
}

Job Offers

Job Offers

There are currently no vacancies.

OUR VIDEO RECOMMENDATION

, ,

Kobweb:Creating websites in Kotlin leveraging Compose HTML

Kobweb is a Kotlin web framework that aims to make web development enjoyable by building on top of Compose HTML and drawing inspiration from Jetpack Compose.
Watch Video

Kobweb:Creating websites in Kotlin leveraging Compose HTML

David Herman
Ex-Googler, author of Kobweb

Kobweb:Creating websites in Kotlin leveraging Compose HTML

David Herman
Ex-Googler, author o ...

Kobweb:Creating websites in Kotlin leveraging Compose HTML

David Herman
Ex-Googler, author of Kob ...

Jobs

Final Verification

After applying these changes, re-run the tests to ensure that navigation still functions correctly.

Migrated version passed tests

Closing

If you found this article helpful or interesting, please give it a clap and consider subscribing for more content! I’d love to hear your thoughts! Your feedback and insights are always welcome, as I’m eager to learn, collaborate, and grow with other developers in the community.

Have any questions? Feel free to reach out!

You can also follow me on Medium or LinkedIn for more insightful articles and updates. Let’s stay connected!

This article is previously published on proandroiddev.com.

Menu