18

I've built a Splash Screen using Android Architecture Components and Reactive approach. I return from Preferences LiveData object fun isFirstLaunchLD(): SharedPreferencesLiveData<Boolean>. I have ViewModel that passes LiveData to the view and updates Preferences

val isFirstLaunch = Transformations.map(preferences.isFirstLaunchLD()) { isFirstLaunch ->
    if (isFirstLaunch) {
        preferences.isFirstLaunch = false
    }
    isFirstLaunch
}

In my Fragment, I observe LiveData from ViewModel

    viewModel.isFirstLaunch.observe(this, Observer { isFirstLaunch ->
        if (isFirstLaunch) {
            animationView.playAnimation()
        } else {
            navigateNext()
        }
    })

I would like to test my ViewModel now to see if isFirstLaunch is updated properly. How can I test it? Have I separated all layers correctly? What kind of tests would you write on this sample code?

Mycoola
  • 1,135
  • 1
  • 8
  • 29
jakub
  • 3,576
  • 3
  • 29
  • 55

2 Answers2

24

Have I separated all layers correctly?

The layers seem reasonably separated. The logic is in the ViewModel and you're not referring to storing Android Views/Fragments/Activities in the ViewModel.

What kind of tests would you write on this sample code?

When testing your ViewModel you can write instrumentation or pure unit tests on this code. For unit testing, you might need to figure out how to make a test double for preferences, so that you can focus on the isFirstLaunch/map behavior. An easy way to do that is passing a fake preference test double into the ViewModel.

How can I test it?

I wrote a little blurb on testing LiveData Transformations, read on!

Testing LiveData Transformations

Tl;DR You can test LiveData transformation, you just need to make sure the result LiveData of the Transformation is observed.

Fact 1: LiveData doesn't emit data if it's not observed. LiveData's "lifecycle awareness" is all about avoiding extra work. LiveData knows what lifecycle state it's observers (usually Activities/Fragments) are in. This allows LiveData to know if it's being observed by anything actually on-screen. If LiveData aren't observed or if their observers are off-screen, the observers are not triggered (an observer's onChanged method isn't called). This is useful because it keeps you from doing extra work "updating/displaying" an off-screen Fragment, for example.

Fact 2: LiveData generated by Transformations must be observed for the transformation to trigger. For Transformation to be triggered, the result LiveData (in this case, isFirstLaunch) must be observed. Again, without observation, the LiveData observers aren't triggered, and neither are the transformations.

When you're unit testing a ViewModel, you shouldn't have or need access to a Fragment/Activity. If you can't set up an observer the normal way, how do you unit test?

Fact 3: In your tests, you don't need a LifecycleOwner to observe LiveData, you can use observeForever You do not need a lifecycle observer to be able to test LiveData. This is confusing because generally outside of tests (ie in your production code), you'll use a LifecycleObserver like an Activity or Fragment.

In tests you can use the LiveData method observeForever() to observer without a lifecycle owner. This observer is "always" observing and doesn't have a concept of on/off screen since there's no LifecycleOwner. You must therefore manually remove the observer using removeObserver(observer).

Putting this all together, you can use observeForever to test your Transformations code:

class ViewModelTest {

    // Executes each task synchronously using Architecture Components.
    // For tests and required for LiveData to function deterministically!
    @get:Rule
    val rule = InstantTaskExecutorRule()


    @Test
    fun isFirstLaunchTest() {

        // Create observer - no need for it to do anything!
        val observer = Observer<Boolean> {}

        try {
            // Sets up the state you're testing for in the VM
            // This affects the INPUT LiveData of the transformation
            viewModel.someMethodThatAffectsFirstLaunchLiveData()

            // Observe the OUTPUT LiveData forever
            // Even though the observer itself doesn't do anything
            // it ensures any map functions needed to calculate
            // isFirstLaunch will be run.
            viewModel.isFirstLaunch.observeForever(observer)

            assertEquals(viewModel.isFirstLaunch.value, true)
        } finally {
            // Whatever happens, don't forget to remove the observer!
            viewModel.isFirstLaunch.removeObserver(observer)
        }
    }

}

A few notes:

  • You need to use InstantTaskExecutorRule() to get your LiveData updates to execute synchronously. You'll need the androidx.arch.core:core-testing:<current-version> to use this rule.
  • While you'll often see observeForever in test code, it also sometimes makes its way into production code. Just keep in mind that when you're using observeForever in production code, you lose the benefits of lifecycle awareness. You must also make sure not to forget to remove the observer!

