Not so long ago the testing team at Google introduced the Android X Test libraries, amongst which we can find
ActivityScenario, a new way to test
Activities and code related to them. These APIs made it to stable version a few weeks back and are totally safe for use on daily basis.
The main idea of this tool is to drive the state of the
Activity so that we can simulate the app passing through the
onResume(), and the other lifecycle methods; which helps developers test the flow between these states.
A very peculiar aspect of
ActivityScenario and other APIs from the same suite is the possibility of running tests on JVM (using Robolectric) or on a device. The the only caveat of this approach is the need to move all the needed test files in the
androidTest folder, based on where we want them to run.
Running tests on the JVM using Robolectric will improve massively the execution time. When we instead run tests on a device or emulator, the
Activity is launched on the device, making the test more coherent with the real life scenarios.
Note: for the purpose of this post, it does not really matter where the test is run. We will not show how to include the libraries for running the tests in a specific environment, which can instead be found here.
The main difference between
ActivityTestRule is the control we have over the
Lifecycle of the target
ActivityScenario can drive each state of the component with ease, and even decide to restart it and test what happens in that situation.
Introducing the scenario
Imagine we have a
Lifecycle-aware component which is part of splash screen. After a timeout, this component would redirect the user to the login screen if they are not logged in, or to the home screen of the app instead.
We use the
Lifecycle to stop all the actions we might take if the user decides to exit the app before the countdown is completed. Since we use this API, we need a
LifecycleOwner in which we can add the component as observer, and an
AppCompatActivity is the easiest way to achieve this.
The component itself is pretty small, as we can appreciate from its source code:
In the constructor of the
SplashScreenManager, we pass the target
Activity boundActivity, the
IntentFactory and a duration time in milliseconds. We then get the
Lifecycle from the
boundActivity and add our component as observer.
When we reach the
onStart() state of the
Activity, we use a coroutine to spawn an asynchronous operation that suspends for the given time and then forwards the user to the next step, closing the
boundActivity right after that step.
In case we reach the
onStop before the countdown is over, we cancel it and the user is not forwarded to the next step.
IntentFactory is not really important for the test we want to write, and the only thing that it should matter is that it works in these way:
- it loads the token, saved in the
- if such token is not available, it will forward the users to the login screen;
- if it is instead available, it will open the home screen of the app.
Writing a test case
Now, we know how we expect this component to work, and we want to write tests around it, so we can make sure it works as expected.
As an example, we will write a test that will check the non-logged in scenario, so we expect the login screen to be invoked:
The first thing we do is invoking
Intents.init() from the Espresso Intent library. In this way, we can capture any
Intent and check their content later on.
We then create the
ActivityScenario of a test
Activity, created to be able to drive the
Lifecycle as it was real.
At this point, such
Activity should be in the
onResume state, and we invoke a lambda on its main thread, creating the objects we need, included the
SplashScreenManager, using the
Activity itself, which is passed as the
it parameter to the lambda.
Note: we made sure to delete the token, so we would be in the situation in which the login was required.
The next step is to move the activity to the
STARTED state, so that our coroutine would run and launch the new screen. We set the waiting time for the test to 0, to avoid any delay or flakiness due to timing.
Once our asynchronous code was run, we use yet another function from the Espresso Intent library,
intended() that is a way to understand what it the content of the
Intent, more or less like
onView() works with
View. In this case, we check if the
Intent was meant to launch the login screen.
Last but not least, we need to invoke
Intents.release() to stop recording the
close() on our
ActivityScenario, so that we free all the resources and we bring our tests to their initial state. Without this steps, our test cases would be in an unexpected (and potentially not replicable) state, which goes against the idea of having stable test suites.
Note: the complete test suite for the component can be found here.
Why should we use it?
Writing small and fast unit tests is definitely the easiest and most reliable way of verifying the code we write, but, as we built a feature, or a screen, we need to verify how the small pieces fit together and within the Android system. Most of the times, we can use integration tests to make sure the different units fit well together, but at some point we find ourselves in a situation where we need to make sure that all the code we wrote works as expected with the platform. In this scenario, being able to drive the
Lifecycle of each component and verifying step by step, is definitely one of the best and least painful way to write a test suite, and this is where
ActivityScenario becomes an essential tool.