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?
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:
Flow
s to pass the List<User>
around.Result
class that holds the success of the fetch users operation as well as the data returned from the operation.Flow
from the ViewModel in the Composable using collectAsStateWithLifecycle()
.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.