Photo by Zach Reiner on Unsplash

Android Model-View-Intent with Unit Tests

Local unit testing using JUnit 5 and MockK

Adam Hurwitz
ProAndroidDev
Published in
8 min readOct 5, 2020

--

You’ve spent too much time testing if you have had to reconfigure a feature’s code to make it testable or have debugged the ordering of actions/events and the resulting test output. Using the Model-View-Intent pattern (MVI) provides the ability to test built-in to the pattern following the best practice of Test Driven Development (TDD).

If you’re not familiar with MVI, the pattern structures how information is shared and how views are created and updated. MVI defines a clear contract between the view and business logic with an interface that is a one-way, immutable, unidirectional data flow (UDF) pattern. The structure of MVI grants the ability to implement with any reactive framework, such as Kotlin Coroutines, RxJava, and LiveData.

The last post, Android Model-View-Intent with Kotlin Flow, provides a comprehensive overview of the MVI pattern used in the Coinverse cryptocurrency news app. It compares MVI to a forest transferring information via its’ highly structured fungi network. If MVI 🌲 ️is like an intelligently designed forest’s ecosystem, then the unit tests 🐝 are the insects that feed off the fungi network and know whether it is poisonous, or in our case, contains errors.

Coinverse code

Also available on the Play Store.

Overview

Advantages

Implement

Summary

Advantages

Photo by Wolfgang Hasselmann on Unsplash

🙋🏻 Why Local Unit Tests?

Local unit tests with JUnit 5 can be run quickly prior to writing a feature and regularly to ensure code quality and mitigate regressions of features’ business logic. These tests do not require dependencies on the Android system. This covers the majority of code for most apps making it a great first step for testing. These local unit tests can be expanded into integration tests for more thorough testing of the data layer, such as network requests and local databases, and for instrumented tests including the UI components.

🥼 Built-in testing

Once modularized properly, the business logic model, in this case Android’s ViewModel component, and the view interface contract used in production are also used for local unit tests without modification to test the given intents that the model uses to generate the expected view states.

MVI defines the intents driven by the Android system and user actions that the model processes to create the view states. Because the ViewModel business logic component observes the intent actions and creates the final persisted data of each view state, a local unit test makes use of the existing ViewModel and view interface contract.

🏎️ More efficient code

Creating intents and view states with MVI reduced the test code by more than 50% compared to the previous implementation using the UDF and emitting many streams of data simultaneously.

100% of FeedViewModel.kt is covered in local unit tests in Coinverse with MVI comprising 306 lines of code in the FeedViewTest.kt class, compared to 753 lines of code across four test classes covering mostly the same code prior to the MVI refactor. In the pre-MVI pattern, UDF was implemented to separate the view, business logic, and data layer. However, many sources of data were emitted from the ViewModel to the View making the tests harder to read, write, and created issues with the order of data being emitted. By knowing exactly what view state should be emitted based on each intent with MVI, it makes the tests more reliable, quicker to build, and more concise.

FeedViewModel.kt Test Coverage

Implement

Photo by Fredrik Ivansson on Unsplash

📚 Libraries — Step 1 of 5

  • JUnit 5 with parameterized tests — Reuse the tests with a stream of many test cases.
  • MockK — Creates components involving the Android system or classes handling data requests like a repository or database, returning data that would be expected to be returned in the loading, success, and error states.
  • Kotlin coroutines — Initiate intent actions and observe view state changes with StateFlow.
  • AssertJ — Evaluates the actual vs. expected states.

build.gradle (Module)

build.gradle (App)

Releases/Versions

💼 ViewModel — Step 2 of 5

A. Define how components are injected

In order to use the same business logic in production and testing, all components that rely on the Android system or data requests are injected into the ViewModel so that test instances can be used in the local unit tests.

See: Android Model-View-Intent with Kotlin Flow > Model — ViewModel > ‘CoroutineScope and CoroutineDispatcher’ and ‘Dagger 2 Dependency Injection for ViewModels with AssistedInject’

FeedViewModel.kt

  1. CoroutineScope — With the custom extension function,getViewModelScope, for production no scope is injected and the Android’s viewModelScope is used by default. In testing the TestCoroutineScope is explicitly injected.
  2. FeedRepository — Data requests made to the network and local database will be mocked requiring a test instance of the repository to be injected.

B. Create the test components to inject

FeedViewTestExtension.kt

  1. TestCoroutineDispatcher and TestCoroutineScope are created in a JUnit 5 test extension. The dispatcher is necessary to ensure coroutines run on a test thread and the scope defines the lifecycle that the coroutines runs within.
  2. The ArchTaskExecutor is required for LiveData to return the PagedList.

