Search code examples
androidkotlinandroid-jetpack-composeandroid-testingandroid-jetpack-navigation

How to test navigation in Jetpack Compose on user click?


I have an app where I have 2 screens:

NavHost(
    navController = navController,
    startDestination = UserListScreen
) {
    composable<UserListScreen> {
        UserListScreen(
            navigateToUserDetails = { user ->
                navController.navigate(user.toUserDetails())
            }
        )
    }
    composable<UserDetails> { entry ->
        UserDetailsScreen(
            user = entry.toRoute<UserDetails>().toUser()
        )
    }
}

Where the UserListScreen is this:

fun UserListScreen(
    viewModel: UserListViewModel = hiltViewModel(),
    navigateToUserDetails: (User) -> Unit
) {
    Scaffold(
        topBar = { UserListTopBar() }
    ) {
        when(viewModel.userListResponse) {
            is Loading -> CircularProgressIndicator()
            is Success -> {
                val userList = userListResponse.userList
                UserListContent(
                    userList = userList,
                    onUserClick = navigateToUserDetails
                )
            }
            is Failure -> print(userListResponse.e.message)
        }
    }
}

Here I get the user list from the ViewModel and pass it to the UserListContent composable. All works fine. The problem comes when testing. For example, on user click, I want to test if the user is correctly navigating to the UserDetailsScreen. So I need to somehow pass a fake user list to the UserListContent so I can see the result of the testing. Here is what I have tried:

class UserListNavigationTest {
    lateinit var navController: TestNavHostController

    @Before
    fun setupNavHost() {
        composeTestRule.activity.setContent {
            navController = TestNavHostController(LocalContext.current)
            navController.navigatorProvider.addNavigator(ComposeNavigator())
            
            NavHost(
                navController = navController,
                startDestination = UserListScreen
            ) {
                composable<UserListScreen> {
                    UserListScreen(
                        navigateToUserDetails = { user ->
                            navController.navigate(user.toUserDetails())
                        }
                    )
                }
                composable<UserDetails> { entry ->
                    UserDetailsScreen(
                        user = entry.toRoute<UserDetails>().toUser()
                    )
                }
            }
        }
    }

    @Test
    fun testDestination() {
        //ToDo
    }
}

So I can call UserListScreen(), but I don't have access to the UserListContent() so I can pass a fake list in order to test. How to solve this problem?


Solution

  • You are already following good practices by using default arguments in your UserListScreen to inject the ViewModel:

    fun UserListScreen(
        viewModel: UserListViewModel = hiltViewModel(),
        navigateToUserDetails: (User) -> Unit
    ) {
        //...
    }
    

    The goal now would be to pass a manually created UserListViewModel instance to the UserListScreen. The ViewModel should take a fake repository instance which returns a predefined list of users.

    Please have a look at the example below. Note that I also did some more optimizations:

    • I am using Flows to pass the List<User> around.
    • I created one sealed Result class that holds the success of the fetch users operation as well as the data returned from the operation.
    • I am observing the Flow from the ViewModel in the Composable using collectAsStateWithLifecycle().
    • I use a repository class that the ViewModel is getting the data from, according to the recommended app architecture in Android.

    First, create a sealed Result class like this:

    sealed class Result {
        object Loading : Result()
        data class Success(val userList: List<User>) : Result()
        data class Failure(val errorMessage: String) : Result()
    }
    

    Then, your UserListViewModel probably would look like this:

    @HiltViewModel
    class UserListViewModel @Inject constructor(
        private val userRepository: UserRepository  // also use constructor injection here
    ) : ViewModel() {
    
        private val _userListResponse = MutableStateFlow<Result>(Result.Loading)
        val userListResponse: StateFlow<Result> = _userListResponse
    
        init {
            fetchUsers()
        }
    
        private fun fetchUsers() {
            _userListResponse.value = UIState.Loading
            viewModelScope.launch {
                try{
                    val userList: List<User> = userRepository.getUsers()
                    _userListResponse.value = UIState.Success(userList)
                } catch(e: Exception) {
                    _userListResponse.value = UIState.Failure(e.message)
                }
            }
        }
    }
    

    Then, create a FakeUserRepository that extends your UserRepository and return some static dummy data there:

    class FakeUserRepository : UserRepository {
        suspend fun getUsers() {
            return listOf(User(/***/), User(/***/))
        }
    }
    

    In your Composable, you can observe the userListResponse:

    Scaffold(
        topBar = { UserListTopBar() }
    ) {
        val userListReponse by viewModel.userListResponse.collectAsStateWithLifecycle()
        when(userListResponse) {
            is Loading -> CircularProgressIndicator()
            is Success -> {
                val userList = userListResponse.userList
                UserListContent(
                    userList = userList,
                    onUserClick = navigateToUserDetails
                )
            }
            is Failure -> print(userListResponse.e.message)
        }
    }
    

    Finally, construct your test like this:

    @Before
    fun setupNavHost() {
        val fakeRepository = FakeUserRepository()
        val fakeViewModel = UserListViewModel(fakeUserRepository)
    
        composeTestRule.activity.setContent {
            navController = TestNavHostController(LocalContext.current)
            navController.navigatorProvider.addNavigator(ComposeNavigator())
            
            NavHost(
                navController = navController,
                startDestination = UserListScreen
            ) {
                composable<UserListScreen> {
                    UserListScreen(
                        viewModel = fakeViewModel,
                        navigateToUserDetails = { user ->
                            navController.navigate(user.toUserDetails())
                        }
                    )
                }
                //...
            }
        }
    }
    

    Now, your UserListScreen Composable will display exactly the Users set in the FakeUserRepository.


    Note:

    Instead of manually creating a FakeUserRepository, you can also use Mocking Frameworks like MockK or Mockito and let them do it for you.