Finally, if you're writing a lot of these tests, the try, observe-catch-remove-code can get tedious. If you're using Kotlin, you can make an extension function that will simplify the code and avoid the possibility of forgetting to remove the observer. There are two options:

Option 1

/**
 * Observes a [LiveData] until the `block` is done executing.
 */
fun <T> LiveData<T>.observeForTesting(block: () -> Unit) {
    val observer = Observer<T> { }
    try {
        observeForever(observer)
        block()
    } finally {
        removeObserver(observer)
    }
}

Which would make the test look like:

class ViewModelTest {

    @get:Rule
    val rule = InstantTaskExecutorRule()


    @Test
    fun isFirstLaunchTest() {

        viewModel.someMethodThatAffectsFirstLaunchLiveData()

        // observeForTesting using the OUTPUT livedata
        viewModel.isFirstLaunch.observeForTesting {

            assertEquals(viewModel.isFirstLaunch.value, true)

        }
    }

}

Option 2

@VisibleForTesting(otherwise = VisibleForTesting.NONE)
fun <T> LiveData<T>.getOrAwaitValue(
    time: Long = 2,
    timeUnit: TimeUnit = TimeUnit.SECONDS,
    afterObserve: () -> Unit = {}
): T {
    var data: T? = null
    val latch = CountDownLatch(1)
    val observer = object : Observer<T> {
        override fun onChanged(o: T?) {
            data = o
            latch.countDown()
            this@getOrAwaitValue.removeObserver(this)
        }
    }
    this.observeForever(observer)

    try {
        afterObserve.invoke()

        // Don't wait indefinitely if the LiveData is not set.
        if (!latch.await(time, timeUnit)) {
            throw TimeoutException("LiveData value was never set.")
        }

    } finally {
        this.removeObserver(observer)
    }

    @Suppress("UNCHECKED_CAST")
    return data as T
}

Which would make the test look like:

class ViewModelTest {

    @get:Rule
    val rule = InstantTaskExecutorRule()

    @Test
    fun isFirstLaunchTest() {

        viewModel.someMethodThatAffectsFirstLaunchLiveData()

        // getOrAwaitValue using the OUTPUT livedata        
        assertEquals(viewModel.isFirstLaunch.getOrAwaitValue(), true)

    }
}

These options were both taken from the reactive branch of Architecture Blueprints.

Lyla
  • 2,767
  • 1
  • 21
  • 23
  • 1
    Why the need to remove the observer from observe forever if you create a new ViewModel after each test? Shouldn't the GarbageCollector collect the observer once the viewmodel is collected? – Glo Sep 16 '20 at 17:23
  • Same question as above. – Sam Chen Aug 03 '21 at 13:28
1

It depends on what your SharedPreferencesLiveData does.

If the SharedPreferencesLiveData contains Android specific classes, you won't be able to test this correctly because JUnit won't have access to the Android specific classes.

The other issue is that to be able to observe LiveData, you need some kind of Lifecycle owner. (The this in the original post code.)

In the Unit test, the 'this' can simply be replaced with something like the following:

private fun lifecycle(): Lifecycle {
    val lifecycle = LifecycleRegistry(Mockito.mock(LifecycleOwner::class.java))
    lifecycle.handleLifecycleEvent(Lifecycle.Event.ON_RESUME)
    return lifecycle
}

And then used in the following way:

@RunWith(MockitoJUnitRunner::class)
class ViewModelTest {

    @Rule
    @JvmField
    val liveDataImmediateRule = InstantTaskExecutorRule()

    @Test
    fun viewModelShouldLoadAttributeForConsent() {
        var isLaunchedEvent: Boolean = False

        // Pseudo code - Create ViewModel

        viewModel.isFirstLaunch.observe(lifecycle(), Observer { isLaunchedEvent = it } )

        assertEquals(true, isLaunchedEvent)
    }

    private fun lifecycle(): Lifecycle {
        val lifecycle = LifecycleRegistry(Mockito.mock(LifecycleOwner::class.java))
        lifecycle.handleLifecycleEvent(Lifecycle.Event.ON_RESUME)
        return lifecycle
    }
}

Note: You have to have the Rule present so that the LiveData executes instantly instead of whenever it wants to.

Bam
  • 478
  • 6
  • 19