Unit testing with JUnit in android

Mubarak Native
9 min readApr 17, 2024

--

Software does not complete without testing.

In this article I guide you through What is Unit testing, and Why do we test our code, and how to automate our tests by using JUnit and more…

What is Unit Testing ?

In Software, we break our code into small units, so it can be easily tested. Units can be a class, or a single method in a class. Basically, tests that help us to ensure that our code/logic should work as intended.

Why do we test our code ?

As I mentioned before, tests can verify that the behavior of our application works as expected. If you break something in our code, it immediately reports the failure. You will easily fix that issue in early development without actually shipping our application to production.

Types of testing?

Manual Testing

Automated Testing

  • Manual Test: As the name implies we test our app manually if it works properly this approach is time consuming and not very scalable.
  • Automated Test: In Automated tests we test that application by using a testing framework that’s cover all edge cases in our application , and it faster also repeatable. For Java JUnit is a popular and a well known Testing framework this is also a default test framework in android when you will creating a new project in android studio.

In this Article we use JUnit Testing Framework to write our all automated tests.

Testing Scope

Testing Scope determines where our test cover in whole application.

  • Unit tests : It verifies small portion of app usually single class or single method in that class.
  • End-to-end tests : It verifies large portions of app such as whole screen or user journey.
  • Integration tests : It verifies the intergration between one or multiple units.

The types of test in android is determined by where the test runs.

1. Local Unit Test

2. Instrumented Test

  1. Local unit tests are run on local JVM, so it is really fast, and typically includes core logic such as business logic, math calculation. That doesn’t need to interact with the android framework and it has a lower fidelity rate.
  2. Instrumented tests relay on android devices either physical or emulated, So it generally slow to run, and instrumented test are typically UI tests example: test the Dao in room database and it has a higher fidelity rate.

Fidelity rate determines how close it is to real world production. Higher fidelity rate comes with the cost of slower test execution time. We need to balance in between.

In this article we only focus on writing local unit test by using JUnit 4.

Testing Environment

A testing environment plays a crucial role in testing, Without proper Testable architecture we can’t properly implement our test cases android recommends Layered architeture you can learn more about here Guide to app architeture.

We need to consider some use cases before testing, that ensure our test will work properly.

1. Isolation

2. Synchronous

  1. Isolate the testing class from its dependencies, use test doubles or fake version of dependent class for testing.
  2. Synchronous: Test should not be Asynchronous it lead to non-deterministic behaviour also called flaky test, if we working with coroutines or livedata our test should be synchronous.

A flaky test is a test whose behavior is non-deterministic. Some time passes and sometimes not.

Enough of theory, Let’s write our unit test for Viewmodel.

The JUnit testing framework provide lots of assert annotations for write tests the partial example would be @assertEquals, @assertNotNull, @assertSame and so on You can sell all here.

But, in this article i will use the Truth assertion library it make assertions more readable.

Add this dependencies in your build.gradle.kts(Module :app ), you don’t need a dependency for JUnit because it come with android studio.

    testImplementation ("androidx.arch.core:core-testing:2.2.0")
testImplementation ("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.1")
testImplementation("androidx.room:room-testing:2.6.1")
testImplementation("androidx.test.ext:junit-ktx:1.1.5")
testImplementation("androidx.test:core-ktx:1.5.0")
testImplementation("com.google.truth:truth:1.1.5")

Let’s take real world example, usually in android, Viewmodel contains business logic in this example i will use my Note app sample.

In this HomeViewModel class we need to test the toggleNoteLayout function to verify does this actually convert the NoteLayout to List to Grid and vice versa and test the redoNoteToActive function.

Usually Testing Viewmodel class go inside test source set and anything that visual (interacting with views) should go inside androidTest source set.

Place the HomeNoteViewModelTest class in test source set

Right Click the test source set New > Kotlin Class/File and name you Test class as YOUR_CLASS_NAME which you are test and append Test in last in our case HomeNoteViewModelTest

Step: 1

Intializing the required class for our HomeNoteViewModel depend on NoteRepository and UserPreference both are Interface, if you use concrete implementation replace it with Interface so we actually easily swaps the real implementation with fake versions for testing.

Use dependency injection whenever possible that help us to maintain Single Responsibilty Principle.

