How to write unit test of app based on Android Architecture Components and Coroutine

Kai Xie
Kai Xie
May 5 · 9 min read

Unit tests are the fundamental tests in your app testing strategy. By creating and running unit tests against your code, you can easily verify that the logic of individual units is correct. Running unit tests after every build helps you to quickly catch and fix software regressions introduced by code changes to your app.

A unit test generally exercises the functionality of the smallest possible unit of code (which could be a method, class, or component) in a repeatable way. You should build unit tests when you need to verify the logic of a specific part of the code in your app. For example, if you are unit testing a class, your test might check that the class is in the right state. Typically, the unit of code is tested in isolation; your test affects and monitors changes to that unit only. You can use dependency providers like Robolectric or a mocking framework to isolate your unit from its dependencies.

https://developer.android.com/training/testing/unit-testing

As said in Google’s document, unit test in an important part of software development to guarantee the quality of delivery. So I will introduce how to write unit test to Android App. We would use JUnit, Spek and Mockk in this tutorial.

Prerequisite

In this tutorial, we will address the unit test of the app built according to the https://medium.com/nerd-for-tech/android-architecture-components-tutorial-7e822cdf2f00. So let us build this app according to the above tutorial.

After the app is built successfully, let us check the unit test under com.ovlesser.pexels(test) package, you would find there is one file named ExampleUnitTest, which is generated by the template. We would leave it and create our own unit tests.

The first thing we need to do is to install the Spek / Spek Framework plugins in the Android Studio.

Launch the Android Studio and open Android Studio / Preferences / Plugins from menu. And then search Spek, and install Spek AND Spek Framework. You might need to restart Android Studio after the installation.

Open build.gradle(Module: Pexels.app) and add the following dependencies into dependencies block and Sync Now.

// Unit testing
testImplementation 'org.jetbrains.kotlin:kotlin-reflect:1.4.10'
testImplementation "org.spekframework.spek2:spek-dsl-jvm:2.0.9"
testImplementation "org.spekframework.spek2:spek-runner-junit5:2.0.9"
testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.4.3'
testImplementation "org.assertj:assertj-core:3.15.0"
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version"
// Mocking for Kotlin
testImplementation "io.mockk:mockk:1.10.0"
testImplementation "io.mockk:mockk-agent-jvm:1.11.0"

After adding the dependencies, let us create several new packages, com.ovlesser.pexels.ui.home and com.ovlesser.pexels.ui.detail under com.ovlesser.pexels(test) package. And we also need to create several unit test classes, HomeViewModelTest, under com.ovlesser.pexels.ui.home package, for HomeViewModel, and DetailViewModeTest, under com.ovlesser.pexels.ui.detail package, for DetailViewModel.

Why we would create two test classes only? That’s because according to the MVVM pattern, the app includes 3 parts, Model (M), View (V) and ViewModel (VM). but the Model is only data, no methods need to be tested, and View is UI-related and tricky to be tested with unit test. So we would focus on ViewModel part only when writing unit tests. That’s why we only need two test classes to cover HomeViewModel class and DetailViewModel class.

Ok, let write the unit test for DetailViewModel first because this class is simpler than HomeViewModel. And we would add the unit test for the construction of the DetailViewModel class only.

Let us open the DetailViewModelTest.kt, and change the DetailViewModelTest class as followed:

class DetailViewModelTest: Spek({

}
)

This is the skeleton of a test class with Spek.

And we need to add the following mock object of Application class in this class because the DetailViewModel class has a parameter of application object

val mockApplication = mockk<Application>() {
every { applicationContext } returns mockk()
}

and instantiate a Photo object, as the other parameter of DetailViewModel class as followed:

val photo = Data.Photo(id = 123, width = 200, height = 120)

and declare a variable, detailViewModel, as the test object as shown:

lateinit var detailViewModel: DetailViewModel

Be careful, we just declared it without instantiating it because there is some LiveData update inside the constructor / init block, which would trigger the following error due to such LiveData update cannot be executed in Main thread:

Method getMainLooper in android.os.Looper not mocked. See http://g.co/androidstudio/not-mocked for details.

We will fix it with the following method.

beforeGroup {
ArchTaskExecutor.getInstance().setDelegate(object : TaskExecutor() {
override fun executeOnDiskIO(runnable: Runnable) {
runnable.run()
}

override fun isMainThread(): Boolean {
return true
}

override fun postToMainThread(runnable: Runnable) {
runnable.run()
}
})
detailViewModel = spyk(DetailViewModel(photo, mockApplication))
}

As shown, we overrode the ArchTaskExecutor in the beforeGroup block, which would be running before the real test code runs. This piece of code make sure we can run the LiveData update without error.

And we also instantiated the detailViewModel object in the beforeGroup block. It would be created successfully because we have overridden the ArchTaskExecutor.

And the spyk is a keyword of Mockk, with which the detailViewModel object can include both real properties and mocked properties.

And then let us write test cases as shown below:

describe("init") {
it("should init successfully") {
assertThat(detailViewModel.photo.value).isEqualTo(photo)
}

it("should return correct size") {
detailViewModel.size.observeForever {
assertThat(detailViewModel.size.value).isEqualTo("${photo.width} * ${photo.height}")
}
detailViewModel.size.removeObserver {}
}
}

