Categories
Programming

Testing Intents on Android: Like Stabbing Yourself in the Eye With A Blunt Implement

App running on emulator in Android Studio
App running on emulator in Android Studio

The picture above shows what am I testing: the home screen of my app. There is a camera button, a gallery button and an inspire button. All of these launch intents, but the camera and gallery buttons launch intents that are expected to return something – an image – either from the camera or the gallery.

First up: how the hell do you test an intent? I started with my straightforward Dagger/Mockito setup, but my tests were failing because they were launching things – the camera, or the gallery – which I then couldn’t get out of to continue my tests.

The answer is the IntentsTestRule, which extends ActivityTestRule (if you’ve written a test for an activity, you’ve probably seen this). This took me a little while to make sense of, mainly because I kept getting this error saying that something had been initialised twice. I was launching the intent in my test, and also calling Intents.init(). Turns out, you don’t need to do that. Having an intent rule is a little bit of magic. It just launches itself.

I have a very straightforward test that just checks that things have loaded, and it’s one I kept coming back to in order to see how things work. Here it is.


@Test
public void testLaunchActivity() {
onView(withId(R.id.home_camera_button)).check(matches(withText("Camera")));
onView(withId(R.id.home_gallery_button)).check(matches(withText("Gallery")));
onView(withId(R.id.home_inspire_button)).check(matches(withText("Inspire")));
}

I think sometimes it seems like it’s not worth having tests like this because all of this will be covered elsewhere. Not true. I always include really straightforward tests. If they’re useless, then who cares, you’ll never think about them again. But in practise I return to them again and again when I’m debugging.

First up I got my gallery test working. I had no idea what I was doing, so basically everything was broken, so I started by being really general and then getting more specific once I had it all working together. For example, figuring out which package it was in was a PITA, so to get thing working I used this handy catchall:

intending(not(isInternal())).respondWith(result);

This will return my “result” intent to every intent outside the app. Definitely not as exact as we want in our tests, but really useful for getting things working. In theory you can check ActionType, which in the case of the gallery is Intent.ACTION_PICK. In practise, this Intent was not being captured.

I eventually managed to find out what the package was by using:

Intents.assertNoUnverifiedIntents();

This is a check for unverified intents, and handily in the error message it gave me it returned the package that the intent was coming from. So, for the gallery I had:

intending(toPackage("com.android.gallery")).respondWith(result);

Useful! The full test is:


@Test
public void testTapGalleryButtonAndReturnOK() {
// Stub the Uri returned by the gallery intent.
Uri uri = Uri.parse("uri_string");
// Build a result to return when the activity is launched.
Intent resultData = new Intent();
resultData.setData(uri);
Instrumentation.ActivityResult result =
new Instrumentation.ActivityResult(Activity.RESULT_OK, resultData);
// Set up result stubbing when an intent sent to "choose photo" is seen.
intending(toPackage("com.android.gallery")).respondWith(result);
onView(withId(R.id.home_gallery_button)).check(matches(withText("Gallery")));
onView(withId(R.id.home_gallery_button)).perform(click());
// Check image processor is reset.
verify(imageProcessor).resetOriginalImage();
// New activity should be launched
intended(hasComponent(ImageEditingActivity.class.getName()));
}

Here I had some SUPER FUN debugging. By super fun I mean: like stabbing myself in the eye with a blunt implement. Luckily there were no blunt implements to hand and I have a strong sense of self preservation. Once I got my intent working, it seemed like the test was done. I added some validation of a new activity getting launched. Also, I decided to add validation on something else should happen – that the image processor gets reset. This should be straightforward, right? Everything is set up with Dagger and injected, I have my Mock Providers, and Mock Component for my tests. I just need to verify.

Nope. It didn’t work, and it took me a long time to figure out why.

The issue was that I didn’t have my mock objects in my activity. I thought this was because the activity was being relaunched, but that was a red-herring. The mock objects were being interacted with in the new activity, so I knew that my setup was somewhat right, but clearly not completely! Obsessively trying to hunt down the cause for this did help me fix a bug though – I had a fall through in a switch statement in my activity.

Breakpoints had failed me (I was putting breakpoints in my tests, but the debugger wasn’t stopping on them – grr) I returned to my very straightforward test, and started just adding some log statements. This helped me figure out the problem: onCreate() in the HomeActivity was being called before setup (labelled with the annotation @Before) in my HomeActivityTest. So I had real objects in my activity under test, because it was using the usual dagger components, not the test ones.

Once I knew what the problem was, I knew what to search for to find a fix for it. The conclusion: I needed to subclass the IntentsTestRule, and override beforeActivityLaunched() to put my pre-activity setup code in it.


private static class HomeActivityTestRule extends IntentsTestRule {
private HomeActivityTestRule() {
super(HomeActivity.class);
}
@Override public void beforeActivityLaunched() {
Instrumentation instrumentation = InstrumentationRegistry.getInstrumentation();
ShowAndHideApplication app =
(ShowAndHideApplication) instrumentation.getTargetContext().getApplicationContext();
ShowAndHideTestComponent component = DaggerShowAndHideTestComponent.builder()
.mockFileUtilitiesModule(new MockFileUtilitiesModule())
.mockImageProcessorModule(new MockImageProcessorModule())
.build();
app.setComponent(component);
}
}