/** FakeNoteRepository for testing*/
class FakeNoteRepository : NoteRepository {

val noteList = mutableListOf<Note>()
override suspend fun insertNote(note: Note) {
noteList.add(note)
}

override suspend fun upsertNote(note: Note) {
val index = noteList.indexOfFirst { it.id == note.id }
if (index != -1) {
noteList[index] = note
} else {
noteList.add(note)
}
}

override fun getAllNote(): Flow<List<Note>> {
return flow { emit(noteList) }
}

override suspend fun deleteNote(note: Note) {
noteList.remove(note)
}

override suspend fun deleteNoteById(noteId: Long) {
noteList.removeAll { it.id == noteId }
}

override suspend fun deleteAllNotes() {
noteList.clear()
}

override suspend fun deleteAllNotesInTrash() {
noteList.clear()
}

override fun searchNote(searchQuery: String): Flow<List<Note>> {
return flow {
val filter = noteList.filter { it.title == searchQuery || it.description == searchQuery }
emit(filter)
}
}

override fun getNoteStreamById(noteId: Long): Flow<Note> {
return flow {noteList.find { it.id == noteId }!! }
}

override fun getNoteByStatus(noteStatus: NoteStatus): Flow<List<Note>> {
return flow {
val filter = noteList.filter { it.noteStatus == noteStatus}
emit(filter)
}
}

override suspend fun getNoteById(noteId: Long): Note {
return noteList.find { it.id == noteId }!!
}
}

Fake version of NoteRepository only for testing not sutiable for production.

/** FakeUserPreference for testing*/
class FakeUserPreference : UserPreference {

private var noteLayout: String = NoteLayout.LIST.name
override suspend fun setNoteLayout(noteLayout: String) {
this.noteLayout = noteLayout
}

override val getNoteLayout: Flow<String>
get() = flow { emit(noteLayout) }
}

Fake version of FakeUserPreference only for testing not sutiable for production.

Now, you will have required dependencies for HomeNoteViewModelTest.kt.

Let’s initialize our viewmodel by our fake dependencies.

package com.mubarak.madexample.ui.note

import com.mubarak.madexample.data.repository.FakeNoteRepository
import com.mubarak.madexample.data.sources.datastore.FakeUserPreference
import org.junit.Before
import org.junit.Test

class HomeNoteViewModelTest {

private lateinit var homeNoteViewModel: HomeNoteViewModel
private lateinit var fakeNoteRepository: FakeNoteRepository
private lateinit var userPreference: FakeUserPreference

@Before
fun setUp() { // this function annotated with @Before meaning it runs before our test best place for initialization
fakeNoteRepository = FakeNoteRepository()
userPreference = FakeUserPreference()
homeNoteViewModel = HomeNoteViewModel(fakeNoteRepository, userPreference)
}

}

Step: 2

Write test case for toggleNoteLayout function.

Create a test function in a HomeNoteViewModelTest in test cases you will follow specific naming convention for test function.

1.) Name of the function or class to test in this casetoggleNoteLayout.

2.) Action or Input that we provide to the function in this case ListLayout because our FakeUserPreference always return NoteLayout.LIST.name.

3.) Finally expected result we expect to be return on that test function in this case toggleNoteLayout should return vice versa so it return GridLayout.

So, our function name would be the following toggleNoteLayout_ListLayout_ShouldReturnGridLayout we can also use backticks for name our function in local test like this toggleNoteLayout ListLayout ShouldReturnGridLayout.

Add this JUnit rule to your HomeNoteViewModelTest.

@get:Rule var taskExecutorRule = InstantTaskExecutorRule()

It is a JUnit rule that defines all architectural components jobs are execute in same thread to archive Synchronous behaviour for our test When we write test for livedata be sure to add this Rule.

Let’s test the LiveData to ensure that is return GridLayout when passing ListLayout, But here is that cache to get the value of the livedata we observe it But we are not in a Fragment or Activity We are right in test class How we observe the value.

Solution is to use the observeForever method you not need a LifeCycleOwner don’t forgot to remove the observer when not needed anymore if you forgot to do that it cause the memory leak.

Here is the code

    @Test
fun toggleNoteLayout_ListLayout_ShouldReturnGridLayout() {

val homeNoteViewModel = HomeNoteViewModel(fakeNoteRepository,userPreference)

val observer = Observer<String> {}
try {
val actual = homeNoteViewModel.noteItemLayout.value
assertThat(actual).isNotEqualTo(NoteLayout.GRID.name)

} finally {
// remove the observer
homeNoteViewModel.noteItemLayout.removeObserver(observer)
}
}

But this seems a lot for single test funtion There is a way to get rid of this you’re going to create a kotlin extension function for livedata called LiveDataTestObserverUtil

Here’s how now it looks like

package com.mubarak.madexample.ui.note

import androidx.arch.core.executor.testing.InstantTaskExecutorRule
import com.google.common.truth.Truth.assertThat
import com.mubarak.madexample.MainCoroutineRule
import com.mubarak.madexample.data.repository.FakeNoteRepository
import com.mubarak.madexample.data.sources.datastore.FakeUserPreference
import com.mubarak.madexample.data.sources.local.model.Note
import com.mubarak.madexample.getOrAwaitValue
import com.mubarak.madexample.utils.NoteLayout
import com.mubarak.madexample.utils.NoteStatus
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest
import org.junit.Before
import org.junit.Rule
import org.junit.Test

