Android Unidirectional Data Flow — Local Unit Testing

MockK, JUnit 5, and AssertJ

Adam Hurwitz
ProAndroidDev

--

When I started building the Unidirectional Data Flow (UDF) with LiveData pattern, I did not have testing experience. Working on a small/fast moving startup team, Close5, owned by eBay, and building Coinverse from scratch, I had not been required, nor did I allocate the time to develop tests. One of the touted advantages of UDF is optimizing the architecture for testing. This made for a great opportunity to develop testing fundamentals explored in the Coinverse code below.

JUnit 5 Testing: Android Unidirectional Data Flow with LiveData (DroidCon San Francisco 2019). See slides.

Previous:

Sample code:

Strategy — UDF + JUnit 5

Photo by Nathan Anderson on Unsplash

Local unit tests are a great beach head to start with if you’re new to writing tests. With the UDF flow the test cares are already organized. Local unit tests provide a good base to build from, as you can then expand to integration and instrumented tests.

  • Unit — Test independent code modules containing business logic, also used for integration and instrumented tests. Mocking components the module is dependent on is required.
  • Integration — Test components app is dependent upon, i.e. Repository.
  • Instrumented — Test end-to-end behavior of a real device or emulator, including UI (Espresso) tests.
UDF + JUnit 5 strategy

To create local unit tests the UI (view), business logic(ViewModel), and data requests (Repository) will be modularized, so that they are independent of each other. Then, the ViewModel's business logic can be tested using JUnit 5.

Tools 🧰

JUnit 5

  • ♻️ Reusable tests with dependency injection and extensions (rules in JUnit 4)
  • 🏷️ Updated lifecycle naming
  • 🏎️ Faster core library updates

MockK

  • 🥇 Kotlin first
  • 🌄 Native coroutines support
  • 🛰 ️Great for mocking Repository object, top-level functions/values, and static Java methods

AssertJ

There are many great assertion libraries so there is more flexibility of options here. Because UDF organizes the final persisted data (view) into a state object and one time navigation events into an effects objects, AssertJ tests the view state/effect values.

AssertJ tests the view state and effects data

Now that the tools are laid out we’ll use them by creating view events with JUnit 5, mocking the data request responses from the Repository with MockK, and testing the correct data is returned with AssertJ.

UDF + JUnit 5 strategy summary

Modularize Components

Photo by SpaceX on Unsplash

We’ll create components independent of each other so that the code not being tested is able to be mocked. For example, we want to avoid situations within the ViewModel where a Repository instance is created, or inside of a Repository where a database instance is created. If these dependancies exist it complicates running the code in the local unit test environment because the environment does not have the same level of access to the Android system.

Dependency Injection

To keep things simple I implemented Singletons to initiate commonly used components on the application level. As Coinverse scales a dependency injection/service locator library may be implemented.

Modularization has been a hotly contested topic in the Twitter + ReddIt(verses). At the 2019 Android Developer Summit Google announced increased focus on Dagger2 dependency injection, including better Kotlin support. There are debates regarding dependency injection vs. service locators.

See Gdoc notes — Dependency Injection / Service Locators.

An Opinionated Guide to Dependency Injection on Android (Android Dev Summit ‘19)

Singletons

To create Singleton module instances on the application level we can override the Application class and create an instance of the component(s) to be used throughout the lifecycle of the app.

Create an instance of the database to access throughout the app lifecycle
Create database Singleton.

The Repository is created as a Singleton object instance. We can use the global database instance created in the Application class to access the database independently inside of the Repository.

Create Repository Singleton and use database Singleton instance.

Because the Repository is also a Singleton we can cleanly call it from within the ViewModel. This allows us to create the ViewModel moving forward in tests without requiring on dependencies from other components like the Repository.

Use Repository Singleton instance inside ViewModel.

Test Implementation Steps

Now that the ViewModel does not have dependent components created inside of it, we can setup the test configuration.

1a. Configuration — Extensions, JUnit 5

  • Coroutines + LiveData
  • Component injection

1b. Configuration — Parameterized tests, JUnit 5

2. Mock components, MockK

3. Create view events, JUnit 5

