Unit testing a fragment with View Model in Android

What does Unit testing a fragment mean?

Tanay Tandon
6 min readDec 4, 2022

Unit testing means verifying a particular atomic piece of logic is working as it is supposed to work. Functions are the most common atomic logic pieces.
A fragment is responsible for displaying data using Android Framework level classes such as EditText, TextView, ImageView, etc.
Therefore unit testing a fragment means verifying that the fragment displays the data as intended.

In this post we’ll see how we can unit test a fragment which has three states:

  1. Loading: shown when view model is fetching the required data.
  2. Success: shown when data is successfully returned from the view model.
  3. Error: shown when there is an error fetching the data from the view model.

Sealed class representing the above three states

sealed class DemoDataStatus {
object Loading : DemoDataStatus()
data class Success(val data: DemoData) : DemoDataStatus()
data class Error(val msg: String) : DemoDataStatus()
}

data class DemoData(val title: String, val description: String)

app/build.gradle

plugins {
id 'com.android.application'
id 'org.jetbrains.kotlin.android'
id 'kotlin-kapt'
}

android {
namespace 'com.example.sampleuitesting'
compileSdk 33

defaultConfig {
applicationId "com.example.sampleuitesting"
minSdk 21
targetSdk 33
versionCode 1
versionName "1.0"

testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
}

buildTypes {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = '1.8'
}
buildFeatures {
viewBinding true
}

packagingOptions {
exclude 'META-INF/DEPENDENCIES'
exclude 'META-INF/LICENSE'
exclude 'META-INF/LICENSE.txt'
exclude 'META-INF/license.txt'
exclude 'META-INF/LICENSE.md'
exclude 'META-INF/LICENSE-notice.md'
exclude 'META-INF/NOTICE'
exclude 'META-INF/NOTICE.txt'
exclude 'META-INF/notice.txt'
exclude 'META-INF/ASL2.0'
exclude 'META-INF/AL2.0'
exclude 'META-INF/LGPL2.1'
exclude("META-INF/*.kotlin_module")
}

}

dependencies {

implementation 'androidx.core:core-ktx:1.9.0'
implementation 'androidx.appcompat:appcompat:1.5.1'
implementation 'com.google.android.material:material:1.7.0'
implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
testImplementation 'junit:junit:4.13.2'

debugImplementation "androidx.test:core:1.5.0"
// Kotlin extensions for androidx.test.core
androidTestImplementation "androidx.test:core-ktx:1.5.0"
// To use the androidx.test.runner APIs
androidTestImplementation "androidx.test:runner:1.5.1"

androidTestImplementation 'androidx.test.ext:junit:1.1.4'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.0'
androidTestImplementation "androidx.test.espresso:espresso-intents:3.5.0"

debugImplementation "androidx.fragment:fragment-testing:1.5.4"

def nav_version = "2.5.3"
// Kotlin
implementation "androidx.navigation:navigation-fragment-ktx:$nav_version"
implementation "androidx.navigation:navigation-ui-ktx:$nav_version"

// Testing Navigation
androidTestImplementation "androidx.navigation:navigation-testing:$nav_version"

// dagger
def dagger_version = "2.44.2"
implementation "com.google.dagger:dagger:$dagger_version"
annotationProcessor "com.google.dagger:dagger-compiler:$dagger_version"

// viewmodel, lifecycle
def lifecycle_version = "2.5.1"
implementation "androidx.lifecycle:lifecycle-runtime-ktx:$lifecycle_version"
implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:$lifecycle_version"
implementation "androidx.lifecycle:lifecycle-livedata-ktx:$lifecycle_version"

// optional - Test helpers for LiveData
def arch_version = "2.1.0"
testImplementation "androidx.arch.core:core-testing:$arch_version"

// optional - Test helpers for Lifecycle runtime
testImplementation "androidx.lifecycle:lifecycle-runtime-testing:$lifecycle_version"

androidTestImplementation "androidx.arch.core:core-testing:2.1.0"
androidTestImplementation "io.mockk:mockk-android:1.13.3"
}

The corresponding layout file

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent">

<ProgressBar
android:id="@+id/pbDemo"
style="?android:progressBarStyleLarge"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />

<TextView
android:id="@+id/tvTitle"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_margin="32dp"
android:textColor="@color/black"
android:textSize="16sp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />

<TextView
android:id="@+id/tvDescription"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="32dp"
app:layout_constraintEnd_toEndOf="@id/tvTitle"
app:layout_constraintStart_toStartOf="@id/tvTitle"
app:layout_constraintTop_toBottomOf="@id/tvTitle" />

<androidx.constraintlayout.widget.Group
android:id="@+id/grpSuccess"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:constraint_referenced_ids="tvTitle,tvDescription" />

<TextView
android:id="@+id/tvError"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_margin="32dp"
android:textColor="@android:color/holo_red_dark"
android:textSize="16sp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />

<Button
android:id="@+id/btnRetry"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="32dp"
android:text="@string/retry"
app:layout_constraintEnd_toEndOf="@id/tvError"
app:layout_constraintStart_toStartOf="@id/tvError"
app:layout_constraintTop_toBottomOf="@id/tvError" />

<androidx.constraintlayout.widget.Group
android:id="@+id/grpError"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:constraint_referenced_ids="btnRetry,tvError" />

</androidx.constraintlayout.widget.ConstraintLayout>

The view model contract that is consumed by the fragment

open class DemoViewModel : ViewModel() {
fun fetchInfo(): Flow<DemoDataStatus> {
return flow{
// insert business logic to fetch data and emit corresponding DemoDataStatus classes
}
}
}

The fragment class

class DemoFragment : Fragment() {

private var _binding: FragmentDemoBinding? = null
private val mBinding get() = _binding!!

private val mViewModel: DemoViewModel by lazy {
ViewModelProvider(requireActivity())[DemoViewModel::class.java]
}

override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
_binding = FragmentDemoBinding.inflate(inflater)
return mBinding.root
}

override fun onDestroyView() {
super.onDestroyView()
_binding = null
}

override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
fetchInfo()
mBinding.btnRetry.setOnClickListener {
fetchInfo()
}
}

private fun fetchInfo() {
viewLifecycleOwner.lifecycleScope.launch {
mViewModel.fetchInfo().collect {
when (it) {
is DemoDataStatus.Loading -> {
// show loading views and hide success and error views
}
is DemoDataStatus.Success -> {
// show success view and hide loading and error views
}
is DemoDataStatus.Error -> {
// show error view and hide success and loading views
}
else -> {}
}
}
}
}

}

What do we unit test?

  1. When fetchInfo returns DemoDataStatus.Loading loading view must be visible whereas success and failure views must be hidden.
  2. When fetchInfo returns DemoDataStatus.Success success view must be visible, the value of text of tvTitle must be DemoDataStatus.Success.data.title, the value of text of tvDescription must be DemoDataStatus.Success.data.description, and the error and loading views must be hidden.
  3. When fetchInfo returns DemoDataStatus.Error error view must be visible and success and loading views must be hidden. The value of tvError must be DemoDataStatus.Error.msg
  4. When the fragment view is created then it must request the view model for data by calling fetchInfo. Clicking on btnRetry should result in the fragment calling fetchInfo again.

Note:
loading view -> pbDemo
success view -> tvTitle, tvDescription
error view -> tvError, btnRetry

The class with the unit tests for the above four cases

@RunWith(AndroidJUnit4::class)
class DemoUITest {

lateinit var mScenario: FragmentScenario<DemoFragment>

private val mMutableStateFlow: MutableStateFlow<DemoDataStatus> by lazy {
MutableStateFlow(DemoDataStatus.Loading)
}

private val mViewModel: DemoViewModel by lazy {
spyk()
}

@Before
fun setup() {
every { mViewModel.fetchInfo() } returns mMutableStateFlow
mScenario =
launchFragmentInContainer(themeResId = R.style.Theme_SampleUITesting)
}

@Test
fun verifyLoadingState() {
mScenario.moveToState(Lifecycle.State.STARTED)
mMutableStateFlow.value = DemoDataStatus.Loading
isViewVisible(R.id.pbDemo)
isViewNotVisible(R.id.tvError)
isViewNotVisible(R.id.tvDescription)
isViewNotVisible(R.id.tvTitle)
isViewNotVisible(R.id.btnRetry)
mScenario.moveToState(Lifecycle.State.DESTROYED)
}

@Test
fun verifySuccessState() {
mScenario.moveToState(Lifecycle.State.STARTED)
val demo = DemoData(UUID.randomUUID().toString(), UUID.randomUUID().toString())
mMutableStateFlow.value = DemoDataStatus.Success(demo)
isViewNotVisible(R.id.pbDemo)
isViewVisible(R.id.tvDescription)
isViewVisible(R.id.tvTitle)
isViewNotVisible(R.id.tvError)
isViewNotVisible(R.id.btnRetry)
verifyText(demo.title, R.id.tvTitle)
verifyText(demo.description, R.id.tvDescription)
mScenario.moveToState(Lifecycle.State.DESTROYED)
}

@Test
fun verifyErrorState() {
mScenario.moveToState(Lifecycle.State.STARTED)
val randomErr = UUID.randomUUID().toString()
mMutableStateFlow.value = DemoDataStatus.Error(randomErr)
isViewNotVisible(R.id.pbDemo)
isViewNotVisible(R.id.tvTitle)
isViewNotVisible(R.id.tvDescription)
verifyText(randomErr, R.id.tvError)
isViewVisible(R.id.tvError)
isViewVisible(R.id.btnRetry)
mScenario.moveToState(Lifecycle.State.DESTROYED)
}

@Test
fun verifyFetchInfoStatus() {
mScenario.moveToState(Lifecycle.State.STARTED)
verify(atMost = 1, atLeast = 1) { mViewModel.fetchInfo() }
mMutableStateFlow.value = DemoDataStatus.Error(UUID.randomUUID().toString())
performClick(R.id.btnRetry)
verify(atMost = 2, atLeast = 2) { mViewModel.fetchInfo() }
mScenario.moveToState(Lifecycle.State.DESTROYED)
}
}