As shown, we have two test cases to test the initialization of the DetailViewModel class: verifying the LiveData property detailViewModel.photo is set successfully, and verifying another LiveData property detailViewModel.size would return the correct value.

Be careful, we need to observe the LiveData property detailViewModel.size with LiveData.observeForever, which would trigger the lambda function once the value of the LiveData changed.

Now let us run the unit test by clicking the small green triangle before DetailViewModelTest class and we would find two tests passed.

This is a very simple case of unit test with Spek / Mockk. And we would add more complicated unit test for HomeViewModel class.

Let us open HomeViewModelTest.kt, which should be in com.ovlesser.pexels(test).ui.home package and write the skeleton as followed:

class HomeViewModelTest: Spek({
val mockApplication = mockk<Application>() {
every { applicationContext } returns mockk()
}
lateinit var homeViewModel: HomeViewModel

beforeGroup {
ArchTaskExecutor.getInstance().setDelegate(object : TaskExecutor() {
override fun executeOnDiskIO(runnable: Runnable) {
runnable.run()
}

override fun isMainThread(): Boolean {
return true
}

override fun postToMainThread(runnable: Runnable) {
runnable.run()
}
})
homeViewModel = spyk(HomeViewModel(mockApplication))
}
}
)

As shown, it is pretty similar as the DetailViewModelTest class.

And let us add the first test case as following:

describe("init") {
it("should init successfully") {
homeViewModel.data.observeForever {
Assertions.assertThat(homeViewModel.data.value).isEqualTo()
}
}
}

We would find the first problem, what is the expected result of homeViewModel.data.value? We don’t know that because this value is from pexelsPhotoRepository and from database.pexelsPhotoDao.getPhotos(). It is from the database and depends on the data saved in the database. And it is worse that the data in the database might change because we are sending an HTTP request to fetch new data from back-end service, and the new data might be inserted into the database once the request comes back successfully.

So we would find the database and the network service are the dependencies of the HomeViewModel class, and the data inside the HomeViewModel class relies on its dependencies. That breaks the isolation principle of the unit test. So we need to mock all these dependencies and make sure the mocked dependencies would give the value we expected.

So let us check the implementation of HomeViewModel class, we would find the instance of network service, PexelApi.retrofitService, is inside the PexelsPhotoRepository class, and the PexelsPhotoRepository instance is inside the HomeViewModel class, and the database object is also instantiated in the HomeViewModel class, and passed into PexelsPhotoRepository instance. They are totally coupled and difficult to isolate. So the first thing we need to is to modify these classes according to the principle of Dependency Injection (DI).

We would change the signature of HomeViewModel class to make its dependencies can be injected because we need different dependencies when the real app runs and when the unit test runs. So we need to change the signature of HomeViewModel class as following:

class HomeViewModel(apiService: PexelsApiService? = null, dao: PexelsPhotoDao? = null, application: Application) : AndroidViewModel(application)

As you see, we’ve introduced two extra optional parameters, apiService and dao in the constructor of HomeViewModel for dependency injection. But we set them as optional because we don’t want to inject them when the real app runs. I would explain it later.

Next, we need to change the instantiation of pexelsPhotoRepository to

private val pexelsPhotoRepository = PexelsPhotoRepository(apiService = apiService, dao = dao ?: getDatabase(application).pexelsPhotoDao)

pass the apiService and dao into the repository. Be careful, we are using the real database the parameter of HomeViewModel, dao, is null, which is the case of real app runs.

And we also need to change the create method of the inner class HomeViewModel.Fatory from

return HomeViewModel(app) as T

to

return HomeViewModel(application = app) as T

because we change the signature of the constructor of the HomeViewModel class.

And then we would notice the error at the creation of the pexelsPhotoRepository object because there are no new parameters in the constructor of PexelsPhotoRepository class. Let us add them by changing the constructor of this class as following:

