1

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?

2 Answers 2

1

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.

Sign up to request clarification or add additional context in comments.

1 Comment

Hey, BenjyTec. Let me try your solution and get back to you. Thanks.
1

If you want to test NavHost specifically, changing what UserListViewModel provides depends on its implementation. If user list comes from a repository, create a fake repository and inject it manually, or even create a hilt module in the androidTest source set (see Replace a binding).

To test UserListScreen in isolation, you can first decouple it from the UserListViewModel for easier testing by creating a UserListRoute composable which will access the UserListViewModel and call UserListScreen:

@Composable
fun UserListRoute(
    navigateToUserDetails: (User) -> Unit,
    viewModel: UserListViewModel = hiltViewModel(),
) {
    val response = viewModel.userListResponse
    UserListScreen(
        response = response,
        navigateToUserDetails = navigateToUserDetails,
    )
}

@Composable
fun UserListScreen(
    response: Response,
    navigateToUserDetails: (User) -> Unit
) {
    //.....

And change UserListScreen to UserListRoute in NavHost:

    composable<UserListScreen> {
        // Updated
        UserListRoute(
            navigateToUserDetails = { user ->
                navController.navigate(user.toUserDetails())
            }
        )
    }

After that all you have to do in the test is to check if navigateToUserDetails was called on click. You don't need any actual navigation or view models:

@Test
fun navigatesToDetailsScreen() {
    var user: User? = null
    composeTestRule.setContent {
        UserListScreen(
            response = Success(/*fake user list*/),
            navigateToUserDetails = { user = it },
        )
    }

    // do the click
    composeTestRule.performClick()

    // assert navigateToUserDetails was called by checking user variable
}

1 Comment

Hey, Jan. Let me try your solution and get back to you. Thanks.

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.