Synchronize Glide Loading in Test with IdlingResource
Glide allows us to load images asynchronously.
However, the asynchronous loading behavior is not testable which can become a pain.
In some instrumented test scenarios, if the execution of the test code and the loading task of Glide cannot ensure synchronization, then the test result can become uncontrollable.
Thankfully, Espresso provides IdlingResource to help us executing asynchronous work in test code synchronously which can solve this problem.
In this post, I will show you how to synchronize the image loading task of Glide in test code with minimal impact on the production code. Some codes are inspired by this issue.
Synchronize with CountingIdlingResource and Target
The interface IdlingResource represents a resource that may do asynchronous work.
It provides an abstract method named boolean isIdleNow() that indicates whether the resource is currently idle or not.
CountingIdlingResource is an implementation of IdlingResource.
It maintains a counter and we can increase or decrease the count.
When the count is 0, isIdleNow() will returns true.
CountingIdlingResource is very suitable for representing the image loading task of Glide.
When a loading task starts we increase the count by 1; when a loading task ends we decrease the count by 1.
To observe the lifecycle of the loading task, we can implement the Target interface.
Target is an interface that Glide can load a resource into and notify of relevant lifecycle events during a load.
The most frequently used method into(ImageView) will finally wrap the ImageView into a Target.
Target provides four lifecycle callbacks:
onLoadStartedonLoadFailedonLoadClearedonResourceReady
When onLoadStarted is called it indicates a loading task starts, and the other three callbacks indicate a loading task ends.
Another point to note is that only the callback onLoadStarted is guaranteed to be called.
If the size of Target is invalid (e.g. visibility of an ImageView is gone) then the other three callbacks will never be called.
To ensure the CountingIdlingResource will eventually become idle instead of waiting until timeout,
we need to decrease the count manually if Target size is invalid.
IdlingResource Target Example
Let's take a look at an example that extends the DrawableImageViewTarget (a base class that implement Target and suitable for most Glide use cases):
class IdlingResourceTarget(private val idlingResource: CountingIdlingResource,view: ImageView) : DrawableImageViewTarget(view) {private var isLoading = falseset(value) {// Only change the count when isLoading really changedif (field != value) {field = valueif (value) idlingResource.increment() else idlingResource.decrement()}}// A Runnable to set isLoading to false if the size is invalidprivate val checkSizeTimeOutRunnable = Runnable {isLoading = false}override fun onLoadStarted(placeholder: Drawable?) {isLoading = trueval handler = Handler(Looper.getMainLooper())// If we cannot get a valid size during the delay (1000ms) then set isLoading to falsehandler.postDelayed(checkSizeTimeOutRunnable, 1_000)getSize { _, _ ->// This callback will only be called if the size is validhandler.removeCallbacks(checkSizeTimeOutRunnable)}super.onLoadStarted(placeholder)}override fun onLoadFailed(errorDrawable: Drawable?) {isLoading = falsesuper.onLoadFailed(errorDrawable)}override fun onLoadCleared(placeholder: Drawable?) {isLoading = falsesuper.onLoadCleared(placeholder)}override fun onResourceReady(resource: Drawable, transition: Transition<in Drawable>?) {isLoading = falsesuper.onResourceReady(resource, transition)}}
If your App uses many custom Target implementations, you can create IdlingResourceTarget with the delegate pattern as described in this comment.
Now we can use the IdlingResourceTarget like this (actually we can't and will be explained in the next section):
Glide.with(fragment).load(imageUrl).into(IdlingResourceTarget(countingIdlingResource,imageView))
Create Different Loading Strategy in Test and Production Flavor
Normally, we only implement the Espresso in the test flavor.
So using the IdlingResourceTarget in the production code is impossible.
To solve this problem, let the interface to do the rescue.
First we define a ImageLoadStrategy interface:
interface ImageLoadStrategy {// Override this method to decide how to apply the request to the imageViewfun apply(request: GlideRequest<Drawable>, imageView: ImageView)}
Then, let the ImageLoadStrategy companion object implements itself with the delegate pattern.
Also define a Normal implementation of ImageLoadStrategy that simply calls request.into(view).
Set the Normal as the default delegate:
interface ImageLoadStrategy {companion object : ImageLoadStrategy {private var delegate: ImageLoadStrategy = Normaloverride fun apply(request: GlideRequest<Drawable>, imageView: ImageView) {delegate.apply(request, imageView)}@VisibleForTestingfun setTestStrategy(strategy: ImageLoadStrategy) {delegate = strategy}@VisibleForTestingfun resetStrategy() {delegate = Normal}}fun apply(request: GlideRequest<Drawable>, imageView: ImageView)private object Normal : ImageLoadStrategy {override fun apply(request: GlideRequest<Drawable>, imageView: ImageView) {request.into(imageView)}}}
Finally, create an extension function intoViewWithStrategy for GlideRequest:
fun GlideRequest<Drawable>.intoViewWithStrategy(view: ImageView) {ImageLoadStrategy.apply(this, view)}
Now, in the production code, calling intoViewWithStrategy(imageView) instead of into(imageView):
Glide.with(fragment).load(imageUrl).intoViewWithStrategy(imageView)
With ImageLoadStrategy the production code will work like before and can load images in another strategy in the test.
Implement the Test ImageLoadStrategy
The simplest way to use ImageLoadStrategy with IdlingResourceTarget in test is like this:
val glideIdlingResource: CountingIdlingResource = CountingIdlingResource("Glide")@Beforefun setUp() {IdlingRegistry.getInstance().register(glideIdlingResource)ImageLoadStrategy.setTestStrategy(object : ImageLoadStrategy {override fun apply(request: GlideRequest<Drawable>, imageView: ImageView) {request.into(IdlingResourceTarget(countingIdlingResource, imageView))}})}@Afterfun tearDown() {ImageLoadStrategy.resetStrategy()IdlingRegistry.getInstance().unregister(glideIdlingResource)}
However, the above code has two problems:
- Not considering the
ScaleTypeof the imageView while theNormalstrategy,into(imageView), considered it. - Too much boilerplate code if we need to apply the test strategy in many tests.
To let the ScaleType just works the same with Normal strategy, simply copy the real implementation into an extension function:
fun GlideRequest<Drawable>.intoIdlingResourceTarget(idlingResource: CountingIdlingResource,view: ImageView) {val requestBuilder =if (!isTransformationSet && isTransformationAllowed && view.scaleType != null) {when (view.scaleType) {ScaleType.CENTER_CROP -> clone().optionalCenterCrop()ScaleType.CENTER_INSIDE -> clone().optionalCenterInside()ScaleType.FIT_CENTER, ScaleType.FIT_START, ScaleType.FIT_END -> clone().optionalFitCenter()ScaleType.FIT_XY -> clone().optionalCenterInside()ScaleType.CENTER, ScaleType.MATRIX -> thiselse -> this}} else {this}// Actually, modify other options can also be done here, e.g. disable thumbnail with `thumbnail(null)`.requestBuilder.into(IdlingResourceTarget(idlingResource, view))}
To reduce boilerplate code we can create a custom test rule:
class GlideTestRule : ExternalResource() {private var glideIdlingResource: CountingIdlingResource? = nulloverride fun before() {glideIdlingResource = CountingIdlingResource("Glide")ImageLoadStrategy.setTestStrategy(object : ImageLoadStrategy {override fun apply(request: GlideRequest<Drawable>, imageView: ImageView) {request.intoIdlingResourceTarget(glideIdlingResource!!, imageView)}})IdlingRegistry.getInstance().register(glideIdlingResource)}override fun after() {ImageLoadStrategy.resetStrategy()IdlingRegistry.getInstance().unregister(glideIdlingResource)glideIdlingResource = null}}
Now, we only need to define a GlideTestRule in the test:
@get:Ruleval glideTestRule = GlideTestRule()
Recap
- Use
intoViewWithStrategy(imageView)in production code. - Use
GlideTestRulein test code.