class PexelsPhotoRepository(
private val apiService: PexelsApiService? = PexelApi.retrofitService,
private val dao: PexelsPhotoDao) {

Same as above, we would use the real PexelApi.retrofitService if the parameter apiService is null, which means the real app is running.

And we also need to change all database.pexelsPhotoDao into dao in this repository class to make sure we are using the dao passed in.

Ok, we have made the changes based on the principle of Dependency Injection. Let us run the app first to make sure we didn’t screw anything up.

Now we have been able to inject mocked ApiService and mocked dao when running the unit test. So let us implement the mocked dependencies.

Firstly, let us create a new file, TestingMocks, in com.ovlesser.pexels(test).ui.home package and create a MockApiService class as following:

class MockApiService(var result: Data): PexelsApiService {
override fun getData(keyword: String, pageIndex: Int, perPage: Int) = MockCall<Data>(result)

override suspend fun getDataCoroutine(keyword: String, pageIndex: Int, perPage: Int) = result
}

and its dependency, MockCall class as shown below:

class MockCall<T>(result: Data): Call<T> {
override fun clone(): Call<T> {
TODO("Not yet implemented")
}

override fun execute(): Response<T> {
TODO("Not yet implemented")
}

override fun enqueue(callback: Callback<T>) {
TODO("Not yet implemented")
}

override fun isExecuted(): Boolean {
TODO("Not yet implemented")
}

override fun cancel() {
TODO("Not yet implemented")
}

override fun isCanceled(): Boolean {
TODO("Not yet implemented")
}

override fun request(): Request {
TODO("Not yet implemented")
}

override fun timeout(): Timeout {
TODO("Not yet implemented")
}

}

As we see, we mocked the methods, getData, which would return the input value wrapped by MockCall, and getDataCoroutine, which would return the input value directly.

And we also need to create another mock class, MockDao, to mock the PexelsPhotoDao class as followed:

class MockDao( val data: Data): PexelsPhotoDao {
private val _photos = MutableLiveData<List<DatabasePexelsPhoto>>()

override fun getPhotos(): LiveData<List<DatabasePexelsPhoto>> {
_photos.value = data.photos.asDatabaseModel()
return _photos
}

override fun insertAll(photos: List<DatabasePexelsPhoto>) {
_photos.value = photos
}

override fun clearAll() {
_photos.value = emptyList()
}
}

We overrode the methods of PexelsPhotoDao class as well.

Now we have had mock class for ApiService and dao, let us go back to HomeViewModelTest class and add the following code

val data = Data( photos = listOf(Data.Photo(id = 111), Data.Photo(id = 222), Data.Photo(id = 333)), totalResults = 3)
val mockApiService = MockApiService(data)
val mockDao = MockDao(data)

In this piece of code, we created two mocked object, mockApiService and mockDao with some data, so these mocked objects would mock the returns with this data. And then we are able to verify them.

And then, we need to inject these mocked objects into the homeViewModel object as shown:

homeViewModel = spyk(HomeViewModel(apiService = mockApiService, dao = mockDao, application = mockApplication))

Now, the homeViewModel is not rely on the real network service or the real database. And then we can change the assertion of the test case init into

Assertions.assertThat(homeViewModel.data.value).isEqualTo(data)

Let us run this test case, and you would find it passed.

Now Let’s add more test cases to test other public methods of HomeViewModel class as followed:

describe("#${HomeViewModel::refreshRepository.name}") {
it("should return") {
homeViewModel.refreshRepository("")
Assertions.assertThat(homeViewModel.status.value).isEqualTo(PexelsApiStatus.DONE)
}
}

describe("#${HomeViewModel::displayPhotoDetails.name}") {
it("should return") {
val photo = Data.Photo(id = 123)
homeViewModel.displayPhotoDetails(photo)
Assertions.assertThat(homeViewModel.selectedPhoto.value).isEqualTo(photo)
}
}

describe("#${HomeViewModel::displayPhotoDetailComplete.name}") {
it("should return null") {
homeViewModel.displayPhotoDetailComplete()
Assertions.assertThat(homeViewModel.selectedPhoto.value).isNull()
}
}

And let’s click the green triangle in front of HomeViewModelTest class and we would see 4 tests passed.

So far, we have added unit test for DetailViewModel and HomeViewModel.

P.S. There is another issue we need to do if we want to test such methods with coroutine as followed:

We need to add the following annotations above the HomeViewModelTest class declaration:

@ObsoleteCoroutinesApi
@ExperimentalCoroutinesApi

and create a new newSingleThreadContext instance as followed:

val mainThreadSurrogate = newSingleThreadContext("UI thread")

and dispatch this thread context in beforeGroup block as shown:

Dispatchers.setMain(mainThreadSurrogate)

and add reset all context in the afterGroup as followed:

afterGroup {
ArchTaskExecutor.getInstance().setDelegate(null)
Dispatchers.resetMain()
mainThreadSurrogate.close()
}

Instead of cover all code with the unit test, this tutorial just gives some examples about how to write the unit test and how to mock the dependencies with Spek and Mockk.

Thanks for browsing my tutorial and any feedbacks are welcome.

Nerd For Tech

From Confusion to Clarification

Kai Xie

Written by

Kai Xie

Senior Software Engineer

Nerd For Tech

NFT is an Educational Media House. Our mission is to bring the invaluable knowledge and experiences of experts from all over the world to the novice. To know more about us, visit https://www.nerdfortech.org/. Don’t forget to check out Ask-NFT, a mentorship ecosystem we’ve started

Kai Xie

Written by

Kai Xie

Senior Software Engineer

Nerd For Tech

NFT is an Educational Media House. Our mission is to bring the invaluable knowledge and experiences of experts from all over the world to the novice. To know more about us, visit https://www.nerdfortech.org/. Don’t forget to check out Ask-NFT, a mentorship ecosystem we’ve started

Medium is an open platform where 170 million readers come to find insightful and dynamic thinking. Here, expert and undiscovered voices alike dive into the heart of any topic and bring new ideas to the surface. Learn more

Follow the writers, publications, and topics that matter to you, and you’ll see them on your homepage and in your inbox. Explore

If you have a story to tell, knowledge to share, or a perspective to offer — welcome home. It’s easy and free to post your thinking on any topic. Write on Medium

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store