The Android Testing Approach Part 1: ATDD

Justin Man
Moonpig Tech Blog
Published in
11 min readMar 24, 2021

--

The Moonpig Android Team uses an Acceptance Test Driven Development (ATDD) approach to building features on the Android app. In this piece I will take you through an approach to ATDD on Android.

Why ATDD?

ATDD is important to our team because it helps developers build features ‘right’. By right we mean that the behaviour of the feature accurately fulfils the acceptance criteria agreed with key stakeholders and from a product standpoint. Acceptance testing helps to ensure that the integration of different units to deliver a piece of functionality works as expected. Writing an acceptance test first helps developers clarify their understanding of what needs to be implemented to achieve the functionality outlined in the requirements.

What is Acceptance Test Driven Development (ATDD)?

First a failing acceptance test is written that describes the functionality to be implemented next. For example, a test that a loading state is displayed to the user when a screen is first launched. To pass the acceptance test, we use the unit test/implement/refactor TDD cycle to implement the feature.

The Acceptance Test Driven Development (ATDD) cycle, where the outer acceptance test loop informs the inner unit test loop.
The ATDD inner and outer feedback cycle. Source: Waldemar Mękal

The idea is that the failing acceptance test will act as an outer loop to constrain us to write code that is directly relevant to pass the test for the described functionality. The passing unit tests will act as small steps of feedback that guide us towards the next step of implementing the acceptance test. For instance, running the acceptance test after a relevant passing unit test should fail on the line of code that is the next step of implementation. The acceptance test will pass when the relevant unit tests are all green for that particular ‘slice’ of functionality. A passing acceptance test is the signal that we are finished with designing how the code is structured and can stop writing implementation code to develop the behaviour.

This way we can use the acceptance test as a measure of demonstrable progress while we are implementing the functionality, with unit tests providing a suite of regression tests that maintain code quality. For more on Acceptance Test Driven Development, check out this book.

ATDD on Android

An approach to testing on Android is to use UI tests at the acceptance level written with the Espresso test framework. The UI test would act as an outer loop of feedback to test that the integration of the individual units work correctly to deliver the behaviour outlined in the UI test.

Although it has improved in recent years, the problem with this approach is that Espresso tests are slow and flaky to run. The Android emulator or physical device is involved in building, installing and launching the app to run tests and lengthens test execution time, especially if run sequentially. There may be problems with these steps that aren’t related to the test itself, which diminishes our confidence in using Espresso UI tests as an outer loop for ATDD.

An Acceptance Test Driven Development cycle on Android.
An approach to the Android ATDD cycle. Source: Google IO ‘17

In response to this our team has shifted the definition of an acceptance test from a UI test with Espresso to a JUnit test. For this method to work successfully, we should design our code so that the UI can be as simple as possible. Then when it comes to testing without the UI, the test can yield a high amount of confidence when it passes along with executing more quickly. Let’s move on to an example to see how this works.

Worked example

Let’s say we wanted to rewrite the Product Details screen and implement a loading state. A test we could write would be to verify if the loading state is structured correctly before being rendered to the UI. First we write a failing acceptance test to model out the interface we wish to work with and how the code will be structured. We use the Arrange, Act and Assert technique along with JUnit and the AssertJ assertions library.

We want a view model which will handle presentation concerns by providing the UI with the correct data to display to the user. The view model will most likely use an abstraction layer to separate data concerns, although at the acceptance level we test for the correct behaviour by calling the public interface and leave these details to be driven out by lower level unit tests. Starting with the “Assert” for this test, we assert the actual output matches what is expected and represent this as a view state. The view state in this context represents the data to be rendered in the UI that results from a user interaction. We have found that a good view state represents the UI as closely as possible. This means if a screen component is initially invisible when the screen loads, the visibility is represented in the view state ready to be bound to the UI.

The product details screen may consist of other components such as an image of the product, a description, a price, etc. We can use different types to represent these components, which can be displayed as a list using RecyclerView from the Android Framework. We can model this as a ProductDetailsViewState that contains a list of component view states. Then a RecyclerView provided by the Android Framework can be used at the UI level to display the view state to the user.

We are using the RxJava library which revolves around programming with asynchronous data streams called observables and makes heavy use of the observer pattern. We leverage this technology to set up a stream to emit new view states which the presentation layer can receive to update the view. In the acceptance test, we assert that the view state received is correct by testing the behaviour of the public method loadProduct(). We use test() to attach a test observer to the viewStates RxJava stream to listen for updates. Then for the “Assert” we retrieve a value from the list of received values from the observable. We are referencing the 2nd value because we will emit an initial view state when the screen is launched. One of the reasons for doing this is to decouple state changes from the action of launching the screen. For example, say we implemented the whole screen and allowed the loading state to be the initial view state. Then there is a new requirement to tap a button before loading the product. That would require more refactoring to change the initial view state to accommodate this.

We use concrete implementations of objects and not mocks in acceptance tests because we want to test the integration of the different architectural components in delivering the correct behaviour and view state. Using a mock would not be accurately testing our system since we would have to dictate the behaviour with a canned response which defeats the point of our test. Therefore we use concrete objects as they fall within the scope of the test in verifying the correct system behaviour.

Right now the test doesn’t compile because SimpleProductDetailsViewModel has not been created yet. We create the class containing the properties and method signatures to satisfy the compile errors.

