Android - MVP Increases Testability

Tan Jun Rong avatar

Tan Jun Rong

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:

My Pretty Login Page Wireframe
My Pretty Login Page Wireframe

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 ๐Ÿ‘‡

Showing Error
Showing Error

Once they both pass the validation, the SEND button should light up. Here's a gif showing how it all work together!

Sample Implementation of Requirement
Sample Implementation of Requirement

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:

Block Diagram (without Architecture)
Block Diagram (without Architecture)

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.

Block Diagram (with MVP Architecture)
Block Diagram (with MVP Architecture)

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:

JUnit testing the Presenter
JUnit testing the Presenter

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:

  1. check if the email for valid format
  2. 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 avatar
Written By

Tan Jun Rong

Android Programmer who likes writing blogs, reading, coffee, snowboarding.
Published in Android
Enjoyed the post?

Clap to support the author, help others find it, and make your opinion count.