Testing Activity lifecycle callback methods in Android

ryansgot

Ryan

Posted on January 12, 2020

Testing Activity lifecycle callback methods in Android

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:

@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:

@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:

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:

@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

@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:

@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.

💖 💪 🙅 🚩
ryansgot
Ryan

Posted on January 12, 2020

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related