There was no new code here – this was in my standard test setup. It’s just in a new place. I made it within the test, so it’s a private static class. As I add more tests I may move it out to be reusable. For now, it’s only binding the components I need in this test. If I move it out, I’ll need to bind everything.

Then I added the test for the result failed test. This is more straightforward so it’s tempting to add this first, but in practise I never add my tests for nothing happening first because I never have any confidence that they actually work until I can break them.


@Test
public void testTapGalleryButtonAndReturnCancel() {
// Build a result to return when the activity is launched.
Intent resultData = new Intent();
Instrumentation.ActivityResult result =
new Instrumentation.ActivityResult(Activity.RESULT_CANCELED, resultData);
// Set up result stubbing when an intent sent to "choose photo" is seen.
intending(toPackage("com.android.gallery")).respondWith(result);
onView(withId(R.id.home_gallery_button)).check(matches(withText("Gallery")));
onView(withId(R.id.home_gallery_button)).perform(click());
// Check image processor is not reset.
verify(imageProcessor, never()).resetOriginalImage();
}

Notice that in the first test, I assert my image processor gets reset. In this one I assert that it doesn’t get reset.

Next, testing the camera intent. It’s basically the same, but I also have to mock the Uri, because when you take a photo on Android you first have to allocate space for it. I have complained about this before so I will refrain here.


@Test
public void testTapCameraButtonAndReturnOK() {
// Uri needed to launch the Camera intent.
Uri uri = Uri.parse("uri_string");
stub(fileUtilities.getOutputMediaFileUri()).toReturn(uri);
// Build a result to return when the activity is launched.
Intent resultData = new Intent();
Instrumentation.ActivityResult result =
new Instrumentation.ActivityResult(Activity.RESULT_OK, resultData);
// Stub result for camera intent.
intending(toPackage("com.android.camera")).respondWith(result);
onView(withId(R.id.home_camera_button)).check(matches(withText("Camera")));
onView(withId(R.id.home_camera_button)).perform(click());
// Check image processor is reset.
verify(imageProcessor).resetOriginalImage();
// New activity should be launched
intended(hasComponent(ImageEditingActivity.class.getName()));
}

Notice at the end of it we assert that we’ve transitioned to the next activity – the ImageEditingActivity. This is what this line does.

intended(hasComponent(ImageEditingActivity.class.getName()));

And again, we check that it works when the result is cancelled.


@Test
public void testTapCameraButtonAndCancel() {
// Uri needed to launch the Camera intent.
Uri uri = Uri.parse("uri_string");
stub(fileUtilities.getOutputMediaFileUri()).toReturn(uri);
// Build a result to return when the activity is launched.
Intent resultData = new Intent();
Instrumentation.ActivityResult result =
new Instrumentation.ActivityResult(Activity.RESULT_CANCELED, resultData);
// Stub result for camera intent.
intending(toPackage("com.android.camera")).respondWith(result);
onView(withId(R.id.home_camera_button)).check(matches(withText("Camera")));
onView(withId(R.id.home_camera_button)).perform(click());
// Check image processor is not reset.
verify(imageProcessor, never()).resetOriginalImage();
}

It was tempting to delete the mock fileUri here, because it seems like we don’t need it, but actually if I did that it would not be testing what I want it to, even though the behaviour (nothing happens) would be the same.

In both the cancel intents, I wanted to assert that no new intent is launched, but it’s wasn’t clear how to do that. Intents.assertNoUnverifiedIntents() was failing on things I’d stubbed. I realised that I had to stub and expect things, so I needed an intended() for each intending(), and voila! It works. Once I discovered this, I went back through all of my tests and added the requisite lines.

Finally! My most straightforward intent test, and you may wonder why I didn’t start with it. As do I. There’s a third button, inspire, which launches the web browser with a specific URL. Note that this test uses intended() rather than intending(), and that it comes after the intent is launched – not before. At the end, we assert no unverified intents.


@Test
public void testTapInspireButton() {
onView(withId(R.id.home_inspire_button)).check(matches(withText("Inspire")));
onView(withId(R.id.home_inspire_button)).perform(click());
// Capture web browser intent.
intended(toPackage("com.android.browser"));
Intents.assertNoUnverifiedIntents();
}

Note: one tutorial I found suggested stubbing all external intents with the blanket catchall, and setting it up in a method annotated with @Before. I didn’t do this and I don’t think it’s a good idea. Launching an external intent is something that you should be capturing deliberately, and that’s the whole point of testing it. I would be more likely to go the other way and assert no unverified intents in a method annotated @After!

Complete code for this HomeActivityTest.

Useful Resources

  • Setting up
  • Sample code
  • Chiu-Ki took me through getting Dagger and Espresso set up in the first place, ages ago. And if you’re not lucky enough to be friends with her (and passing through Denver), she has a bunch of resources for testing on Android on her website.

4 replies on “Testing Intents on Android: Like Stabbing Yourself in the Eye With A Blunt Implement”

Thanks for the post.
It looks like this works only when you launch an Intent with startActivity() or startActivityWithResult(). To me it doesn’t work when I want to test I’m launching a service with startService().

M.

Comments are closed.