Android: Building self-contained, lifecycle aware testable components

In Android applications, many actions are lifecycle driven, and as an application grows in robustness, so does the actions a lifecycle owner (say an Activity
or Fragment
) has to manage in its lifecycle callback methods, making the code harder to maintain and test -if not implemented correctly-.
In this article, you’ll see 2 approaches to solving the same problem (inspired from the android docs), one using a “common” -traditional- approach, the other using a “better” solution that makes use of the android architecture lifecycle classes to build a lifecycle aware component.
Prerequisites
Some knowledge about the android architecture Lifecycle components would be nice in order to follow along and understand the rest of this article.
Problem
You want to display the user’s location in your application only if they are logged in. For both proposed solutions below, you have the following 3 classes:
MainActivity
: The screen where the location should be displayed.MainViewModel
: Contains the logic that asynchronously checks a user’s status. For the sake of this article, it simply returns a value oftrue
wrapped inLiveData
(But in a real-world scenario, this operation may take some time to complete).
LocationListener
: The class that handles getting and keeping track of the device’s location. It’s the main focus of the 2 approaches below. It includes 2 main methods:start()
andstop()
which begin and stop keeping track of the device’s location. For the sake of simplicity, these 2 methods only update the value of a variablestatus
.
Solution 1: The “common” way
A common approach to solving this problem would include an implementation of LocationListener
that looks like this.
LocationListener
's constructor is given a callback, which is an action to be run once the user’s location is available. The start()
and stop()
methods called as follows.
As you can see, a variable enabled
is needed to call the LocationListener
's methods. With more that 1 component to manage and multiple actions to handle in the lifecycle callback methods (onStart()
, onPause()
, etc) , the activity will probably become robust and contain code that is hard to maintain, let alone to test. One way to tackle this is to extract the LocationListener
component’s lifecycle dependent logic into its class, which is what the second solution will try to do.
Solution 2: The “better” way
A better way to approach solving the problem, as hinted above, is to build a component that is:
- Self-contained: All location-related logic is in 1 single place, making it easier to debug if need be, maintain and build upon.
- Lifecycle aware: The component observes a
LifecycleOwner
(like anActivity
orFragment
) and reacts accordingly to its lifecycle state changes, decoupling it from the UI. - Testable: The component can be isolated and unit tested by simply providing it a dummy
Lifecycle
.
With this approach, the MainActivity
looks cleaner.
Nothing crazy is happening in it, it initializes a LocationListener
, observes its view model’s checkUserStatus
method, and only calls locationListener.enable()
if the user is logged in. Let’s see what the LocationListener
class now looks like.
Below are the main things to note about the LocationListener
class.
- The
LocationListener
is constructed by passing in aLifecycle
and aCallback
(an action to be run once the user’s location is available). - An instance of
LocationListener
starts observing itslifecycle
once it’s created, and stops observing it once it -its lifecycle owner- is destroyed. LocationListener
only updates its status ifenabled
is set to true.enabled
is set to true after the ViewModel’scheckUserStatus()
method returns a result, and due to its asynchronous nature there’s no way to tell whether this occurs before or after the activity’sonStart()
method, which is why theenable()
method adds the following conditionlifecycle.currentState.isAtLeast(STARTED)
.
With LocationListener
now being a self-contained lifecycle aware component, it becomes easy to isolate it and unit test it. Since it requires a Lifecycle
instance, you can mock a LifecycleOwner
and use it for your tests.
Now, you can use the above TestLifecycleOwner
's lifecycle to initialize a LocationListener
in your test.
So what should you be testing?
- That
LocationListener
begins observing its lifecycle when it’s initialized, and stops observing it when its owner is destroyed. - That
LocationListener
connects, does not connect and disconnects appropriately in different scenarios depending on the order of the lifecycle callbacks and the value ofenabled
.
You can check out all these scenarios in LocationListenerShould
.
You can find all the code above in the following repo on Github.
Conclusion
As many actions in Android applications are lifecycle driven, using lifecycle aware components that react to lifecycle status changes to perform actions helps produce better-organized, decoupled and light-weight components, which in turn goes a long way to building a more maintainable and testable codebase.