Android - MVP Increases Testability
Testing MVP with mockk
MVP/MVVM/MVI/Redux. Ouch... It's really hard to keep up!
Recently there are many discussions about architectures for Android development. I have trouble trying to keep up myself. ๐จ๐ฆ
However, I don't think it's important to know everything. That's the fear of missing out, and that is not good . I realized that it's better to take the time to learn something properly rather than rushing through learning. This way, I actually enjoy the learning process itself.. ๐ ๐ ๐
Let's look at simple MVP implementation in this blogpost.
Instead of arguing over the naming, or which is the best implementation of MVP, I want to discuss about the testability of MVP.
Let's begin! ๐ช
Prerequisite
Sample code will be written in Kotlin in this example. I will also use mockk library. If you donโt already know, itโs totally fine too!
Source code
All the source code in this post are just a snippets from the complete example. The complete example is in my Github repo.
Testability
While I was writing huge monster activity in the past, I started to learn MVP. Then I saw this sentence coming up again and again.
MVP increase testibility
I really didn't understand what it means, because from my understanding, I was moving things from big monster Activity
into Presenter
. Was I not making another monster Presenter
file, how does that make it easier to test? ๐ฎ
Let's discuss about this and see what this sentence actually means: MVP increase testibility.
Requirement
It's hard to visualize things without an example, so let's start with a requirement.
Let's say we need to implement a form like this:
This is a very common use case, which is most likely needed in almost every app that requires login.
Next, we need to validate the email and password, making sure that it's the correct email format and password length.
Then, we should show the error message like the following if the format or length didn't meet the requirement ๐
Once they both pass the validation, the SEND button should light up. Here's a gif showing how it all work together!
Now that we know what we're going to build, let's start.
Without MVP architecture
First, let's consider doing it without any architecture ใผ placing everything a huge activity.
I will not show any code, but I'll use the block diagram instead:
The problem with this is that Espresso is very expensive, it takes very long to run and consume lots of resources. It involves firing up the emulator.
What we really want to test is the Logic, highlighted in blue in the diagram above๐. It's a waste firing up the emulator just to test the Logic, since it can be done by pure JUnit test.
We can save the Espresso test for what cannot be tested by JUnit test.
With MVP Architecture
Next, let's take a look at MVP implementation.
Let's first consider the block diagram.
It's very important to note that Presenter
should be Java/Kotlin-only, it shouldn't contain any class or reference to related to Android. This way we can run JUnit test (which is much cheaper). The block diagram including test looks like this:
From the diagram, we can see that Android Emulator is no longer in the picture! ๐
Implementation of MVP
I think MVP is a concept, not a framework. Perhaps if your implementation evolved into one and can be extracted into a library, you can argue that it's a framework. However, I don't think we need any library to implement this.
To show the error, we need to add a listener to emailEditText
:
(Reference: SimpleMvpActivity.kt@Github)
class SimpleMvpActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
...
emailEditText.addTextChangedListener(object : TextWatcher {
override fun afterTextChanged(s: Editable) {}
override fun beforeTextChanged(s: CharSequence, start: Int, count: Int, after: Int) {}
override fun onTextChanged(s: CharSequence, start: Int, before: Int, count: Int) {
// 1. check the email for valid format
// 2. show error message if invalid format
}
})
...
}
}
For 1. and 2. in the code, we delegate that to the Presenter
:
class SimpleMvpActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
...
emailEditText.addTextChangedListener(object : TextWatcher {
override fun afterTextChanged(s: Editable) {}
override fun beforeTextChanged(s: CharSequence, start: Int, count: Int, after: Int) {}
override fun onTextChanged(s: CharSequence, start: Int, before: Int, count: Int) {
// 1. check the email for valid format
// 2. show error message if invalid format
// delegate to the presenter:
presenter.onEmailTextChanged(s.toString())
}
})
...
}
}
Let's take a look at the Presenter
:
(Reference: SimpleMvpPresenter.kt@Github)
class SimpleMvpPresenter(val view: ???) {
var email = ""
...
fun onEmailTextChanged(_email: String) {
email = _email
// 1. check the email for valid format
// 2. show error message if invalid format
view.showEmailError(shouldShowEmailError(email))
...
}
private fun shouldShowEmailError(email: String) =
!email.isBlank() && !email.isValidEmail()
...
}
Notice that we have delegate the showing of email validation error into the Presenter
. I'm calling View
to access the showing of error message from the UI. In this case, the View
is an interface
to access methods in the Activity
.
I place ???
in the constructor of the code above intentionally. It's important that we SHOULD NOT reference to the Activity
like below:
// BAD IMPLEMENTATION
class SimpleMvpPresenter(val view: Activity) {
Because this way, we will need Robolectric or Espresso to run the test. To keep it at JUnit level, we should make an interface
instead.
class SimpleMvpPresenter(val view: SimpleMvpPresenter.View) {
...
interface View {
fun showEmailError(shouldShow: Boolean)
}
}
Then in our Activity
, we should hook up the real implementation of View
, and instantiate the Presenter
:
class SimpleMvpActivity : AppCompatActivity() {
val view: SimpleMvpPresenter.View by lazy {
object : SimpleMvpPresenter.View {
override fun showEmailError(shouldShow: Boolean) {
if (shouldShow) {
emailErrorLabel.visibility = View.VISIBLE
} else {
emailErrorLabel.visibility = View.INVISIBLE
}
}
...
}
}
// instantiate the Presenter
val presenter: SimpleMvpPresenter by lazy { SimpleMvpPresenter(view) }
override fun onCreate(savedInstanceState: Bundle?) {...}
}
Up to now, I only implement for email
, but the same needs to be done for password
and the send button
, but I will exclude from the post to keep it short. Complete code can be found in the Github link above.
Testing
Now letโs look at testing using JUnit and mockk.. What we want to test are these 2 points:
- check if the email for valid format
- show error message if invalid format
First we need to setup the Presenter
and View
:
Presenter
is what we want to test, so Iโm making a real instance.
View
supposed to contain the logic that actually calls the Android classes, so we will mock it using mockk
.
lateinit var view: SimpleMvpPresenter.View
@Before
fun setup() {
view = mockk()
presenter = SimpleMvpPresenter(view)
// setup
every { view.showEmailError(any()) } just Runs
every { view.showPasswordError(any()) } just Runs
every { view.setButton(any()) } just Runs
}
every { view.showEmailError(any()) } just Runs
is asking mockk
to do nothing
when view.showEmailError
method is called.
After setting up. We can finally test it.
Letโs begin with the checking the case of invalid email:
@Test
fun presenterError() {
// action
presenter.onEmailTextChanged("not email")
// assert
verify {
view.showEmailError(shouldShow = true)
}
}
This test should pass. What I like to do to make sure my test is valid is to flip the assertion around to make sure my test is actually working as expect.
For example, making sure that the code below will fail:
// change this to false, when it should be true
view.showEmailError(shouldShow = false)
After that, we can test the happy path with valid email:
@Test
fun presenterNoError() {
// action
presenter.onEmailTextChanged("[email protected]")
// assert
verify {
view.showEmailError(shouldShow = false)
}
}
Thatโs all!
Discussion
Testing Pyramid
In this article we can see that MVP helps us organize the code, and also helps making it possible to test using JUnit without Espresso.
Following the https://testing.googleblog.com/2015/04/just-say-no-to-more-end-to-end-tests.html, we can now test our Presenter
heavily.
Parameterized Tests
While testing the happy path and the error path, 2 different tests are used. You might notice that the testing code are so similar. Youโre right! It can actually be extracted into the same test using https://github.com/junit-team/junit4/wiki/parameterized-tests. Think of testing AND Gate
:
- 0 AND 1 = 0
- 0 AND 1 = 0
- 1 AND 0 = 0
- 1 AND 1 = 1
Instead of using 4 tests to check the above behaviour, only 1 test is actually needed. It can be an improvement to the current implementation in this example.
RxJava
In this example, whenever thereโs an input change from user, we call presenter.onEmailTextChanged(s.toString())
to delegate the logic into Presenter
.
This happens inside an EditText
, so it looks like this:
emailEditText.addTextChangedListener(object : TextWatcher {...
override fun onTextChanged(s: CharSequence, start: Int, before: Int, count: Int) {
presenter.onEmailTextChanged(s.toString())
}
})
This implementation can be changed by using RxJava, which brings in a different programming paradigm. Rx can help to redirect and manipulate streams of data. Besides, it can turn different API into the same API, which is Rx.
The difference is so big that it deserves another post by itself.
Bye!
That's all for this post, hope you enjoy reading!
Tan Jun Rong
Clap to support the author, help others find it, and make your opinion count.