How to balance Unit and UI tests on Android

Faruk Toptaş
Android Bits
Published in
5 min readJul 8, 2020
Photo by Zdeněk Macháček on Unsplash

Just making things work is not enough. Our code/app can be broken with a small change. At first we should be aware of that writing tests is a need. If we agree that, the next question will be “What about the coverage?”.

https://martinfowler.com/bliki/TestPyramid.html

If your code is fit with Single Responsibility Principle it will be easy to organize your tests like the pyramid above. Before writing tests we need a good architecture. If the architecture is not good enough it can be a nightmare to test. Here are some simple rules to make your code testable:

  • Choose a layered architecture (MVVM, MVP, MVI etc.)
  • Split your code into small units
  • Abstract behaviors, don’t mind implementations
  • Use dependency injection
  • Keep your UI classes (Activity/Fragment/View etc.) as dummy as possible. Reduce the number of possibilities on a single UI class.

Let’s try it on a simple example.

I have a login screen with 4 scenarios. When Login button is pressed:

  • If username is empty show alert with “Username should not be empty
  • If password is empty show alert with “Password should not be empty
  • If username and password is not empty then call Login api. If credentials don’t match show alert “Wrong credentials
  • If username and password is not empty then call Login api. If credentials match show Dashboard screen.
Login screen

1. Choose a layered architecture

Here we go with Jetpack ViewModel. I keep all my logic in a ViewModel class. Unit testing the ViewModel will cover all my logic.

class LoginViewModel(private val repo:LoginRepository) {

val error = SingleLiveEvent<String>()
val goToDashboard = SingleLiveEvent<Boolean>()


fun login(user: String, pass: String) {
if (user.isEmpty()) {
error.postValue("Username should not be empty")
return
}

if (pass.isEmpty()) {
error.postValue("Password should not be empty")
return
}

callApi({ repo.login(user, pass) },
success = {
goToDashboard.postTrue()
},
fail = {
error.postValue("Wrong credentials")
})
}

}

2. Split your code into small units

In the code above you don’t see the implementation of callApi method. Because ViewModel should not care about the detail of a network call. It is just a data. It doesn’t matter if comes from network or anywhere else.

3. Abstracting Behaviors

interface LoginRepository{
suspend fun login(user:String, pass:String) : Response<Boolean>
}

class LoginRepositoryImpl(private val api:Api):LoginRepository{
override suspend fun login(user: String, pass: String): Response<Boolean> {
return apiWrapper { api.login(user, pass) }
}
}

Defining an interface for a class is an extra effort but makes abstract your behavior. So you will think about WHAT not HOW. Then it will be easily mocked.

// success scenario
whenever(repo.login(any(),any())).thenReturn(Response(true))
// fail scenario
whenever(repo.login(any(),any())).thenReturn(Response(false))

4. Dependency Injection

This is a hot topic I will not deep dive into DI. Dagger and Koin are the most used DI libraries on Android. You can choose one of them or use your own solution.

Injecting dependencies will let you use different implementations with the same behavior. Like If you are testing your ViewModel logic or an alert dialog is visible to user, you don’t need to make a network call. Mocking the network call will make tests run faster and cover all edge cases.

5. Keep UI classes as dummy as possible

class LoginActivity : BaseActivity() {

private val vm: LoginViewModel by viewModel()

override fun onCreate(savedInstanceState: Bundle?) {
...
btnLogin.setOnClickListener { vm.login(user, pass) }

// Observe LiveData
vm.baseErrorLive.observeNotNull(this) { showError(it) }
vm.goToDashboard.observeIfTrue(this) { showDashboard() }
}
}

Try not use any logic in your UI classes. You should have no if-else statements there if possible :) There is a somehow rightful approach to stop using if conditions.

Unit Test Scenarios

@Test
fun testEmptyUsername() {
vm.login("", "pass")

assertEquals("Username should not be empty", vm.error)
verify(repo, never()).login(any(), any())
}

@Test
fun testEmptyPass() {
vm.login("user", "")

assertEquals("Password should not be empty", vm.error)
verify(repo, never()).login(any(), any())
}

@Test
fun testWrongCredential() = runBlocking {
whenever(repo.login(any(), any())).thenReturn(Response(false))
vm.login("user", "wrong_pass")

verify(repo).login(any(), any())
assertEquals("Wrong credentials", vm.error)
assertNull(vm.goToDashboard)

}

@Test
fun testShowDashboard() = runBlocking {
whenever(repo.login(any(), any())).thenReturn(Response(true))
vm.login("user", "real_pass")

verify(repo).login(any(), any())
assertNull(null, vm.error)
assertEquals(true, vm.goToDashboard)
}

Unit tests will cover all my business logic. But I need to be sure that UI actions work, like posting an error message to my UI class triggers an AlertDialog.

The effect of an empty password or a network error is the same on UI. So I do not need to write UI test for each scenario.

It is a good approach to write UI tests for all LiveData instances observed in the UI.

I have 2 observed LiveData instances. So I will write only 2 UI test scenarios.

@RunWith(AndroidJUnit4::class)
class LoginActivityTest {

@get:Rule
val rule = ActivityTestRule(LoginActivity::class.java, false, false)

private val repo: LoginRepository = mock()

@Before
fun setup() {
StandAloneContext.loadKoinModules(module {
single(override = true) { repo }
}
)
val intent = Intent()
rule.launchActivity(intent)
}

@Test
fun testLoginFail() {
login {
setUserName("user")
setPassword("")
clickLogin()
matchErrorText("Username should not be empty")
}
}

@Test
fun testLoginSuccess() = runBlocking{
whenever(repo.login(any(), any()))
.thenReturn(Response(true))
login {
setUserName("user")
setPassword("real_pass")
clickLogin()
matchText("This is Dashboard screen")
}
}

}

In the setup method I only inject the interface that I want to mock. In the dependency graph there are many objects. All of them stays there except for the implementation that I want to change.

Some extra points you should care of:

  • Writing UI test for an Activity/Fragment is ok. But you have to take care of the flow. For example your Activity A needs an intent extra and you set it while testing. Everything works fine. But what about if you forget to set intent extra while starting Activity A from Activity B. Testing the activity itself may not be enough. You may need to test Activity transitions.
  • If you reuse behaviors or move some common tasks to base classes it will be enough to test it only once.
  • Be aware of persistent data. All test cases should be stateless. For example don’t use SharedPreferences directly, always mock it.

If you like this article you can follow me on Medium or Github.

You can read my previous articles about Espresso tests.

--

--