The test expectedly fails when run with the following message:

The test specifically fails on the assert when retrieving the value emitted from the observable stream at the specified index.

The values are emitted as a List<T> from the observable stream. For testing purposes, we have assigned Observable.never() to viewStates which never emits any values to observers. The second value doesn’t exist and cannot be retrieved, resulting in an Invalid index error.

The failure message is exactly what we are expecting and forms our outer loop. To make this test pass we will need to drop down to a lower level (ViewModel) to test-drive out the loadProducts() method. However, before we can do this we first need to test-drive out how the initial view state will be handled. This will direct us to implement stateSubject and separates testing the initial view state and loading a product. This will allow us to test the behaviour in isolation and moves us a step closer to passing the acceptance test.

Emit initial view state acceptance test

We create a new test SimpleProductDetailsViewModelTest to test the initial view state behaviour. Starting with the “Assert”, we check if the first value received by the view state observer is the initial view state.

For the initial view state we can set components of the view state to start off as an empty list and will be replaced with the list of components when the data comes back from the network call to load the product. As with the acceptance test we use the viewStates observable and attach a test observer to synchronously listen to events.

Running the test fails with a message that there were no values emitted from the viewStates observable.

To implement this, we use a BehaviorSubject to emit the initial view state when created. A BehaviorSubject is a special type of observable that can emit events but can also accept values pushed to it. BehaviorSubject “replays” the last emitted event to each new observer as soon as it starts listening for updates.

Caching the view state like this is useful because the most recent view state can be restored in case of a device configuration change. An example is changing screen orientation, where Android will destroy and recreate the instance of the screen, losing the view state in the process.

We specify the initial view state when creating the BehaviorSubject, run the test again and it passes.

A feature of this implementation is that the internal state of the BehaviorSubject is exposed to any client that accesses viewStates such as an Android Activity. The Activity can push values to it and modify its internal state by using onNext() to emit new events.

We can avoid this by using an RxJava method called hide(). This method creates an observable from the subject that encapsulates the internal state and makes it read-only. We also pull out the initial view state into a property to aid readability as a refactoring. The test still passes.

Load product view model test

Now we have implemented the initial view state code, we move on to loading a product. To keep the example simple this will happen in the view model. In this test, we want to assert that the network response state is correctly mapped to the loading view state. The loadProduct method will require a network call to be made so we can create another abstraction level to decouple this from our data layer. This will be our repository and will be added as a dependency to the view model. The Mockito library is used to set up the mock to return an ProductDetailsRequestState.InFlight. Our test does not compile because the InFlight state and repository do not exist yet so this is our next step.

Why are we testing the loading view state in both the acceptance test and the view model test? The view model test allows us to test the view model in isolation by mocking the repository response, whereas the acceptance test purposely uses real implementations to test the integration of the architectural components. The two tests have different testing purposes.

We add the product repository interface, it’s implementation and the ProductDetailsRequestState. This will follow a similar structure to the ProductDetailsComponentViewState in being a sealed class.

We then add the repository dependency to the view model and to the acceptance test.

class SimpleProductDetailsViewModel @Inject constructor(private val productRepository: ProductRepository) : ViewModel() {}

We can now run the test, which fails. The resulting stack trace tells us that Loading is not in the list of view states but only the initial view state is. This is expected behaviour since the loading view state mapping has not been implemented yet.

To implement the test we call productRepository.fetch() and manipulate the response by using withLatestFrom() before stateSubject receives the update. The withLatestFrom() method combines the emission from productRepository.fetch() with the latest emission from the stateSubject in a BiFunction. A new observable is created that emits the combined value.

In the BiFunction we reduce the request state with the previous view state to create a new view state with the loading component. The when construct will be expanded to handle more ProductDetailsRequestState types such as Success and Error states. This example just focuses on the loading state.

Repository test

Running the acceptance test again after this change directs us to the TODO() placed in the ProductRespository.fetch().

We drop down to the repository level to test it in isolation.

Running the test fails on the same line as the acceptance test with a kotlin.NotImplementedError. This is good feedback that points us to the next step of implementation. We implement Observable.just() to emit ProductDetailsRequestState.InFlight. Observable.just() emits the specified values and completes, which is sufficient to pass the acceptance test, although the implementation will change to accommodate different states such as success and error.

The failing Repository test now passes. We run the acceptance test again to see if we get a new error message.

A passing loading state acceptance test.
A passing acceptance test.

We have a passing acceptance test. As a refactoring, we can extract the code that maps a ProductDetailsRequestState to a ProductDetailsViewState to a method to aid readability.

We now have confidence that the Loading view state is correct according to the expected behaviour. For the UI, we display the view state as a component in a RecyclerView using data binding. The loading state of the Product Details screen in the Android app currently looks like this:

The Product Details Screen Loading state on the Moonpig Android App.
Product Details Screen Loading state on Android

Closing

In this piece I have introduced the concept of ATDD and how it can be practiced in Android by implementing a loading state. Although there is more that can be done to improve our testing practices, the Android team has found that a good view state should represent as closely as possible what will be displayed on the UI. Shifting the definition of an acceptance test from a UI test using Espresso to JUnit has helped to quicken test execution and to inspire more confidence in them by being less flakey. An outer loop acceptance test to assert the view state supported by an inner loop of unit tests shortens feedback cycles to help us develop features right faster.

--

--