Not A Subscriber?
Join thousands of people who build and refuse to get left behind. One message a week on building, using AI as an accelerator, and staying sharp in the new AI frontier.
Receive one free message a weekJanuary 6, 2014 Donn Felker
Robolectric ShadowTaskStackBuilder
I’m a proponent of unit testing and once you figure out how to use Robolectric its great for helping you unit test Android. That is, until you run into an instance where a Shadow class is not implemented and you get some obscure error while running the unit test. Such thing happened recently when trying to get some code under test.
The code previously used a Context#startActivitiy(…) to start the Activity. I’m building some deep linking framework for a client for their Android app and I needed to use the TaskStackBuilder in order to build the back tasks properly. The code looked similar to this:
PfmDispatcher.java
// Build the intent instance, etcTaskStackBuilder t = TaskStackBuilder.create(context);t.addNextIntentWithParentStack(intent);t.startActivities();The code above starts and Activity and if the Activity defines a parent activity in the AndroidManifest.xml the entire task stack will be built and the activity will be started. My existing unit test to test this looked like this:
DeepLinkRouterTest.java
@Test public void shouldBeAbleToGoToComments() {
Uri deepLink = Uri.parse("pfm:///user/frank/status/123/?version=88983845");
System.out.println(String.format("Testing %s", deepLink.toString()));
// The intent to start. Intent expectedIntent = new Intent(Robolectric.application, Status.class); expectedIntent.putExtra(Constants.Extras.STATUS_ID, 123L); IntentCreator mockIntentCreator = mock(IntentCreator.class);
// Set up the stub when(mockIntentCreator.newCommentsIntent(anyLong())).thenReturn(expectedIntent);
// Set up the router and run the code that will run the code path we want to test DeepLinkRouterDouble router = DeepLinkRouterDouble.getInstance(Robolectric.application, Routes.class, new PfmDispatcher(Robolectric.application, mockIntentCreator), deepLink); router.route();
// Assert that the intent returned goes to the welcome screen. final Intent next = shadowOf(Robolectric.application).getNextStartedActivity();
ANDROID.assertThat(resolvedIntent).hasComponent(Robolectric.application, next);
ANDROID.assertThat(next).hasExtra(Constants.Extras.STATUS_ID);
// Verify that the intended intent creator method was indeed called. verify(mockIntentCreator).newCommentsIntent(123L);
}The goal was to make sure the test above still passed when I moved from the traditional Context object to a TaskStackBuilder instance for starting the Activities. Unfortunately moving to use the TaskStackBuilder made the test fail with a framework exception. Robolectric does not have a ShadowTaskStackBuilder. So I built a very rudimentary one that would help my test pass, its not 100% perfect by any means but it should give you a jumping off point if you want to extend it as it needs a bit of work.
Writing the Shadow
ShadowTaskStackBuilder.java
package com.pfm.shadows;
import android.content.Context;import android.content.Intent;import android.support.v4.app.TaskStackBuilder;import org.robolectric.Robolectric;import org.robolectric.annotation.Implementation;import org.robolectric.annotation.Implements;import org.robolectric.annotation.RealObject;
import static org.robolectric.bytecode.ShadowWrangler.shadowOf;import static org.robolectric.Robolectric.directlyOn;/** * This is kind of a hack, The real task stack builder walks the Android Manifest and then creates * the various child activities. I have not taken the time to figure out if we can * manually instantiate the activities or not. This is a simple fake routine to work around. */@SuppressWarnings({"UnusedDeclaration"})@Implements(TaskStackBuilder.class)public class ShadowTaskStackBuilder{ private Context context; private Intent intent;
@RealObject TaskStackBuilder realTaskStackBuilder;
private void setup(Context context) { this.context = context;
}
@Implementation public static TaskStackBuilder create(Context context) { TaskStackBuilder taskStackBuilder = Robolectric.newInstanceOf(TaskStackBuilder.class); ShadowTaskStackBuilder stsb = (ShadowTaskStackBuilder) shadowOf(taskStackBuilder); stsb.setup(context); return taskStackBuilder; }
@Implementation public TaskStackBuilder addNextIntentWithParentStack(Intent intent) { this.intent = intent; return realTaskStackBuilder; }
@Implementation public void startActivities() { context.startActivity(intent); }
}When the test is run in Robolectric and configured to use this Shadow (as shown below) the code above will set up a new instance of TaskStackBuilder and use the Shadow to manage as the execution. In What we’re actually doing is avoiding all of the actual Implementation of TaskStackBuilder all together so we can get this test to pass. The code builds a TaskStackBuilder and then when the addNextIntentWithParentStack(Intent intent) method is called the intent is saved locally in the shadow. When the user calls startActivities on the TaskStackBuilder, we intercept that and simply call context.startActivity(intent). Since the context is an instance of Robolectric.application this works out great.
Is this perfect? By all means, no. But it will help someone who is stuck. How can it be improved? If you look at the source of TaskStackBuilder you’ll notice it delegates the activity creation and starting of them to a class called ContextCompat and it also uses IntentCompat. The source of ContextCompat reveals the usage of two other classes: ContextCompatJellybean and ContextCompatHoneycomb as well as some other code. As you can see, its a mini pandoras box under the hood. My implementation above avoids all of the code below and allow us to trust that TaskStackBuilder is doing what its supposed to.
Simply assuming that code will work is dangerous in practice you could do the following to ease your testing anxieties:
• Implement Shadows for all of those (have fun with that)
• Write a test that checks to manifest to ensure that parent activities are set in the AndroidManifest.xml
• Write integration tests that ensure that the up state is working.
Using these Shadow
To use the Shadow, create your test class and annotate it with the @Config annotation as shown below:
DeepLinkRouterTest_V2.java
@RunWith(RobolectricTestRunner.class)@Config(shadows = { ShadowTaskStackBuilder.class }) // Robolectric will read this and use the shadow for this test filepublic class DeepLinkRouterTest{ @Test public void shouldBeAbleToGoToComments() {
Uri deepLink = Uri.parse("pfm:///user/frank/status/123/?version=88983845");
System.out.println(String.format("Testing %s", deepLink.toString()));
// The intent to start. Intent expectedIntent = new Intent(Robolectric.application, Status.class); expectedIntent.putExtra(Constants.Extras.STATUS_ID, 123L); IntentCreator mockIntentCreator = mock(IntentCreator.class);
// Set up the stub when(mockIntentCreator.newCommentsIntent(anyLong())).thenReturn(expectedIntent);
// Set up the router and run the code that will run the code path we want to test DeepLinkRouterDouble router = DeepLinkRouterDouble.getInstance(Robolectric.application, Routes.class, new PfmDispatcher(Robolectric.application, mockIntentCreator), deepLink); router.route();
// Assert that the intent returned goes to the welcome screen. final Intent next = shadowOf(Robolectric.application).getNextStartedActivity();
ANDROID.assertThat(resolvedIntent).hasComponent(Robolectric.application, next);
ANDROID.assertThat(next).hasExtra(Constants.Extras.STATUS_ID);
// Verify that the intended intent creator method was indeed called. verify(mockIntentCreator).newCommentsIntent(123L); }}Thats it. You now have a very naive implementation of ShadowTaskStackBuilder to use in your application.