4. Make assertions, AssertJ

5. Verify tests, MockK

Creating the configuration and mock components, steps 1–2, is 80% of the work. Initiating the view events, testing the view state/effects, and verifying the methods is easier, steps 3–5.

1a. Configuration — Extensions

Photo by Ivan Bandura on Unsplash

Extensions are a new feature of JUnit 5 allowing for modularization and re-using components across tests to improve speed and efficiency. In JUnit 4 rules allowed similar functionality. The JUnit team indicates extensions improve upon rules by creating the ability to provide more frequent improvements because extensions can be updated easier.

I took advantage of extensions to re-use the logic for the following.

  • Test lifecycle methods
  • Injecting common components withParameterReslolver

The test class implements an extension with the ExtendWith annotation, and injects the components of type CoroutineDispatcher and ViewModel.

The test extends Extension and injects components as class values.

The extension can access lifecycle methods and ParameterResolver for injecting components.

The Extension implements test lifecycle and ParameterResolver.

Coroutines + LiveData

In order for Coroutines and LiveData to work outside of the Android framework in the JUnit 5 local unit tests, we need to set a test CoroutineDispatcher and LiveData executor.

Setting and Resetting/Clearing CoroutineDispatcher and LiveData
  1. In thebeforeEach test lifecycle method the CoroutineDispatcher and LiveData executor are created before each test.
  2. In afterEach the CoroutineDispatcher and LiveData are reset and cleared after each test.
  3. We’ll look at the usage of getStore in the next Component Injection section to reuse components.

CoroutineDispatcher — Handles launching new Coroutines within tests

ArchTaskExecutor — Update live data immediately (don’t post value)

Component Injection

The U.S. Army

Reusing components in JUnit 5 cleans up code and improves efficiency. ParameterResolver is implemented in ContentTestExtension.kt (Step 1a), then supportsParameter and resolveParameter are implemented to check for the type of component being requested for injection in FeedLoadTests.kt. Lastly, appropriate component is returned to the test.

ParameterResolver methods implemented to inject components and getStore used to store existing components
  1. FeedLoadTests.kt requests a CoroutineDispatcher and ViewModel object in the class constructor.
  2. supportsParameter checks for the type of object being requested for injection.
  3. resolveParameter returns the corresponding object based on the requested object type.

See Inject Parameters into JUnit Jupiter Unit Tests (Baeldung)

Store Objects

To reuse components for injection that have already been created JUnit 5’s getStore is used to save and retrieve components.

4. getTestCoroutineDispatcher and getViewModel retrieve the object based on the key/value stored using get. If the value is null in resolveParameter a new instance is created, otherwise the existing instance is returned from storage.

5. saveAndReturnTestCoroutineDispatcher saves the object based on a key/value using put.

See 5.8.1. Before and After Test Execution Callbacks (JUnit 5 documentation)

1b. Configuration — Parameterized Tests

FeedLoad view event error fired in ‘onCreate’ of Android lifecycle

Parameterized tests in JUnit 5 make testing error responses, empty/null data, and other edge cases easier by creating a stream of test objects modeling different scenarios that are run with the same test logic. This is important to avoid unintended consequences in production.

A Stream of test cases are created and passed into the test.

2. Mock Components

Photo by Darren Bockman on Unsplash

As the tests are focused on the ViewModel's view events, state, and effects, the components and data responses from the network and local database need to be mocked, as they will not be tested directly.

Objects

ContentRepository.kt is a Singleton object, responsible for returning data responses from the network and database requests to the ViewModel business logic, that updates the view state/effects.

Simplified outline of the ContentRepository.kt singleton in production.

1. Mock object creation.

Since the Repository is used for every test it will be created once in the ContentTestExtension.kt with MockK’s mockkObject method, before all of the tests run. Then, the Repository will be returned to its’ original state after all of the tests have completed with unmockkAll.

Create and re-assign mock Repository

2. Define mock return values.

Within the test, the mock components used are initiated before the view events are fired or assertions made.

Define mock return values first.

