Testing COIL with Espresso
COIL is the biggest new thing in Android community in recently. For those of you doesn’t know, COIL is an image loading library for Android, made with Kotlin coroutines. So I was intrigued by the fact that you can inject your own implementation of ImageLoader for testing. AFAIK, I haven’t seen this before in Glide or Picasso. I thought to myself, “Okay, Let’s write a fun project to test if the image urls are passed into ImageaLoader
correctly.” So I begin my journey of writing a small one screen app.
The premise of the app is very simple. It only shows a list of movie poster images along with their titles in a RecyclerView
, with data pulled from TMDB. The setup is pretty straightforward as well, Retrofit
is used together with Moshi
for network calls, and then a Service
class is written, which serves the data onto a ViewModel
. The ViewModel
then tell the Activity
to show the data in RecyclerView.
The goal of the test is to see if the posterPath
is correctly passed into ImageLoader
, i.e, if the ImageLoader
is invoked with posterPath as its argument.
Using default ImageLoader
COIL provides two ways to use ImageLoader; dependency injection(DI) of ImageLoader
or Singleton. In an ideal solution, a DI approach has to be used. But when I first started the project, I went with a very simple approach; use default one. Now of course, we only need to use provided out-of-the-box extension inline function with no argument passed for ImageLoader
.
DI Approach
That works for the app quite well, but there is still a problem, we can’t test this. So what do we do? Well, we use dependency injection to pass an ImageLoader
into the Activity
. I created a ImageLoaderModule
so that in the test, it can be replaced with TestImageLoaderModule
.
In actual ImageLoaderModule
, an ImageLoader
is built with the provided Builder.
I really like the fact that it provides a way to use default placeholder drawable through the use of builder. I can imagine a use case where you need a default placeholder like your app logo, before the image actually loads, or just a grey drawable. In most of my apps, I mostly use the same placeholder for images, so I think this is a pretty good solution for me. Just write once, and reuse that instance everywhere. It also exposes okHttpClient
, so we can also override the default one. In my cases, I use the one with logging interceptor attached so that I can check the image loading log as well. This could be useful in cases where auth token is required to pull the image, (Although I have never implemented this use case) then we can just add an Interceptor
to it.
Okay, let’s get back on the track. And then I add injection code to my activity, which then was passed onto MovieRecyclerViewAdapter
, and then into its ViewHolder
. Using DI comes with a cost of writing more code, but for testability, it’s worth it, and hey, you can swap it easily later too. And then I write(copy 😛) FakeImageLoader
in the test package.
I don’t want to make an actual network call, so I mocked the service to return 10 randomly generatedMovie
objects, and instead of loading actual posterPath, FakeImageLoader
would return a black color filled drawable.
Testing Setup
Before we go into different approaches I tried, let me explain a little about how the test structure is set up. First it has its own independent dagger module to replace the service with mock instance. A TestApplication
then calls the separate injection into the app. Custom TestRunner
is also created to replace the default application class with TestApplication
. I know this is confusing for those who are not familiar with instrumentation test, I have shared the Github link of the source at the end of the post, so be sure to check it out to know more in depth.
To accommodate background data call, I had to resort to using runIdling
approach on production code. A test rule has been planned to introduce into coroutine and can be tracked here. Since this is just for demonstration, let’s ignore this for a while.
To recap, we are going to test
If theposterPath
is correctly passed intoImageLoader
List Approach
My first thought is to store the load request in a form of list inside the FakeImageLoader
. Then all we need to do is to check if the value inside the index is the same as the index of the posterPath. A list to store the value is then introduced into FakeImageLoader
We then expose this imageLoader from the dependency graph. This is simple as we only need to write a function inside TestAppComponent
fun imageLoader(): ImageLoader //Add this in TestAppComponent
Now we can access this imageLoader inside the test via
val imageLoader = TestApplication.appComponent().imageLoader()
All we need is to write assertion inside the test now
ANDDDDD the test fails 🛑. Why? Because I forgot the fact that onBindViewHolder
can be called multiple times while scrolling. this causes the request urls to be saved in wrong order multiple times inside the list, I can see 20 items are being passed into the list although I faked only 10 items for the test. So how do we remove duplicate ones? Well, let’s try to use the Set
instead
Set Approach
All we need to do is to replace the List
with Set
and this would remove duplication.
Instead of List
, now we run the test again with Set
ANDDDDD the test fails AGAIN 🛑! Well, debugging shows that duplication problem is solved, and it now has same amount of request as fake generated movie list. The problem is the ordering was still messed up, so we can’t really check with index.
That got me thinking, do we really need to test with index? What I want to test is if posterPath
is passed into the ImageLoader
, doesn’t matter how many times, I only want to verify that imageLoader.load
is called with posterPath
as argument. So if we can check if either List
or Set
contains the posterPath
, then we can say the test is correct. Let’s try with this below assertion instead
Assert.assertEquals(true, requestSet.find { //or requestList
val url = it.data as String
url == movie.posterPath
} != null)
Then the test PASSED ✅!
That means the ImageLoader
provided by COIL is indeed testable. But can we do better than List
and Set
? Can we try using Mockito and use its verify
API to check the data?
Mockito Approach
First of all, we need to replace the FakeImageLoader
with mock. I use mockito-kotlin for this
Then what we need to do is mock the load function to return a mocked Disposable
. In the setUp
function, we add this line of code to mock it
And then in the assertion, all we need to do is change to this implementation.
We will be testing to see if FakeImageLoader.load
method is called with posterPath
as argument where other arguments could be anything. If we want to see if loadRequestBuilder
contains the crossfade, placeholder .. etc. , then we can use ArgumentCaptor
to capture it, and then write assertions. But for now, I will skip those assertions.
If we run the test, it will crash 🛑! Why? Remember we call this extension inline function in the ViewHolder
.
ivPoster.load(item.posterPath, imageLoader)
Well when we compile the code, the previous function is replaced with the following function
imageLoader
.load(LoadRequestBuilder(context, defaults)
.data(url)
.apply(builder)
.build())
Here, the test will throw non-null error because well, we haven’t mocked defaults
. But we can’t mock it since it’s final variable. So, we need to work around this. How so? Well instead of calling provided load
extension inline function, we will need to write the underlying implementation by ourselves.
//Create Load Request Builder
val loadRequestBuilder = LoadRequestBuilder(
itemView.context, LoadRequest(
itemView.context,
defaults = DefaultRequestOptions()
)
)
//Set target to imageView
loadRequestBuilder.target(ivPoster)//Load the request
imageLoader.load(loadRequestBuilder.build())
This solved the issue, and we run the test again.
We can see the test passes ✅, however, there is an another issue, we lose the black drawable provided by FakeImageLoader
before. No worries, we can use thenAnswer
instead of thenReturn
for this.
whenever(imageLoader.load(requestCaptor.capture())).thenAnswer {
//Set a black color drawable
requestCaptor.lastValue.target?.onSuccess(ColorDrawable(Color.BLACK))
object : RequestDisposable {
override fun isDisposed() = true
override fun dispose() {}
}
}
First, we capture any request coming into load
by using an ArgumentCaptor
. And then we immediately answer the function call and set the target’s onSuccess
value as the black color drawable. Afterwards, we return the fake RequestDiposable.
Then we run the test again.
It seems the COIL’s testing feature actually works! We can make sure the ImageLoader
is properly invoked through an instrumentation test. The conclusion of the experiment was
- If we use either
List
orSet
to store the requests, then we don’t need anything and can check withcontains
orfind
. - If we use Mockito’s verify API, then we need a workaround to prevent the call to
imageLoader.default
and construct aDefaultRequestOptions
ourselves.
Here’s an inline function that you can use for ease of use
Check out the code below and happy testing 🔨