class HomeNoteViewModelTest {

private lateinit var homeNoteViewModel: HomeNoteViewModel
private lateinit var fakeNoteRepository: FakeNoteRepository
private lateinit var userPreference: FakeUserPreference

@get:Rule
var taskExecutorRule = InstantTaskExecutorRule()

@Before
fun setUp() {
fakeNoteRepository = FakeNoteRepository()
userPreference = FakeUserPreference()
homeNoteViewModel = HomeNoteViewModel(fakeNoteRepository, userPreference)

}

@Test
fun toggleNoteLayout_ListLayout_ShouldReturnGridLayout() {
val actual = homeNoteViewModel.noteItemLayout.getOrAwaitValue()
assertThat(actual).isNotEqualTo(NoteLayout.GRID.name)
}

}

Step: 3

Assert the values that we expect to be happen and actual livedata returned value.

As you can see variable actual should return NoteLayout.LIST.name We expect when NoteLayout is List it should return Grid so we use isNotEqualTo for this.

Run the test by clicking Run icon near toggleNoteLayout_ListLayout_ShouldReturnGridLayout function.

Oops.. our test will failed with a expection: Module with the Main dispatcher had failed to initialize. For tests Dispatchers.setMain from kotlinx-coroutines-test module can be used.

Test will failed

This is because, As i previously mention when testing coroutines we should execute it synchronously by default coroutines execute asynchronously kotlinx-coroutines provides special dispatcher TestDispatcher.

Lastly create a MainCoroutineRule.kt file and add this code.

MainCoroutineRule is a JUnit rule it helps to execute coroutine task synchronously and immediately by using this class whenever we use coroutine scope Just add this junit rule.

@ExperimentalCoroutinesApi

@get:Rule val mainCoroutineRule = MainCoroutineRule() to your test class

Finally our HomeNoteViewModelTest class code looks like this.

package com.mubarak.madexample.ui.note

import androidx.arch.core.executor.testing.InstantTaskExecutorRule
import com.google.common.truth.Truth.assertThat
import com.mubarak.madexample.MainCoroutineRule
import com.mubarak.madexample.data.repository.FakeNoteRepository
import com.mubarak.madexample.data.sources.datastore.FakeUserPreference
import com.mubarak.madexample.getOrAwaitValue
import com.mubarak.madexample.utils.NoteLayout
import kotlinx.coroutines.ExperimentalCoroutinesApi
import org.junit.Before
import org.junit.Rule
import org.junit.Test

class HomeNoteViewModelTest {

private lateinit var homeNoteViewModel: HomeNoteViewModel
private lateinit var fakeNoteRepository: FakeNoteRepository
private lateinit var userPreference: FakeUserPreference

@ExperimentalCoroutinesApi
@get:Rule
val mainCoroutineRule = MainCoroutineRule() // don't forgot to add this JUnit rule just we create now

@get:Rule
var taskExecutorRule = InstantTaskExecutorRule()

@Before
fun setUp() {
fakeNoteRepository = FakeNoteRepository()
userPreference = FakeUserPreference()
homeNoteViewModel = HomeNoteViewModel(fakeNoteRepository, userPreference)

}

@Test
fun toggleNoteLayout_ListLayout_ShouldReturnGridLayout() {
val actual = homeNoteViewModel.noteItemLayout.getOrAwaitValue()
assertThat(actual).isNotEqualTo(NoteLayout.GRID.name)
}

}

And our test will passed !

Test will passed!

Let’s write another test that include testing a suspend function

Let’s write another test case for redoNoteToActive method that verifies its correctly convert archive note the active note.

Same as before name the test case as i mention above in this case redoNoteToActive_NoteStatus_Archive_ShouldConvertArchiveToActive this is the name of our test case.

redoNoteToActive is a suspend function we all know suspend function only called in coroutine scope or another suspend function.

Which scope we need to use for testing, Use runTest this coroutine scope specialy made for testing coroutines and it also skip the delays.

@Test
fun redoNoteToActive_NoteStatus_Archive_ShouldConvertArchiveToActive() = runTest {
val note = Note(
1,
"Title",
"Description",
NoteStatus.ARCHIVE
)
fakeNoteRepository.insertNote(note)

homeNoteViewModel.redoNoteToActive(noteId = note.id) // this function converts the archive note into active
val expected = fakeNoteRepository.getNoteById(note.id)
val actual = Note(
1,
"Title",
"Description",
NoteStatus.ACTIVE
)
assertThat(actual).isEqualTo(expected)
}

Run this test case hopefully it should pass.

Test will passed!

Follow the same steps as before, to test other functions in HomeNoteViewModel.

Here is the full test cases i added to HomeNoteViewModelTest.

That’s all for this article I hope you will find this article helpful

Signing off, Mubarak Native

--

--

Mubarak Native

I am Mubarak "Native Android Developer" Focus on Clean and Minimal Code (simply love to write clean code)