mockComponents will return the mocked Repository data in the same format it is returned in production in order to test the ViewModel's business logic (view/effects).

  1. The exact method in the Repository, getMainFeedListis defined inside coEvery so that MockK can listen for this method to be called, and return the corresponding test data instead of using the production Repository.
  2. coEvery is used for Coroutines here, otherwise use every

See Convert List Into PagedList With Mock DataSource.Factory (StackOverflow)

Static Methods

Coinverse’s written code is 100% in Kotlin, however libraries included contain Java static methods that are called within libraries implemented in the Repository and require mocking.

Production

The FirebaseRemoteConfig library is used inside the Repository.

Test

To mock FirebaseRemoteConfig’s static method, mockkStatic is used. Because we’re not testing FirebaseRemoteConfig.class, the class is returned empty using relaxed = true.

FirebaseRemoteConfig mocked as empty.

Constant Values

Production

Top-level constant value

To mock static values mockStatic is also used, but the package name that Java converts the Kotlin code into is required for Mockk to properly read the value called in the test and return a test value.

Test

Top-level constant value mocked and returning test value

3. Create View Events

We begin testing the ViewModel by calling the view event FeedLoad, a Kotlin Sealed class of type ContentViewEventType.

Test

FeedLoad event called.

Production

View events Sealed class

Within the ViewModel this will populate related view state and effects.

ViewModel creates/updates the view state and effects

4. Make Assertions

After the FeedLoad event is called in the ViewModel we test the view’s persisted data (view state) containing the main feed content, and the corresponding view effects initiated.

In AssertJ the actual value resulting from the test is passed into assertThat, and the expected value is passed into isEqualTo.

View state and effects’ values tested

1. To run Coroutines synchronously testDispatcher.runBlockingTest is used.

See kotlinx.coroutinesrunBlockingTest (Documentation)

2. Both the view state value pagedList and view effect UpdateAds are being observed and tested with the utility function getOrAwaitValue.

See Unit-testing LiveData and other common observability problems (Jose Alcérreca)

3. I’ve also created utility extension functions for observing LiveData from the view state and effects with feedViewState, viewEffects, and observe.

Utility test functions for observing LiveData

Error

When a JUnit 5 parameterized tests fails with AssertJ, Android Studio produces the test conditions for the failure and the expected/actual result in order to debug the test.

Test case conditions
Test error with expected/actual results

5. Verify Tests

Photo by SpaceX on Unsplash

The last step in testing is to use Mockk to verify that all of the expected methods are called and also that all of the methods called during the tests are expected.

  1. coVerify is used for code containing Coroutines, otherwise verify would be used.
  2. The verification confirms that the method getMainFeedList is called within the tests.
  3. confirmVerified checks if all method calls fired in the tests are verified/expected to be made.

Errors

Two common errors with verification are methods called in the tests that aren’t verified, and methods that are expected to be verified, but never called in the tests.

Method called in test, but not verified

This could either occur when you forget to include a method in the verification, or if an unexpected method is being called in the test that should not be.

Not verified calls

Method not called in test, but should be verified.

This indicates the test code is likely not running as expected as the method should be accessed, but is not.

Method was not called

Recap

Using the modular approach with local unit tests, 96% of the lines of code for the ContentViewModel are covered.

Takeaways

  1. Modularity improves testability.
  2. 80% of work consists of configuration (Coroutines, LiveData, component injection, test cases) and mocking.
  3. Ensure view state/effects are tested in the correct order they occur in production.
  4. Ensure emission of all of the view states/effects scenarios are tested.
  5. Be careful one test does not break the following parameterized tests.

Next Steps

  1. 🧪 Expand local unit tests to the entire app.
  2. 🏗️ Integrated (dependent components) tests, i.e. Repository.
  3. ⚙️Instrumented (e2e) tests, i.e. View, ViewModel, Repository.
  4. 📱Espresso UI tests, i.e. View.

Resources

  • Check out Coinverse on the Play Store:
  • Explore and run the Coinverse code:

A big thanks to DroidCon for providing the opportunity to share the above at the 2019 San Francisco conference! 🙏

I’m Adam Hurwitz, an Android consultant + developer. Follow me on Medium for more on Android and connect with me on LinkedIn.

--

--