I am learning UI testing with Espresso. I want to test scrolling of recycler view to bottom and only then load next page from view model and pass it recycler view.
I have following onScrollListener in my fragment:
private fun setupOnScrollListener() {
recyclerViewApi.addOnScrollListener(object : RecyclerView.OnScrollListener() {
override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) {
super.onScrollStateChanged(recyclerView, newState)
val isRecyclerViewBottom = !recyclerView.canScrollVertically(1) &&
newState == RecyclerView.SCROLL_STATE_IDLE
if (isRecyclerViewBottom) {
downloadNextPage()
}
}
})
}
private fun downloadNextPage() {
showProgressBar(true)
viewModel.getNextMovies()
}
When I test it manually with Log.d() it works great.
My question is: How to use Espresso (or maybe different API, if you know better than Espresso) to scroll recycler view to this state:
isRecyclerViewBottom = !recyclerView.canScrollVertically(1) && newState == RecyclerView.SCROLL_STATE_IDLE,
so my downloadNextPage() will be invoked and test function will pull more data.
My test function:
#Test
fun scrollToBottom_isNextPageLoaded(){
every { repository.getApiMovies(any(), any()) } returns
Flowable.just(Resource.success(moviesList1_5)) andThen
Flowable.just(Resource.success(moviesList1_10))
val scenario = launchFragmentInContainer<ApiFragment>(factory = fragmentsFactory)
//first 5 items are in view, so I go to the last item (index 4)
recyclerView.perform(scrollToPosition<ViewHolder>(4))
recyclerView.perform(swipeDown())
//Below doesn't make any difference
Thread.sleep(1000L)
verify(exactly = 2) { repo.getApiMovies(any(), any()) }
}
I use Robolectric, Mockk, Espresso. I have mocked here repository class, which is passed to constructor of ViewModelFactory, which is passed to constructor of the ApiFragment.
Message from JUnit:
java.lang.AssertionError: Verification failed: call 1 of 1: ApiRepository(repo#4).getApiMovies(any(), any())).
One matching call found, but needs at least 2 and at most 2 calls
Call: ApiRepository(repo#4).getApiMovies(Top Rated, 1)
It is not my first test function. Everything else works great. I just don't know how to make Espresso to go to bottom of recycler view and 'pull up' bottom edge of it to call downloadNextPage()
Ok. I have just found a sollution. I changed recyclerView.perform(swipeDown()) to recyclerView.perform(swipeUp()).
Related
Is there a way to update (automatically) the RecyclerView when a list is populated with data?
I created a simple app (here is the repository for the app).
In HomeFragment there is a RecyclerView and a button to refresh the data.
The app works fine as long as I have the following code in HomeFragment to update the adapter whenever the StateFlow list gets data.
private fun setupObservers() {
lifecycleScope.launchWhenStarted {
vm.state.collect() {
if (it.list.isNotEmpty()) {
todoAdapter.data = it.list
} else {
todoAdapter.data = emptyList()
}
}
}
}
My question is, is there a away for the RecyclerView to update, without having to observe (or collect) the changes of the list of the StateFlow?
Something has to notify the RecyclerView adapter when the data has changed. Either you do it in a collector/observer, or you have to proactively do it in every place in your code where you do something that might affect the data. So, it is much easier and less error-prone to do it by collecting.
Side note, the if/else in your code doesn't accomplish anything useful. No reason to treat an empty list differently if you still end up passing an empty list to the adapter.
It's more correct to use repeatOnLifecycle (or flowWithLifecycle) than launchWhenStarted. See here.
private fun setupObservers() {
vm.state.flowWithLifecycle(viewLifecycleOwner.lifecycle, Lifecycle.State.STARTED)
.onEach { todoAdapter.data = it.list }
.launchIn(viewLifecycleOwner.lifecycleScope)
}
I personally like to use an extension function like this to make it more concise wherever I'm collecting flows:
fun <T> Flow<T>.launchAndCollectWithLifecycle(
lifecycleOwner: LifecycleOwner,
state: Lifecycle.State = Lifecycle.State.STARTED,
action: suspend (T) -> Unit
) = flowWithLifecycle(lifecycleOwner.lifecycle, state)
.onEach(action)
.launchIn(lifecycleOwner.lifecycleScope)
Then your code would become:
private fun setupObservers() {
vm.state.launchAndCollectWithLifecycle(viewLifecycleOwner) {
todoAdapter.data = it.list
}
}
I am working on a compose screen, where on application open, i redirect user to profile page. And if profile is complete, then redirect to user list page.
my code is like below
#Composable
fun UserProfile(navigateToProviderList: () -> Unit) {
val viewModel: MainActivityViewModel = viewModel()
if(viewModel.userProfileComplete == true) {
navigateToProviderList()
return
}
else {
//compose elements here
}
}
but the app is blinking and when logged, i can see its calling the above redirect condition again and again. when going through doc, its mentioned that we should navigate only through callbacks. How do i handle this condition here? i don't have onCLick condition here.
Content of composable function can be called many times.
If you need to do some action inside composable, you need to use side effects
In this case LaunchedEffect should work:
LaunchedEffect(viewModel.userProfileComplete == true) {
if(viewModel.userProfileComplete == true) {
navigateToProviderList()
}
}
In the key(first argument of LaunchedEffect) you need to specify some key. Each time this key changes since the last recomposition, the inner code will be called. You may put Unit there, in this case it'll only be called once, when the view appears at the first place
The LaunchedEffect did not work for me since I wanted to use it in UI thread but it wasn't for some reason :/
However, I made this for my self:
#Composable
fun <T> SelfDestructEvent(liveData: LiveData<T>, onEvent: (argument: T) -> Unit) {
val previousState = remember { mutableStateOf(false) }
val state by liveData.observeAsState(null)
if (state != null && !previousState.value) {
previousState.value = true
onEvent.invoke(state!!)
}
}
and you use it like this in any other composables:
SingleEvent(viewModel.someLiveData) {
//your action with that data, whenever it was triggered, but only once
}
I'm using Paging 3 with RemoteMediator that shows cached data while fetching new data from the network.
When I refresh my PagingDataAdapter (by calling refresh() on it) I want my RecyclerView to scroll to the top after the refresh is done. In the codelabs they try to handle this via the loadStateFlow the following way:
lifecycleScope.launch {
adapter.loadStateFlow
// Only emit when REFRESH LoadState for RemoteMediator changes.
.distinctUntilChangedBy { it.refresh }
// Only react to cases where Remote REFRESH completes i.e., NotLoading.
.filter { it.refresh is LoadState.NotLoading }
.collect { binding.list.scrollToPosition(0) }
}
This indeed does scroll up, but before DiffUtil has finished. This means that if there is actually new data inserted at the top, the RecyclerView will not scroll all the way up.
I know that RecyclerView adapters have an AdapterDataObserver callback where we can get notified when DiffUtil has finished diffing. But this will cause all kinds of race conditions with PREPEND and APPEND loading states of the adapter which also cause DiffUtil to run (but here we don't want to scroll to the top).
One solution that would work would be to pass PagingData.empty() to the PagingDataAdapter and rerun the same query (just calling refresh won't work because the PagingData is now empty and there is nothing to refresh) but I would prefer to keep my old data visible until I know that refresh actually succeeded.
In cases, such as searching a static content, we can return false inside areItemsTheSame of DiffUtil.ItemCallback as a workaround. I use this also for changing sorting property.
#Florian I can confirm we don't need the postDelayed to scroll to top using version 3.1.0-alpha03 released on 21/07/2021.
Also, I managed to make further filter the loadStateFlow collection so it doesn't prevent StateRestorationPolicy.PREVENT_WHEN_EMPTY to work based on #Alexandr answer. My solution is:
By the time I am writing this, the latest version of Paging3 is 3.1.0-alpha03 so import:
androidx.paging:paging-runtime-ktx:3.1.0-alpha03
Then set the restoration policy of your adapter as following:
adapter.stateRestorationPolicy = RecyclerView.Adapter.StateRestorationPolicy.PREVENT_WHEN_EMPTY
If you have compilation error for the above mentioned change, make sure you are using at least version 1.2.0-alpha02 of RecyclerView. Any version above that is also good:
androidx.recyclerview:recyclerview:1.2.0-alpha02
Then use the filtered loadStateFlow to scroll the list to top only when you refresh the page and items are prepended in the list:
viewLifecycleOwner.lifecycleScope.launch {
challengesAdapter.loadStateFlow
.distinctUntilChanged { old, new ->
old.mediator?.prepend?.endOfPaginationReached.isTrue() ==
new.mediator?.prepend?.endOfPaginationReached.isTrue() }
.filter { it.refresh is LoadState.NotLoading && it.prepend.endOfPaginationReached && !it.append.endOfPaginationReached}
.collect {
mBinding.fragmentChallengesByLocationList.scrollToPosition(0)
}
}
The GitHub discussion can be found here: https://github.com/googlecodelabs/android-paging/issues/149
adapter.refresh()
lifecycleScope.launch {
adapter.loadStateFlow
.collect {
binding.recycleView.smoothScrollToPosition(0)
}
}
Take a look on the code the condition if loadtype is refreshed.
repoDatabase.withTransaction {
// clear all tables in the database
if (loadType == LoadType.REFRESH) {
repoDatabase.remoteKeysDao().clearRemoteKeys()
repoDatabase.reposDao().clearRepos()
}
val prevKey = if (page == GITHUB_STARTING_PAGE_INDEX) null else page - 1
val nextKey = if (endOfPaginationReached) null else page + 1
val keys = repos.map {
Log.e("RemoteKeys", "repoId: ${it.id} prevKey: $prevKey nextKey: $nextKey")
RemoteKeys(repoId = it.id, prevKey = prevKey, nextKey = nextKey)
}
repoDatabase.remoteKeysDao().insertAll(keys)
repoDatabase.reposDao().insertAll(repos)
}
You should delete the condition if LoadType is refresh clear all the tables.
if (loadType == LoadType.REFRESH) {
repoDatabase.remoteKeysDao().clearRemoteKeys()
repoDatabase.reposDao().clearRepos()
}
I've managed the way to improve base code snippet from the topic question.
The key is to listen non-combined variants of properties inside CombinedLoadStates
viewLifecycleOwner.lifecycleScope.launchWhenCreated {
adapter?.loadStateFlow
?.distinctUntilChanged { old, new ->
old.mediator?.prepend?.endOfPaginationReached.isTrue() ==
new.mediator?.prepend?.endOfPaginationReached.isTrue()
}
?.filter { it.refresh is LoadState.NotLoading }
..
// next flow pipeline operators
}
where isTrue is Boolean extension fun
fun Boolean?.isTrue() = this != null && this
So the idea here is to track mediator.prepend:endOfPagination flag states. When mediator has completed his part of paging load for the current page, his prepend state will not change (in case if you are loading pages after scroll down).
Solution works well both in offline & online modes.
If you need to track prepend paging or paging in both directions it is a good starting point to play around with the another CombinedLoadStates properties append,refresh,mediator and source
Follow https://developer.android.com/reference/kotlin/androidx/paging/PagingDataAdapter
val USER_COMPARATOR = object : DiffUtil.ItemCallback<User>() {
override fun areItemsTheSame(oldItem: User, newItem: User): Boolean =
// User ID serves as unique ID
oldItem.userId == newItem.userId
override fun areContentsTheSame(oldItem: User, newItem: User): Boolean =
// Compare full contents (note: Java users should call .equals())
oldItem == newItem
}
class UserAdapter : PagingDataAdapter<User, UserViewHolder>(USER_COMPARATOR) {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): UserViewHolder {
return UserViewHolder.create(parent)
}
override fun onBindViewHolder(holder: UserViewHolder, position: Int) {
val repoItem = getItem(position)
// Note that item may be null, ViewHolder must support binding null item as placeholder
holder.bind(repoItem)
}
}
I have a pretty simple test. I'm waiting for a list of transactions to be received and assigned to a list view.
#Test
fun testTransactionsLoad(){
val listView: ListView? = fragment!!.findView(R.id.listView)
assertNotNull(listView)
await().atMost(20, TimeUnit.SECONDS).until(transactionsLoad(listView!!))
assert(listView.adapter.count > 0)
}
But for some reason, awaitability always send false for this callable:
private fun transactionsLoad(listView: ListView): Callable<Boolean> {
return if (listView.adapter == null){
Callable { false }
} else {
Callable { true }
}
}
It's not the first time Awaitility returns the wrong thing. On my device, the list view loads, proving it should also have an adapter. The adapter still appears as null in the callable though.
Any ideas?
As you are using kotlin, you could do:
await().atMost(20, TimeUnit.SECONDS).until{ listView.adapter != null }
I'm trying to write simple test for pull to refresh as a part of integration testing. I'm using the newest androidX testing components and Robolectric. I'm testing isolated fragment in which one I'm injecting mocked presenter.
XML layout part
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
android:id="#+id/refreshLayout"
android:layout_width="match_parent"
android:layout_height="match_parent">
<androidx.recyclerview.widget.RecyclerView
android:id="#+id/recyclerTasks"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"/>
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
Fragment part
binding.refreshLayout.setOnRefreshListener(new SwipeRefreshLayout.OnRefreshListener() {
#Override
public void onRefresh() {
presenter.onRefresh();
}
});
Test:
onView(withId(R.id.refreshLayout)).perform(swipeDown());
verify(presenter).onRefresh();
but test doesn't pass, message:
Wanted but not invoked: presenter.onRefresh();
The app works perfectly fine and pull to refresh calls presenter.onRefresh(). I did also debugging of the test and setOnRefreshListener been called and it's not a null. If I do testing with custom matcher to check the status of SwipeRefreshLayout test passes.
onView(withId(R.id.refreshLayout)).check(matches(isRefreshing()));
I did some minor investigation over last weekend since I was facing the same issue and it was bothering me. I also did some comparing with what happens on a device to spot the differences.
Internally androidx.swiperefreshlayout.widget.SwipeRefreshLayout has an mRefreshListener that will run when onAnimationEnd is called. The AnimationEnd will trigger then OnRefreshListener.onRefresh method.
That animation listener (mRefreshListener) is passed to the mCircleView (CircleImageView) and the circle animation start is called.
On a device when the view draw method is called it will call the applyLegacyAnimation method that will, in turn, call the AnimationStart method. At the AnimationEnd, the onRefresh method will be called.
On Robolectric the draw method of the View is never called since the items are not actually drawn. This means that the animation will never run and thus neither will the onRefresh method.
My conclusion is that with the current version of Robolectric is not possible to verify that the onRefresh called due to implementation limitations. It seems though that it is planned to have a realistic rendering in the future.
I'm finally able to solve this using a hacky way :
fun swipeToRefresh(): ViewAction {
return object : ViewAction {
override fun getConstraints(): Matcher<View>? {
return object : BaseMatcher<View>() {
override fun matches(item: Any): Boolean {
return isA(SwipeRefreshLayout::class.java).matches(item)
}
override fun describeMismatch(item: Any, mismatchDescription: Description) {
mismatchDescription.appendText(
"Expected SwipeRefreshLayout or its Descendant, but got other View"
)
}
override fun describeTo(description: Description) {
description.appendText(
"Action SwipeToRefresh to view SwipeRefreshLayout or its descendant"
)
}
}
}
override fun getDescription(): String {
return "Perform swipeToRefresh on the SwipeRefreshLayout"
}
override fun perform(uiController: UiController, view: View) {
val swipeRefreshLayout = view as SwipeRefreshLayout
swipeRefreshLayout.run {
isRefreshing = true
// set mNotify to true
val notify = SwipeRefreshLayout::class.memberProperties.find {
it.name == "mNotify"
}
notify?.isAccessible = true
if (notify is KMutableProperty<*>) {
notify.setter.call(this, true)
}
// mockk mRefreshListener onAnimationEnd
val refreshListener = SwipeRefreshLayout::class.memberProperties.find {
it.name == "mRefreshListener"
}
refreshListener?.isAccessible = true
val animatorListener = refreshListener?.get(this) as Animation.AnimationListener
animatorListener.onAnimationEnd(mockk())
}
}
}
}