The methods isViewNotVisible, isViewVisible, verifyText, and performClick are wrapper methods to avoid boilerplate Espresso code. They can be found here

If you run DemoUIUnit test, all the four unit tests will fail as they should because fetchInfo method inside the DemoFragment does not do anything.
Update the logic inside DemoFragment

    private fun fetchInfo() {
viewLifecycleOwner.lifecycleScope.launch {
mViewModel.fetchInfo().collect {
when (it) {
is DemoDataStatus.Loading -> {
mBinding.pbDemo.visibility = View.VISIBLE
mBinding.grpSuccess.visibility = View.GONE
mBinding.grpError.visibility = View.GONE
}
is DemoDataStatus.Success -> {
mBinding.tvTitle.text = it.data.title
mBinding.tvDescription.text = it.data.description
mBinding.grpSuccess.visibility = View.VISIBLE
mBinding.pbDemo.visibility = View.GONE
mBinding.grpError.visibility = View.GONE
}
is DemoDataStatus.Error -> {
mBinding.pbDemo.visibility = View.GONE
mBinding.grpSuccess.visibility = View.GONE
mBinding.tvError.text = it.msg
mBinding.grpError.visibility = View.VISIBLE
}
else -> {}
}
}
}
}

Run the class DemoUITest again, the test will still fail.

Why do the tests fail?

For our tests to run mViewModel in DemoUITest must be used as the View Model by DemoFragment, but DemoFragment has no knowledge of this and on each launch a new instance of DemoViewModel is created.

How do we make the DemoFragment use mViewModel defined in DemoUITest when the tests are running?

We do so by using inheritance and ViewModelProvider.Factory.

  1. Convert DemoViewModel from an open class to an abstract class.
  2. Create a class DemoViewModelImpl which extends the DemoViewModel class and implement the fetchInfo method.
  3. Add a Factory class inside DemoViewModel. The factory class must extend ViewModelProvider.Factory and implement the onCreate method.
  4. Update the DemoFragment to use DemoViewModel.Factory when creating an instance of DemoViewModel.

DemoViewModel file will now look like

abstract class DemoViewModel : ViewModel() {
abstract fun fetchInfo(): Flow<DemoDataStatus>

class Factory() : ViewModelProvider.NewInstanceFactory() {

override fun <T : ViewModel> create(modelClass: Class<T>): T {
return if (modelClass.isAssignableFrom(DemoViewModel::class.java)) {
DemoViewModelImpl()
} else super.create(modelClass)
}
}
}

class DemoViewModelImpl() : DemoViewModel() {
override fun fetchInfo(): Flow<DemoDataStatus> {
return flow {
emit(DemoDataStatus.Loading)
delay(2000L)
val isSuccess = ((Math.random() * 100).toInt() % 2) == 0
emit(
if (isSuccess) DemoDataStatus.Success(
DemoData("sample title", "sample description")
) else DemoDataStatus.Error("there was a failure")
)
}
}

}

In DemoFragment replace

private val mViewModel: DemoViewModel by lazy {
ViewModelProvider(requireActivity())[DemoViewModel::class.java]
}

with

private val mFactory by lazy {
DemoViewModel.Factory()
}

private val mViewModel: DemoViewModel by lazy {
ViewModelProvider(requireActivity(), mFactory)[DemoViewModel::class.java]
}

This still does not solve the problem of DemoFragment using mViewModel defined in DemoUITest instead of creating a new instance of DemoViewModelImpl.

To do so

  1. Add a public static nullable member INSTANCE of type DemoViewModel to the class DemoViewModel.Factory via the companion object.
  2. Update the definition of the onCreate method in the Factory class to return INSTANCE if it is not null. If INSTANCE is null then create an instance of DemoViewModelImpl.
  3. In the setup method of DemoUITest set INSTANCE to mViewModel.

Updated DemoViewModel.Factory class

class Factory() : ViewModelProvider.NewInstanceFactory() {
companion object {
var INSTANCE: DemoViewModel? = null
}

override fun <T : ViewModel> create(modelClass: Class<T>): T {
return if (modelClass.isAssignableFrom(DemoViewModel::class.java)) {
(INSTANCE ?: DemoViewModelImpl()) as T
} else super.create(modelClass)
}
}

Updated setup method of DemoUITest

@Before
fun setup() {
DemoViewModel.Factory.INSTANCE = mViewModel
every { mViewModel.fetchInfo() } returns mMutableStateFlow
mScenario =
launchFragmentInContainer(themeResId = R.style.Theme_SampleUITesting)
}

All four of the unit tests should now run and pass successfully.

Github link

Downsides of this approach:

  1. Need to create an abstract class of each view model used in the fragments we want to test.
  2. Need to define a Factory method for each view model.

Thanks for reading. Please leave a comment below in case of any queries. My LinkedIn profile

--

--