Easier Testing with MVVM, MVI, MVP and Kotlin Multiplatform
https://akjaw.com/testing-on-kotlin-multiplatform-and-strategy-to-speed-up-development/

Easier Testing with MVVM, MVI, MVP and Kotlin Multiplatform

Before we start, this article requires basic knowledge about the following topics :

  • Clean Architecture
  • Unit Testing
  • Domain Driven Design (at least knowing what is Domain/Core, what is a Use-case and what is a Repository)
  • One of the GUI patterns (MVP or MVVM or MVI)
  • [Optional] Ports and Adapters Architecture - this will be covered up partially as we go

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

Yuliya Maltaki

Развиваю бизнес в компании Law firm "Vetrov and partners"

1 年

Ahmed, ??

Aleksander Jaworski

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/

Shady Yehia Selim, MSc,MBA

Human | Staff Android Engineer | Android Kotlin Advocate & Public Speaker

2 年

??????? ?? ????? ???? ????? ??? ?????

Ahmad Hamwi

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.

要查看或添加评论,请登录

Ahmed Adel Ismail的更多文章

  • Sharing data across multiple Mobile Squads - with examples

    Sharing data across multiple Mobile Squads - with examples

    Earlier I shared an article suggesting a solution to a common problem with teams following the "Spotify Model", which…

  • SDD - Squad Driven Design

    SDD - Squad Driven Design

    Working in multiple big teams I've found that we are always trying to apply our known best practices in software, but…

    4 条评论
  • Android - A Cleaner Clean Architecture

    Android - A Cleaner Clean Architecture

    It has been a while now since Clean Architecture was out, and even many of us started embracing hexagonal (ports and…

    10 条评论
  • Beyond Functional Programming

    Beyond Functional Programming

    In the Android industry, lately functional programming was the all new stuff to learn, RxJava, Kotlin, and the whole…

    7 条评论
  • Dependency Injection in Clean Architecture

    Dependency Injection in Clean Architecture

    After Google's Opinionated Guide to Dependency Injection Video, Google made a clear statement that they want developers…

    18 条评论
  • Meta Programming in Android

    Meta Programming in Android

    Year after year we are getting rid of the boilerplate code that we need to write for small and simple tasks in Android,…

    2 条评论
  • MVI Pattern For Android In 4 Steps

    MVI Pattern For Android In 4 Steps

    Lately I wrote an article about MVI pattern, but as we are facing new problems every day and face more use-cases, we…

    7 条评论
  • Agile - Moving Fast

    Agile - Moving Fast

    We always here about Agile, and think about which methodology do we use, what practices do we have, team velocity…

    1 条评论
  • Kotlin Unit Testing with Mockito

    Kotlin Unit Testing with Mockito

    I've always believed that, if the code is designed to be tested, we wont need any testing framework or library ..

    17 条评论
  • MVI - Model View Intent simplified

    MVI - Model View Intent simplified

    I have been searching for a proper resource to explain the MVI pattern, but every time I get hit with Hannes Dorfmann…

    4 条评论

社区洞察

其他会员也浏览了