Testing Activity lifecycle callback methods in Android
Ryan
Posted on January 12, 2020
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:
- If the
Intent
used to start it DOES NOT have the "text" string extra, then show "Hello World!" - 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:
- Create a new project via AndroidStudio
- Select the Empty Activity option
- 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 theMainActivity
- 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:
- The default case
- The non-default case
- 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:
- 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. - 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:
- (not recommended) Use the JUnit4
Parameterized
test runner and constructor arguments to parameterize your test. See the drawbacks of this approach below. - Write a custom
RUNTIME
retention annotation that you then read when theActivityTestRule
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.
Posted on January 12, 2020
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.