Using Robolectric to Test Classes with Dagger-Injected Dependencies

Brian Terczynski
Jun 30 · 4 min read

It is common for Android code to use dependency injection (DI). And one of the tenets of DI is to make code more testable. So it would follow that if your Android classes use DI, then they should be easily testable.

That is true if your class uses constructor injection. You simply call the class’s constructor and provide implementations of its dependencies in the parameters to the constructor. Often, that means passing mocks of the dependencies so you can simulate various conditions in your unit test.

But if your class uses field injection, then testing is a little tricker. In that case, you need to construct the object, and then somehow set the values of each injected field before you test any methods that use those fields.

The real problem comes when your class uses field injection and you cannot control the creation lifecycle of the class. That is the case with several Android classes, like Activities and Views. The Android framework controls the creation of these. For example, a View is often created by inflating it from an XML file, meaning that the Android framework will not only control calling the View’s (default) constructor but will also call various lifecycle methods such as onFinishInflate(). And since we cannot specify custom constructors for injecting dependencies, we need to use field injection.

With Dagger, field injection works by annotating such fields with @Inject, and then the class with those fields will call the relevant injection method in the @Component object, passing itself in so that Dagger can set those fields on that object appropriately. The call looks something like this:

class OurView {
...
init {
ourComponent.inject(this)
...
}
...
}

The corresponding method in the component interface is like this:

@Component
interface OurComponent {
...
// Note that Dagger does not care about the name of this method;
// we call it inject() per our own convention.
fun inject(ourView: OurView)
...
}

And often, that call will be done in the constructor or some other initialization block (e. g. an init block in Kotlin) or some initializing lifecycle method like onCreate() or onFinishInflate().

If you are writing a unit test within Robolectric, you want to have control over how those injected field values are set. For example, if one of those fields is your Presenter, you might want to mock it and have it return errors for particular tests, or return different values to test how your View renders those. That means you want to override what myComponent.inject(myView) does. As such, it means that you need to be able to pass in a special implementation of your @Component to your class-under-test, so that you can have it inject whatever values you wish.

For our Android apps at Thumbtack, we have this exact situation. We have several View classes that have Dagger field injection. And almost all of them call the Component’s inject method within their init{} blocks (our code base is almost all Kotlin). For our particular case, our @Component is accessible as a field from within our Application object, so our View classes are able to call their injection methods in a manner similar to the following:

class OurView {
...
init {
if (!isInEditMode) {
(context.applicationContext as OurApplication)
.appComponent
.inject(this)
}

...
}
...
}

So when we write a Robolectric test for this View, we subclass our Application class to provide our own implementation of Component, like so:

object TestApplication : OurApplication() {
...
override val appComponent: OurComponent = mockk() // mockk.io
}

You will notice in the above that, rather than providing a subclass of our Component, we simply provide a mock object. It makes it easier than having to provide implementations for every inject method within that Component, which in our case is hundreds of methods.

More importantly, by making it a mock, each test we write can simply provide implementations of only those inject() methods that are needed. For OurView, all we need to do is provide an implementation of that inject() method to the mock, and we can have it set the View’s fields however we wish:

@Config(
application = TestApplication::class,
... other config values here ...
)
@RunWith(RobolectricTestRunner::class)
class OurViewTest {

Often, a View may depend on another View that also has injected fields (for example, a child View). For that, all you have to do is provide another every{}.answers{} clause, but for that child View.

We even wrote a helper method to make the call site syntax slightly more concise. It is an extension method that you call on the View’s class object. The argument is a lambda that runs on the instance of the View being constructed, so “this” refers to the OurView instance being constructed. Within this lambda, you assign the necessary values to the injected fields of your View. So now the above would be called as:

@Before
fun setUp() {
OurView::class.setupInjection {
textFormatter = mockTextFormatter
clock = mockClock
logger = mockLogger
}

...
}

The setupInjection() method is defined as:

inline fun <reified B : Any, reified T : KClass<B>>
T.setupInjection(
crossinline callback: B.() -> Unit
) {
val method = appComponent.javaClass.getMethod(
"inject",
this.java
)
every {
method.invoke(appComponent, ofType(this@setupInjection))
} answers {
val injectionTarget = this.firstArg<B>()
callback.invoke(injectionTarget)
}
}

The syntax at the call site is now a little more concise.

The above demonstrates how one can write unit tests for Android classes with field dependency injection when running them in an Android framework such as Robolectric. And really, the key is overriding the method that does the actual field injection and doing so before the object is initialized. For Dagger, it involves overriding the injection method within the relevant @Component class. Once that is done, you are able to reap the benefits of dependency injection and still be able to write focused unit tests for your Android components.

Thumbtack Engineering

From the Engineering team at Thumbtack

Welcome to a place where words matter. On Medium, smart voices and original ideas take center stage - with no ads in sight. Watch

Follow all the topics you care about, and we’ll deliver the best stories for you to your homepage and inbox. Explore

Get unlimited access to the best stories on Medium — and support writers while you’re at it. Just $5/month. Upgrade

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store