Android Unidirectional Data Flow — Local Unit Testing
MockK, JUnit 5, and AssertJ
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.
Previous:
Sample code:
Strategy — UDF + JUnit 5
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.
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.
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.
Modularize Components
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.
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.
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.
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.
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
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 with
ParameterReslolver
The test class implements an extension with the ExtendWith
annotation, and injects the components of type CoroutineDispatcher
and ViewModel
.
The extension can access lifecycle methods and ParameterResolver
for injecting components.
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.
- In the
beforeEach
test lifecycle method theCoroutineDispatcher
andLiveData
executor are created before each test. - In
afterEach
theCoroutineDispatcher
andLiveData
are reset and cleared after each test. - We’ll look at the usage of
getStore
in the next Component Injection section to reuse components.
CoroutineDispatcher
— Handles launching new Coroutines
within tests
- Replaces main dispatcher with test dispatcher
- Creates mock thread pool for testing
- See Providing an explicit TestCoroutineDispatcher (Kotlin documentation)
ArchTaskExecutor
— Update live data immediately (don’t post value)
- Don’t update live data on the main thread
- See JVM unit tests don’t know anything about the Android main thread. (Jeroen Mols)
Component Injection
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.
- FeedLoadTests.kt requests a
CoroutineDispatcher
andViewModel
object in the class constructor. supportsParameter
checks for the type of object being requested for injection.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
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.
2. Mock Components
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.
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
.
2. Define mock return values.
Within the test, the mock components used are initiated before the view events are fired or assertions made.
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).
- The exact method in the Repository,
getMainFeedList
is defined insidecoEvery
so that MockK can listen for this method to be called, and return the corresponding test data instead of using the production Repository. coEvery
is used forCoroutines
here, otherwise useevery
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
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
.
Constant Values
Production
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
3. Create View Events
We begin testing the ViewModel
by calling the view event FeedLoad
, a Kotlin Sealed class of type ContentViewEventType
.
Test
Production
Within the ViewModel
this will populate related 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
.
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
.
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.
5. Verify Tests
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.
coVerify
is used for code containingCoroutines
, otherwiseverify
would be used.- The verification confirms that the method
getMainFeedList
is called within the tests. 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.
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.
Recap
Using the modular approach with local unit tests, 96% of the lines of code for the Content
ViewModel
are covered.
Takeaways
- Modularity improves testability.
- 80% of work consists of configuration (Coroutines, LiveData, component injection, test cases) and mocking.
- Ensure view state/effects are tested in the correct order they occur in production.
- Ensure emission of all of the view states/effects scenarios are tested.
- Be careful one test does not break the following parameterized tests.
Next Steps
- 🧪 Expand local unit tests to the entire app.
- 🏗️ Integrated (dependent components) tests, i.e. Repository.
- ⚙️Instrumented (e2e) tests, i.e. View, ViewModel, Repository.
- 📱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! 🙏