See: Android Unidirectional Data Flow — Local Unit Testing > 1a. Configuration — Extensions

FeedViewTest.kt

  1. The mock repository is initialized and passed into the ViewModel along with the TestCoroutineScope created in FeedViewTestExtension.kt above.
  2. The repository method getMainFeedNetwork's response is mocked to return test data. In the full sample code this emits a Flow of PagedList data. With the Paging 3 library this can be simplified removing the dependency to mock LiveData asPagedList and return a PagingData response with Kotlin coroutines. See StackOverflow for Paging 2: Convert List Into PagedList With Mock DataSource.Factory
  3. The FeedViewTestCaseStream is a Stream.of(...) FeedViewTestCase defined in a data class and represents different scenarios such as loading, success, and error states. The stream is passed into the test making the test reusable for all of the states defined.

🌉 Interface — Step 3 of 5

Bind the view interface contract to the ViewModel

  1. The majority of logic tested is defined by implementing the view interface and binding it to the ViewModel. The interface represents the intent/actions sent from the view to the ViewModel, and the view states rendered by the ViewModel back to the view. See: Android Model-View-Intent with Kotlin Flow > Intent — View Interface
  2. The view’s StateFlow intents are defined in FeedViewIntent for organizational purposes. See: Android Model-View-Intent with Kotlin Flow > View — Activity/Fragment
  3. The test view interface observes emissions from the intent Flows and the test view interface renders a view state sealed class as it would in production.

FeedViewTest.kt

🖼️ Intents and View States — Step 4 of 5

  1. Call the intents/actions that initiate the business logic to generate a view state, in this case, loadFromNetwork intent is emitting a true value which initiates the FeedViewModel to create the Feed state. Then, observe that the actual view state of type Feed matches the expected state.
  2. isEqualToComparingFieldByField is used to compare the properties of the actual and observed Feed view state emitted. Feed is a class rather than a data class so that each instance of Feed has a unique hash code and can be properly emitted by the model.

FeedViewTest.kt

🔬 Verify and Confirm Method Calls — Step 5 of 5

  1. The MockK verification coVerify confirms that the method getMainFeedNetwork is called within the test.
  2. confirmVerified checks that all method calls in the test are verified/expected to be called.

See: Android Unidirectional Data Flow — Local Unit Testing > 5. Verify Tests

FeedViewTest.kt

Summary

Photo by Michael D on Unsplash

⚠️ UDF Without MVI

From Step 3 of 5 — Interface section above when a view state is rendered, we can see how much more complicated this is compared to UDF emitting many data types simultaneously. In this case, loading the feed data is done by observing two emissions of data, the view state, and a one-time view effect to update ads. This also creates ugly code nesting, and is harder to read vs. creating one view state with all of the view’s final persisted data contained.

See: Android Unidirectional Data Flow — Local Unit Testing > 4. Make Assertions

FeedLoadContentTests.kt

🌊 Alternatives to Kotlin Flow

The Kotlin Flow implementation is powerful and concise in handling threading, lifecycle scope, and allowing for mutable and immutable flows.

MVI also works well for testing with other reactive frameworks as well.

  • RxJava’s Subject or Relay are also solid options for MVI in the data, view state, and view layers.
  • LiveData can be used to achieve the tests above for the view state layer using MutableLiveData and LiveData. It is not recommended for the data request repository layer as LiveData is tied to a view’s lifecycle scope and some data requests may need to be decoupled from the UI.
  • Callbacks could work in theory, but run the risk of becoming overly complicated with both the view and the ViewModel implementing listeners to send actions from the view to the ViewModel and rendering view states from the ViewModel to the view.

Whichever tool is chosen, the MVI pattern separates the view, business logic, and data requests as well as modularizes the various features into view states make initiating the user and system actions and testing the generated view states from the model accessible.

🌱 Next Steps

Now that local unit tests are created for the business logic using the MVI pattern, there is a foundation to expand upon for integration and instrumented tests.

  • Integration — Components the app is dependent upon such as network requests and local databases. Retrofit requests can be tested using MockWebServer and a Room database can be tested by creating a database with a test app context using Robolectric.
  • Instrumented — End-to-end behavior of a real device or emulator. UI tests can be incorporated with Espresso.

Explore Coinverse on the Google Play Store or the open-source code. Please clap if you like the above. The longer the clap button is pressed the more claps. 👏🏻

📝 Notes

I’m Adam Hurwitz, writer of code and more. Follow me on Twitter.

--

--