Testing Activity lifecycle callback methods in Android
Tags: android, activitytestrule, junit4, testing
Published: 2020-01-12 17:27:29

---
title: Testing Activity lifecycle callback methods in Android
published: true
description: Android Activity lifecycle and JUnit4 test lifecycle do not work in ways that make it easy to test behaviors that happen until after the Activity is resumed. This article has a simple example of working around these lifecycle differences that leverages runtime annotations.
tags: Android, ActivityTestRule, JUnit4, Testing
---

Suppose that you're testing an `Activity` class. In this `Activity` class, the `Intent` used to start the `Activity` causes a difference in behavior in the `onCreate()`, `onStart()` or `onResume()` lifecycle callbacks. Therefore, you need to test that the `Activity` class treats different `Intent` instances differently. This is straightforward enough, right? Just create a different `Intent` for a bunch of different variations of the test . . . but how do you do that?

As a simple example that illustrates the true problem in setting up the test and motivates the solution, we're just going to have a `Activity` that shows a `TextView`. The requirements for this new `Activity` are as follows:
1. If the `Intent` used to start it _DOES NOT_ have the "text" string extra, then show "Hello World!"
2. If the `Intent` used to start it _DOES_ have the "text" string extra, then show whatever is in that extra

So to get started, do the following:
1. Create a new project via AndroidStudio
2. Select the Empty Activity option
3. Open the generated activity_main.xml layout file and add the following attribute to the `TextView` element: `android:id="@+id/hello"` (which will allow us to reference it in the test and in the `MainActivity`
4. Add the following `androidTestImplementation` dependency to your app's build.gradle file: `androidTestImplementation 'androidx.test:rules:1.1.0'`

Now that you have the setup in-place, Create a new class in the `androidTest` source set:

```java
@RunWith(AndroidJUnit4.class)
public class MainActivityTest {

    @Rule
    public final ActivityTestRule<MainActivity> rule
            = new ActivityTestRule<>(MainActivity.class);

    @Test
    public void shouldShowHelloWorldByDefault() {
        Espresso.onView(ViewMatchers.withId(R.id.hello))
                .check(ViewAssertions.matches(ViewMatchers.withText("Hello World!")));
    }
}
```

This test is good enough to test the default case. And it passes! And it was pretty easy to write. So far so good!

What about the non-default case? Alright--lets customize the `Intent` we send into the `Activity`. In order to do that, we'll create an inline extension of `ActivityTestRule` that overrides the `getActivityIntent()` method:

```java
@RunWith(AndroidJUnit4.class)
public class MainActivityTest {

    @Rule
    public final ActivityTestRule<MainActivity> rule
            = new ActivityTestRule<MainActivity>(MainActivity.class) {
        @Override
        protected Intent getActivityIntent() {
            return new Intent(getApplicationContext(), MainActivity.class)
                    .putExtra("text", "Goodbye, Cruel World!");
        }
    };

    @Test
    public void shouldShowHelloWorldByDefault() {
        Espresso.onView(ViewMatchers.withId(R.id.hello))
                .check(ViewAssertions.matches(ViewMatchers.withText("Hello World!")));
    }
}
```

That was easy enough, and our test still passes, but it really shouldn't. It passes because we haven't actually updated the `MainActivity` to set the text by the intent. Lets do that:

```java
public class MainActivity extends AppCompatActivity {

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);

        String text = getIntent().getStringExtra("text");
        if (text != null) {
            ((TextView) findViewById(R.id.hello)).setText(text);
        }
    }
}
```

That should be enough to support the requirements of the `MainActivity`, but now our test, correctly, fails. Lets just ignore that test for now and write a test that will expect "Goodbye, Cruel World!" to be shown:

```java
@RunWith(AndroidJUnit4.class)
public class MainActivityTest {

    @Rule
    public final ActivityTestRule<MainActivity> rule
            = new ActivityTestRule<MainActivity>(MainActivity.class) {
        @Override
        protected Intent getActivityIntent() {
            return new Intent(getApplicationContext(), MainActivity.class)
                    .putExtra("text", "Goodbye, Cruel World!");
        }
    };

    @Ignore
    @Test
    public void shouldShowHelloWorldByDefault() {
        Espresso.onView(ViewMatchers.withId(R.id.hello))
                .check(ViewAssertions.matches(ViewMatchers.withText("Hello World!")));
    }

    @Test
    public void shouldShowTextFromExtraInIntent() {
        Espresso.onView(ViewMatchers.withId(R.id.hello))
                .check(ViewAssertions.matches(ViewMatchers.withText("Goodbye, Cruel World!")));
    }
}
```

So we've got a problem testing more than one case in the same test class. Now that the problem has been established, why is it a problem? The answer is that the lifecycle of your JUnit4 test starts _AFTER_ the Activity you're testing has been resumed.

Don't worry--we're going to solve this problem.

## A few naive approaches

One could imagine a few different ways to solve this issue. Here are a couple naive ways:
* Create a test class for each intent that you want to send to launch the Activity
* Delay the launch of the activity you're testing by starting the activity you want to test from an intermediate activity.

### Why are these approaches naive?

Creating a new test class for each intent you want to use will work for you only if the number of cases is low. For example, there are three cases that we need to test:

1. The default case
2. The non-default case
3. The non-default case with a different value than in case 2 (to ensure that the source of the string was, indeed the intent extra)

However, as the number of cases proliferate, it could get laborious to maintain all of those test classes.

Delaying the launch of the activity under test by using an intermediary is one way to work around the proliferation of classes and copied code. However, there are two reasons why this solution is not the best:
1. You have to launch an activity that you don't intend to test in order to test the activity you intend to test. Thus, you need to create this dummy `Activity` class.
2. This approach works best with the `ActivityTestRule` constructor that launches the activity once per test class instead of launching a new activity per test. Doing so opens the door to leaking state between tests.

## Some better solutions

The following are two better solutions:
1. (not recommended) Use the JUnit4 `Parameterized` test runner and constructor arguments to parameterize your test. See the drawbacks of this approach below.
2. Write a custom `RUNTIME` retention annotation that you then read when the `ActivityTestRule` is applied.

### The JUnit4 Parameterized Runner approach

```java
@RunWith(Parameterized.class)
public class MainActivityTest {

    public String extraText;
    public String expectedText;

    public MainActivityTest(String extraText, String expectedText) {
        this.extraText = extraText;
        this.expectedText = expectedText;
    }

    @Rule
    public final ActivityTestRule<MainActivity> rule
            = new ActivityTestRule<MainActivity>(MainActivity.class) {
        @Override
        protected Intent getActivityIntent() {
            return new Intent(getApplicationContext(), MainActivity.class)
                    .putExtra("text", extraText);

        }
    };

    @Parameterized.Parameters
    public static Iterable<Object[]> data() {
        return Arrays.asList(new Object[][] {
                {
                        null,
                        "Hello World!"
                },
                {
                        "string1",
                        "string1"
                },
                {
                        "string2",
                        "string2"
                }
        });
    }


    @Test
    public void shouldShowCorrectText() {
        Espresso.onView(ViewMatchers.withId(R.id.hello))
                .check(ViewAssertions.matches(ViewMatchers.withText(expectedText)));
    }
}
```

This will work and it will cover all of our cases, but I don't love this approach. The `Parameterized` JUnit4 Runner does not delegate to the test runner that you configure via your build system. Furthermore, you cannot name your test methods appropriately. The above example has the following test names: `shouldShowCorrectText[0]`, `shouldShowCorrectText[1]`, and `shouldShowCorrectText[2]`.

### The Custom RUNTIME Annotation approach

So lets write this test class in a way that uses the `AndroidJUnit4` runner and create sensible test names:

```java
@RunWith(AndroidJUnit4.class)
public class MainActivityTest {

    @Retention(RetentionPolicy.RUNTIME)
    public @interface TestConfig {
        String extraText();
    }

    TestConfig testConfig;

    @Rule
    public final ActivityTestRule<MainActivity> rule
            = new ActivityTestRule<MainActivity>(MainActivity.class) {

        @Override
        public Statement apply(Statement base, Description description) {
            try {
                Method m = MainActivityTest.class.getDeclaredMethod(description.getMethodName());
                testConfig = m.getAnnotation(TestConfig.class);
                return super.apply(base, description);
            } catch (Exception e) {
                throw new RuntimeException(e);
            }
        }

        @Override
        protected Intent getActivityIntent() {
            if (testConfig == null) {
                return super.getActivityIntent();
            }
            return new Intent(getApplicationContext(), MainActivity.class)
                    .putExtra("text", testConfig.extraText());

        }
    };

    @Test
    public void shouldShowHelloWorldByDefault() {
        verifyText("Hello World!");
    }

    @Test
    @TestConfig(extraText = "Goodbye, Cruel World!")
    public void shouldShowCorrectTextFromExtraWhenSet() {
        verifyText(testConfig.extraText());
    }

    @Test
    @TestConfig(extraText = "Hello, Wonderful World!")
    public void shouldShowCorrectTextFromExtraWhenSetToSomethingElse() {
        verifyText(testConfig.extraText());
    }
    
    private void verifyText(String text) {
        Espresso.onView(ViewMatchers.withId(R.id.hello))
                .check(ViewAssertions.matches(ViewMatchers.withText(text)));
    }
}
```

Notice that the `Description` of the test in the `apply(Statement, Description)` method comes with the name of the current test method. Therefore, you can get a reference to the `Method` object and access its annotations.

## Conclusion

Because the `JUnit4` test lifecycle starts _AFTER_ the `Activity` under test is resumed, it can be difficult to configure the behavior of the activity prior to the point you can interact with it in the test. There are a few approaches for dealing with this situation, but using a custom `RUNTIME` annotation can help you configure the test prior to the activity launching with relatively few drawbacks.