Easier Testing with MVVM, MVI, MVP and Kotlin Multiplatform
Ahmed Adel Ismail
Engineering Manager @ Yassir | x-SadaPay | x-Swvl | x-Talabat | x-TryCarriage | x-Vodafone | More than a decade of experience in Android development and teams leadership
Before we start, this article requires basic knowledge about the following topics :
Disclaimer: This Article does not teach you how to Unit Test, but it shows you how to make it easier to test and with accurate test coverages, plus moving forward to embrace Kotlin Multiplatform Mobile
Assuming that we are following an Onion / Clean Architecture in our project, we always have a good test coverage in our domain module, since it has no external dependencies, so it is easy to test ... but we still face hard time unit testing other modules like presentation module (view models, presenters, etc..) and Data sources modules
And this difficulty comes from the nature of such modules, is that in production we need to have external dependencies, and still we are implementing something like presentation logic inside such modules
In some projects, when it comes to test reports, we see a presentation module with test coverage 40% but we know that it is nearly tested to the max, and we know that the rest can be achieved by UI tests for that module
Thinking Clean Architecture vs Ports And Adapters
Some see that the difference between those two architecture patterns is how we divide modules (will talk about Ports and Adapters in more details as we go), but there is a major difference between them as well ... it is How We Think ... not only how we structure our modules
There are many things common between the two, and the main rule of thumb here is that the Domain / Core module does not have any external dependencies
In Clean Architecture, we have our domain module containing our Business rules (Use-Cases + Entities), and Repositories ... and even Uncle Bob defined how things should go in detail in the domain through Defining Inter-actors, Boundaries, Request/Response models, etc...
on the other hand, Ports and Adapters still have the domain / core module that depends on nothing external, but with a very important difference ... it is not limited to business rules
In Ports and Adapters, the Core module is the place were we put the important code in it, which is defined as "The Code That Matters", the code that belongs to our system, and if this code requires any interaction with the external dependencies, we create an interface Port, and in other modules, we create an Adapter that implements this interface and communicates with that external dependencies in that module, and in runtime we inject the adapters to our core module
a very good example about that is the repositories in the domain module for clean architecture, where our use case wants to deal with external source like server, so it talks to a repository interface (Port), and the implementation of this repository exists in the data sources module (adapter), and then we inject the repository implementer to the use-case at runtime from the application module
But in Ports and Adapters as we said, it is not limited to business logic only in the domain, it can contain any logic that matters in the domain / core, and this can include Presentation logic as well
Presentation Logic is part of "The Code That Matters"
And here we are talking about the presentation logic, and not the UI logic, presentation logic is currently written in ViewModels or Presenters, which makes it put in the module that we cannot measure confidently in Unit tests
But following the Idea of Ports and Adapters ... think about it like this
I have some code that matters and I want to test, this code is mainly logic, and it has to communicate with the external dependency which is the Android or iOS, so All I need is just declare the Ports / interfaces that will be implemented by the ViewModel or Presenter on Any platform, and with pure Kotlin we can write this logic and test it 100% coverage in our core module
Adding Presentation logic to Core module and supporting Kotlin Multi-platform
To go through this in steps, we assume that we have an already running project with what ever architecture it does not matter, and we need to either include our presentation code in the testable part (core) for either test coverage or for supporting Kotlin Multiplatform or both
suppose our feature is a screen that shows a user location on a map, and show a list of near-by points of interests in a form of list, and there is a button to re-detect the user location (like a retry button)
MVVM example
one of the powerful Kotlin features in declaring fields in interfaces, and without this language feature, we wont have reached this point right now
so in our core module (which can be Kotlin Native), let's declare the Port that will be implemented by the ViewModel :
interface PresentationPortMvvm {
var progressing: Boolean?
var locationOnMap: LocationInfo?
var nearbyPointsOfInterest: List<PointOfInterest>?
var errors: Throwable?
}
the next powerful Kotlin feature that we will require in our design here is extension functions on interfaces, as this will show those extension functions as if they are part of the interface implementer class ... and that's exactly what we want
now lets declare our presentation logic functions in our core module as well :
/**
* move the location on map to the user location,
* and show the near by points of interests
* for that location
*/
suspend fun PresentationPortMvvm.onDetectUserLocation(
locationsRepository: LocationsRepository = CoreDependencies.locationsRepository,
pointsOfInterestsRepository: PointsOfInterestsRepository = CoreDependencies.pointsOfInterestsRepository
) {
progressing = true
runCatching {
requestLocationAndPointsOfInterests(
locationsRepository,
pointsOfInterestsRepository
)
}.onFailure {
errors = it
}
progressing = false
}
private suspend fun PresentationPortMvvm.requestLocationAndPointsOfInterests(
locationsRepository: LocationsRepository,
pointsOfInterestsRepository: PointsOfInterestsRepository
) {
val location = locationsRepository.detectUserLocation()
val pointsOfInterest = pointsOfInterestsRepository.requestPointsOfInterests(location)
locationOnMap = location
nearbyPointsOfInterest = pointsOfInterest
}
and now, our presentation logic function is not depending on any external code, so it can be unit tested the same way we unit test our use cases, and since it is extension function on the port interface, it can access all it's variables and update it as required ... and also when this is used from the UI module, it will be accessible on the implementing ViewModel as well, since it is extension function on the interface implemented by the ViewModel (as mentioned before)
now moving to the Android module that will implement this feature, we will declare a ViewModel that implements this port :
class PresentationAdapterMvvm(
val progressStream: MutableStateFlow<Boolean?> = MutableStateFlow(null),
val userLocationStream: MutableStateFlow<LocationInfo?> = MutableStateFlow(null),
val nearbyPointsOfInterestStream: MutableStateFlow<List<PointOfInterest>> = MutableStateFlow(listOf()),
val errorsStream: MutableSharedFlow<Throwable> = MutableSharedFlow(onBufferOverflow = DROP_OLDEST),
private val dispatcher: CoroutineDispatcher = Dispatchers.IO
) : ViewModel(), PresentationPortMvvm {
override var progressing by emitInto(progressStream)
override var locationOnMap by emitInto(userLocationStream)
override var nearbyPointsOfInterest by emitInto(nearbyPointsOfInterestStream)
override var errors by emitInto(errorsStream)
init {
detectUserLocation()
}
fun detectUserLocation() {
viewModelScope.launch(dispatcher) {
onDetectUserLocation()
}
}
}
If you notices the ViewModel is almost empty of any logic, it is just observing on the changes happening to the values declared in our port, and putting them into a stream (flow here)
[Implementation Detail] the emitInto() method in the UI module is a delegate that observes on the variable it is assigned to, and once it's value change, it emits that new value to the flow passed to it's parameter ... this is an implementation detail that can be done in many different ways, but for sake of curiosity, this is it's code (not mandatory to understand for now)
fun <T> emitInto(flow: MutableStateFlow<T>) = Delegates.observable<T?>(null) { _, _, value ->
flow.tryEmit(value ?: return@observable)
}
any ways Implementation details are not part of our topic, but just to clarify what's going on, now the ViewModel is our Adapter, and it implements the Port, we then draw the results on UI through an activity or fragment like this :
class MvvmActivity : AppCompatActivity() {
...
private val viewModel by lazy { ViewModelProvider(this)[PresentationAdapterMvvm::class.java] }
override fun onCreate(savedInstanceState: Bundle?) {
...
lifecycleScope.launch {
viewModel.progressStream.asStateFlow().collect { renderProgressBar(it) }
viewModel.nearbyPointsOfInterestStream.asStateFlow().collect { renderRecyclerView(it) }
viewModel.userLocationStream.asStateFlow().collect { renderLocationOnMap(it) }
viewModel.errorsStream.asSharedFlow().collect { renderErrorView(it) }
detectLocationButton.setOnClickListener { viewModel.detectUserLocation() }
}
}
...
}
and this can be applied on iOS side as well if our core is Kotlin native, as you see we did not use any thing android specific in implementing the presentation logic
领英推荐
MVI example
similar to what we did in MVVM example, if we want this feature to be implemented in MVI pattern, we will declare our Port in the core module as follows :
interface PresentationPortMvi {
var cancellable: Cancellable?
var state: State?
data class State(
val userLocation: LocationInfo? = null,
val nearbyPointsOfInterest: List<PointOfInterest> = listOf(),
val error: NullableWrapper<Throwable>? = null
)
}
here we also declared the ViewState class (remember this is not clean architecture, we have more flexibility here, as long as our code is not depending on external dependencies or libraries, we can declare it here and test it ... plus the view state is nothing but a data class at the end)
[Implementation Detail] also we have a cancellable to class that will limit overlapping between events, this is the Cancellable class and it's runner :
/**
* this function is used with all Mvi ports, not only this one
*/
suspend fun runCancellable(action: suspend Cancellable.() -> Unit): Cancellable {
val cancellable = Cancellable()
cancellable.action()
return cancellable
}
/**
* this class is used with all Mvi ports, not only this one
*/
class Cancellable {
var isCancelled = false; private set
fun cancel() {
isCancelled = true
}
}
then we declare our presentation logic as extensions on the port interface :
/**
* move the location on map to the user location, and show the near by points of interests
* for that location
*/
suspend fun PresentationPortMvi.onDetectUserLocation(
lastState: PresentationPortMvi.State = PresentationPortMvi.State(),
locationsRepository: LocationsRepository = CoreDependencies.locationsRepository,
pointsOfInterestsRepository: PointsOfInterestsRepository = CoreDependencies.pointsOfInterestsRepository
) {
cancellable?.cancel()
cancellable = runCancellable {
val result = detectLocation(lastState, locationsRepository, pointsOfInterestsRepository)
if (!isCancelled) state = result
}
}
private suspend fun detectLocation(
lastState: PresentationPortMvi.State,
locationsRepository: LocationsRepository,
pointsOfInterestsRepository: PointsOfInterestsRepository
) = runCatching {
val location = locationsRepository.detectUserLocation()
lastState.copy(
userLocation = location,
nearbyPointsOfInterest = pointsOfInterestsRepository.requestPointsOfInterests(location),
error = null
)
}.getOrElse {
lastState.copy(error = NullableWrapper(it))
}
and the same case as any use-case, we can test this with 100% coverage as we have nothing to mock, just implement the port in our unit test and trigger the presentation logic, and assert on the final state
we dont even have to care about streams or concurrency although this is an MVI, since in the core module the viewStates is just a normal variable called "state", and the UI is responsible for converting it to a stream like what happened in MVVM
now going to the UI module (in our case here it is Android, but iOS can do similar implementation)
our ViewModel will implement the port interface :
class PresentationAdapterMvi(
val intentsStream: MutableSharedFlow<Intents?> = MutableStateFlow(Intents.OnDetectUserLocation()),
val viewStatesStream: MutableStateFlow<State?> = MutableStateFlow(null),
dispatcher: CoroutineDispatcher = Dispatchers.IO
) : ViewModel(), PresentationPortMvi {
override var cancellable: Cancellable? = null
override var state by emitInto(viewStatesStream)
init {
viewModelScope.launch(dispatcher) {
intentsStream.asSharedFlow().filterNotNull().collect { intent ->
when (intent) {
is Intents.OnDetectUserLocation -> onDetectUserLocation(intent.state)
// handle more intents here
else -> throw UnsupportedOperationException("intent not handled")
}
}
}
}
override fun onCleared() {
cancellable?.cancel()
}
}
If you can see nothing to test, even if we have many intents, the same applies, all the heavy work is in the core now and tested
and the Intents will be declared in the UI module as follows :
sealed class Intents {
abstract val state: State
class OnDetectUserLocation(override val state: State = State()) : Intents()
// another intent example
class OnMoveToPointOfInterest(override val state: State) : Intents()
}
and the MVI activity will look like this :
class MviActivity : AppCompatActivity() {
...
private val viewModel by lazy { ViewModelProvider(this)[PresentationAdapterMvi::class.java] }
override fun onCreate(savedInstanceState: Bundle?) {
...
progressBar.visibility = View.VISIBLE
lifecycleScope.launch {
viewModel.viewStatesStream.asStateFlow().filterNotNull().collect { viewState ->
progressBar.visibility = View.GONE
renderRecyclerView(viewState.nearbyPointsOfInterest)
renderLocationOnMap(viewState.userLocation)
renderErrorViewIfPresent(viewState.error)
detectLocationButton.setOnClickListener {
progressBar.visibility = View.VISIBLE
viewModel.intentsStream.tryEmit(Intents.OnDetectUserLocation(viewState))
}
}
}
}
...
}
* In MVI, since we receive the whole views state every time, no need to handle progress in the presentation logic, it should be as simple as showing the progress with every action, and hiding it when ever a view state is received, that way we can reduce lots of complexity in the presentation logic
MVP example
If we want the UI to follow the MVP pattern in our feature, in core module we will declare 2 ports, one for the view, and one for the presenter :
interface PresentationPortMvpView {
fun onRenderProgress(progressing: Boolean)
fun onRenderUserLocation(userLocation: LocationInfo)
fun onRenderNearByPointOfInterest(nearByPoint: List<PointOfInterest>)
fun onRenderError(throwable: Throwable)
}
interface PresentationPortMvp {
var view: PresentationPortMvpView
}
and in our core module as well, we will put the presentation logic :
/**
* move the location on map to the user location,
* and show the near by points of interests
* for that location
*/
suspend fun PresentationPortMvp.onDetectUserLocation(
locationsRepository: LocationsRepository = CoreDependencies.locationsRepository,
pointsOfInterestsRepository: PointsOfInterestsRepository = CoreDependencies.pointsOfInterestsRepository
) {
view.onRenderProgress(true)
runCatching {
requestLocationAndPointsOfInterests(
locationsRepository,
pointsOfInterestsRepository
)
}.onFailure {
view.onRenderError(it)
}
view.onRenderProgress(false)
}
private suspend fun PresentationPortMvp.requestLocationAndPointsOfInterests(
locationsRepository: LocationsRepository,
pointsOfInterestsRepository: PointsOfInterestsRepository
) {
val location = locationsRepository.detectUserLocation()
val pointsOfInterest = pointsOfInterestsRepository.requestPointsOfInterests(location)
view.onRenderUserLocation(location)
view.onRenderNearByPointOfInterest(pointsOfInterest)
}
and we can unit test it as well and go for nearly 100%
now to the UI module, the class that will implement the presenter port interface will be a ViewModel for rotation handling :
class PresentationAdapterMvp @JvmOverloads constructor(
override var view: PresentationPortMvpView,
val dispatcher: CoroutineDispatcher = Dispatchers.IO
) : ViewModel(), PresentationPortMvp {
private var detectUserJob: Job? = null
fun detectUserLocation() {
detectUserJob?.cancel()
detectUserJob = viewModelScope.launch(dispatcher) {
onDetectUserLocation()
}
}
override fun onCleared() {
detectUserJob?.cancel()
}
}
// factory to pass the view to the constructor of our presenter :
class PresentationAdapterMvpFactory(private val view: PresentationPortMvpView) :
ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return PresentationAdapterMvp(view) as T
}
}
We could have used the cancellable like MVI, or this way, these are just implementation details, but the main idea is the design decisions across the journey, not the implementation details
now for the Activity/Fragment that will implement the view port :
class MvpActivity : AppCompatActivity(), PresentationPortMvpView {
...
private val presenter by lazy {
ViewModelProvider(this, PresentationAdapterMvpFactory(this))
.get(PresentationAdapterMvp::class.java)
}
override fun onCreate(savedInstanceState: Bundle?) {
...
detectLocationButton.setOnClickListener {
presenter.detectUserLocation()
}
// request upon rotation or creation
presenter.detectUserLocation()
}
override fun onRenderUserLocation(userLocation: LocationInfo) {
lifecycleScope.launch(Dispatchers.Main) {
// draw user location on map
}
}
override fun onRenderProgress(progressing: Boolean) {
lifecycleScope.launch(Dispatchers.Main) {
// update progressing view
}
}
override fun onRenderNearByPointOfInterest(nearByPoint: List<PointOfInterest>) {
lifecycleScope.launch(Dispatchers.Main) {
// update recycler view
}
}
override fun onRenderError(throwable: Throwable) {
lifecycleScope.launch(Dispatchers.Main) {
// show error based on Exception type
}
}
}
And that's it
Conclusion
We do not need to move to ports and Adapters to embrace the thought process of it, to be driven by testing while not violating common sense rules and proper design decisions
After the language features of Kotlin, we now can accurately know the test coverage for our code that contains logic, weather it is business or presentation, at the end of the day, both are present in the requirements and both are very important to test
thinking of UI the same way we think of Data Sources also gave us the ability to embrace kotlin Multiplatform easily, and invest the minimum time in platform specific code, while having the important parts implemented in our core
PS. this code is for sake of example, but I did this myself on big-scale projects before
PS. we can debate about what theories say, but at the end this is just a way to increase testability with respect to an existing architecture
the code for the example is in this link :
https://github.com/Ahmed-Adel-Ismail/GDG-Helwan-MVI-2021/tree/feature/test-coverage
Развиваю бизнес в компании Law firm "Vetrov and partners"
1 年Ahmed, ??
Senior Android Developer at FootballCo.
1 年Thanks for linking the original source of the image ?? https://akjaw.com/testing-on-kotlin-multiplatform-and-strategy-to-speed-up-development/
iOS Engineer
2 年Ahmed Abdelfattah
Human | Staff Android Engineer | Android Kotlin Advocate & Public Speaker
2 年??????? ?? ????? ???? ????? ??? ?????
Mobile Software Engineer II @ noon
2 年I have to say, I loved the amount of information you've shown here. Well written article, well done.