Testing COIL with Espresso

Testing COIL with Espresso

Image are not owned by me

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.

App Preview

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 the posterPath is correctly passed into ImageLoader

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 ✅!

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

.load(LoadRequestBuilder(context, defaults)

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(
    defaults = DefaultRequestOptions()

//Set target to imageView
loadRequestBuilder.target(ivPoster)//Load the request

This solved the issue, and we run the test again.

Test passed! but the images are blank!

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

  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.

The magic is back! And test passes!

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 or Set to store the requests, then we don’t need anything and can check with contains or find .
  • 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 🔨

You can't perform that action at this time. You signed in with another tab or window. You signed out in another tab or…
Show Comments