Shining Light on Robolectric Shadows
One of the most powerful features of Robolectric is its Shadow concept. What’s a Shadow? The docs describe what’s behind the name as:
Shadow objects are not quite Proxies, not quite Fakes, not quite Mocks or Stubs. Shadows are sometimes hidden, sometimes seen, and can lead you to the real object. At least we didn’t call them “sheep”, which we were considering.
Shadows lurk behind real Android framework classes giving Robolectric the chance to intercept method calls to them. This allows code that interacts with those objects to work even when the Android OS isn’t available, such as when testing on a development or CI machine.
The best way to understand is to look at an example.
Let’s imagine I want to test my code is properly requesting audio focus.
@Test
fun test_play_requests_audio_focus() {
// GIVEN
val audioManager: AudioManager = ...
val player = MyPlayer(...).apply {
/* setup */
}
Shadows.shadowOf(audioManager)
.setNextFocusRequestResponse(AudioManager.AUDIOFOCUS_REQUEST_GRANTED)
// WHEN
player.play()
// THEN
val request = Shadows.shadowOf(audioManager).lastAudioFocusRequest
/* Do checks */
}
On a virtual or physical Android device there’s a system service that actually handles audio focus requests, but there isn’t one here. Robolectric uses byte code instrumentation to swap in a verified fake to do this, so your app code doesn’t need to change.
The code above shows that the shadow of AudioManager has extra methods to set the response to certain API requests (such as setNextFocusRequestResponse
), and public variables to get the result of calls (ShadowAudioManager.lastAudioFocusRequest
).
Custom shadows
That works well when there are shadow classes, but what about when there isn’t one? It turns out that custom shadows are very easy to write.
Let’s say we want to test something that uses ImageView.setImageResource(resId: Int)
. Robolectric doesn’t include a ShadowImageView
, so we’ll have to write our own.
The first thing we need to do is create a class, perhaps ShadowImageView
, and annotate it so Robolectric knows it’s a shadow of ImageView
:
@Implements(ImageView::class)
class ShadowImageView {
}
Next, we want to implement the methods we’ll use in our test, which in this case is just setImageResource
:
@Implementation
protected fun setImageResource(resId: Int) {
setImageResource = resId
}
Notice that we annotate it with @Implementation
. This tells Robolectric that this function is the implementation for a method on ImageView
. Since this function matches the signature of ImageView. setImageResource
, this method, on our shadow, will be called instead of the real implementation in ImageView
.
We can write whatever we’d like here, but let’s just keep it ultra simple. We’ll save the resource ID set, and allow that resource to be looked at so it can be verified. Perhaps something like:
@Implements(ImageView::class)
class ShadowImageView {
@DrawableRes
var setImageResource: Int = 0
private set
@Implementation
protected fun setImageResource(resId: Int) {
setImageResource = resId
}
}
Now we can write our tests to use it!
Using custom shadows
First, we need to tell Robolectric that we have a custom shadow. The easiest way to do that is to add a @Config
annotation to our test class, following our @RunWith
annotation:
@RunWith(RobolectricTestRunner::class)
@Config(shadows = [ShadowImageView::class])
class ExampleUnitTest {
If we had more shadows we could add them in the array there, but, in this case, we only have the one.
Next, we write our test:
@Test
fun test_complexImageView() {
val imageView: ImageView = layout.findViewById(R.id.image_view)
myApp.processWithView(imageView)
// ...
Robolectric instruments every instance of a class with a shadow, whether created explicitly (i.e.: val view = View(context)
) or inflated from an XML file (i.e.: val layout = layoutInflater.inflate(R.layout.my_layout, root)
).
With built-in shadows, it’s possible to use Shadows.shadowOf
, like we did for AudioManager
, but to get a custom shadow we need to use Shadow.extract
:
Shadow.extract<ShadowImageView>(imageView)
This returns the instance of our ShadowImageView
that’s associated with the real object imageView
.
With a ShadowImageView
instance, we can easily check our code called setImageResource
with the correct parameter:
Assert.assertEquals(
Shadow.extract<ShadowImageView>(view)
.setImageResource, R.drawable.ic_launcher_foreground)
Note that we use Shadow.extract
each time to access it. Shadows should not be saved in temporary variables. While this might seem odd at first, holding onto two different, but very similar objects, can lead to confusion and subtle errors in the code. By only holding references to the real objects, the confusion is eliminated.
Wrapping up
We’ve talked about what shadows are, and how they can be useful. We also saw how they’re surprisingly easy to write our own Shadows.
This was only a very brief introduction, however, and more information can be found in the official docs.
Happy testing!