As Ben Franklin once said, “In this world nothing can be said to be certain, except death and taxes [and that software design specs always change]”.
The reality of ever-changing design specs can, at times, be frustrating and miserable, but rather than focus on the negatives I like to think that it can also be a fun challenge in how we approach development. So today I am going to share one improvement I made to how I handle network requests in Android development to protect against UI changes.
MVP
MVP (Model View Presenter) is a popular architectural style for Android development. And while there are many positives - easy to test, good separation between views and model - there are also some downsides. One particular frustration I have faced is having to update the view interface every time you want to update another UI element. For example, if you were building a profile page you might have the following view interface:
interface ProfileView {
fun showFirstName(name: String)
fun showLastName(name: String)
}
But if you wanted to update the UI to also show a user’s age and astrological sign you would also have to update the view interface.
interface ProfileView {
fun showFirstName(name: String)
fun showLastName(name: String)
fun showAge(age: Int)
fun showAstrologicalSign(sign: String)
}
This update to the view interface is both frustrating and a violation of the
Open-Closed Principle
.
Note: Before I continue talking about how annoying updating view interfaces can be, I want to admit that, just like everything else in life, there are pros and cons, and sometimes this sort of update is necessary and can even be good.
But enough mature pragmatism, back to the bad… Another reason I find these
sorts of updates frustrating is that they can take away from the bigger picture
of what is happening in a given view and focus too much on the minute details
of visual implementation. Now I know what you’re thinking, “but it’s a VIEW
INTERFACE! It’s supposed to focus on the visuals!”. And you would be correct,
but before you get a bee π in your bonnet π consider that we do not yet have the
full picture. So far in our example we have only been focusing on the methods
that handle displaying a User
. Let’s look at the presenter, to see how it
fetches the User
to better understand the rest of what is happening
with this view.
class ProfilePresenter(val view: ProfileView, val api: ProfileDataStore) {
fun fetchUser() {
api.fetchUser()
.subscribe({ user ->
// network call successful
view.showFirstName(user.firstName)
view.showLastName(user.lastName)
view.showAge(user.age)
view.showAstrologicalSign(user.sign)
}, { error ->
// network call failed
})
}
}
Ah good ol’ network calls. Because our User
object is coming
from a network call, we need to update our view interface to include methods
for showing the various states surrounding making a request.
interface ProfileView {
fun showFirstName(name: String)
fun showLastName(name: String)
fun showAge(age: Int)
fun showAstrologicalSign(sign: String)
+ fun showError(errorMessage:String)
+ fun showLoading()
+ fun hideLoading()
}
Our view interface is starting to look a bit different. It’s not all about the
User
details any more, its also got some networking view state stuff in
there. As you can imagine all view interfaces that work with a presenter that
makes networking calls would also have to add these networking related view
methods. That means a lot of duplication across all our view interfaces and no
one wants that π
.
Networking View State
Say hello to NetworkingViewState
π. A sealed
class that abstracts
all the various states a view can be in, as a result of a networking call,
into a single type.
sealed class NetworkingViewState {
class Loading() : NetworkingViewState()
class Success<T>(val item: T) : NetworkingViewState()
class Error(val errorMessage: String?) : NetworkingViewState()
}
Now let’s update our view interface with this new type.
interface ProfileView {
var networkingViewState: NetworkingViewState
}
And our presenter…
class ProfilePresenter(val view: ProfileView, val api: ProfileDataStore) {
fun fetchUser() {
view.networkingViewState = NetworkingViewState.Loading()
api.fetchUser()
.subscribe({ user ->
// network call successful
view.networkingViewState = NetworkingViewState.Success<User>(user)
}, { error ->
// network call failed
view.networkingViewState = NetworkingViewState.Error(error.message)
})
}
}
Now if the UI changes, our view interface, our presenter and our tests do not have to change, because the bigger picture behavior remains the same πͺ
But were not done yet… While we have made our code more SOLID (get it!?) we
have also lost some test coverage. Before we introduced NetworkingViewState
our presenter was in charge of mapping the User
object we got in the response
from the API into the appropriate types to be shown by the view interface. And
because those formatted values were passed through the view interface we could
assert they were the correct values in our tests.
Like this:
class ProfilePresenterTest() {
val view = mock<ProfileView>()
val dataStore = mock<ProfileDataStore>()
val mockUser = User("Amanda", "Hill", 100, "Aquarius")
@Test
fun testFetchUser_success() {
//stub network response
whenever(dataStore.fetchUser()).thenReturn(Observable.just(mockUser))
val presenter = ProfilePresenter(view, dataStore)
presenter.fetchUser()
verify(view).showLoading()
verify(view).showFirstName(mockUser.firstName)
verify(view).showLastName(mockUser.lastName)
verify(view).showAge(mockUser.age)
verify(view).showAstrologicalSign(mockUser.sign)
verify(view).hideLoading()
}
}
But with our updated presenter our tests now look like this:
class ProfilePresenterTest() {
val view = mock<ProfileView>()
val dataStore = mock<ProfileDataStore>()
val mockUser = User("Amanda", "Hill", 100, "Aquarius")
@Test
fun testFetchUser_success() {
//stub network response
whenever(dataStore.fetchUser()).thenReturn(Observable.just(mockUser))
val presenter = ProfilePresenter(view, dataStore)
presenter.fetchUser()
verify(view).networkingViewState = isA<NetworkingViewState.Loading>()
verify(view).networkingViewState = isA<NetworkingViewState.Success<User>>()
verifyNoMoreInteractions(view)
}
}
There is no validation around the User
object being passed on the success
case. So how can we gain those formatting tests back?
That’s right, view models! When we set the NetworkingViewState
to Success
in our presenter, rather than pass the User
object we can pass a
UserViewModel
instead. And we can add tests for our UserViewModel
in a
separate class to ensure all values are formatted properly.
Something like this:
class UserViewModelTest {
val mockUser = User("Amanda", "Hill", 100, "Aquarius")
@Test
fun testName() {
val viewModel = UserViewModel(mockUser)
val expected = "Amanda Hill"
val actual = viewModel.fullName()
assertEquals(expected, actual)
}
}
Huzzah πͺ ! Test coverage regained π ! And blog post complete π€ !