Exercises in futility: Unit-testing LiveData, ViewModels and Coroutines

Share
Exercises in futility: Unit-testing LiveData, ViewModels and Coroutines

This is part of a series head-scratching my way into coroutines. It can be read as a standalone although you might be missing out on some spicy memes here and here.

Testing ViewModels without losing the will to live

A ViewModel “sits” quite close to the activity/fragment. Something that a user would be looking at generally, even if they are unaware.

You would think that a professional keyboard user walking into your ViewModel test file would probably be able to read the test methods and make some sense of what’s going on at the screen level without even running your app, right? 🤡

Get some dependencies first

dependencies {
    // .. other dependencies
    // ..

    // testing dependencies
    testImplementation 'androidx.test.ext:junit:1.1.2-alpha03'
    testImplementation 'org.mockito:mockito-core:3.0.0'
    testImplementation 'com.nhaarman.mockitokotlin2:mockito-kotlin:2.1.0'
    testImplementation 'org.mockito:mockito-inline:3.0.0'
    testImplementation 'org.amshove.kluent:kluent:1.51'
    testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.3.2'
    testImplementation 'androidx.arch.core:core-testing:2.1.0'


}

build.gradle

Where we left off

Our little ViewModel looks like this:

class PostViewModel(private val redditRepository: RedditRepository) : ViewModel() {

    val stateData = MutableLiveData<State>()

    fun getRedditPost() {
        viewModelScope.launch {
            stateData.value = State.Loading
            val post: State.Post = redditRepository.getPost()
            stateData.value = post
        }
    }
}

sealed class State {
    object Loading : State()
    data class Post(val text: String) : State()
}

PostviewModel.kt

Most android apps out there are based on this simple premise.

The PostViewModel is responsible for fetching a post from Reddit while the calling activity/fragment is responsible for observing the stateData variable.

Googling “LiveData testing how to” (and its myriad of variations), you’ll find all sorts of smart extension functions (LiveDataTestUtil and others like it) that hide what’s going on and are not really that useful on a number of occasions.

A simple solution is to use a LifeCycleTestOwner helper class. (at the cost of adding a few extra lines of code that is)

class LifeCycleTestOwner : LifecycleOwner {

    private val registry = LifecycleRegistry(this)

    override fun getLifecycle(): Lifecycle {
        return registry
    }

    fun onCreate() {
        registry.handleLifecycleEvent(Lifecycle.Event.ON_CREATE)
    }

    fun onResume() {
        registry.handleLifecycleEvent(Lifecycle.Event.ON_RESUME)
    }

    fun onDestroy() {
        registry.handleLifecycleEvent(Lifecycle.Event.ON_DESTROY)
    }
}

LifeCycleTestOwner.kt

We’ll use this to replicate the presence of an activity or fragment in our test class.

The setup

@ExperimentalCoroutinesApi
class PostViewModelTest {
    @get:Rule
    val coroutineTestRule = CoroutineTestRule()
    @get:Rule
    var rule: TestRule = InstantTaskExecutorRule()

    private val stateObserver: Observer<State> = mock()
    private val redditRepository: RedditRepository = mock()

    private lateinit var lifeCycleTestOwner: LifeCycleTestOwner
    private lateinit var postViewModel: PostViewModel

    @Before
    fun setUp() {
        lifeCycleTestOwner = LifeCycleTestOwner()
        lifeCycleTestOwner.onCreate()
        postViewModel = PostViewModel(redditRepository)
        postViewModel.stateData.observe(lifeCycleTestOwner, stateObserver)
    }

    @After
    fun tearDown() {
        lifeCycleTestOwner.onDestroy()
    }
}

PostViewModelTest.kt

  • Using the CoroutineTestRule we have full control of running everything on the TestCoroutineDispatcher. You can find more on this rule here.
  • InstantTaskExecutorRule comes bundled in the androidx.arch.core:core-testing library and should be used when testing LiveData.
  • The stateObserver variable is just a mock. Think of it as the observer in the activity/fragment.
  • The lifeCycleTestOwner plays the role of the lifecycle of the activity/fragment and is created and destroyed before each test.

Testing?

Unit tests do have their place, although they can get a bit overboard. A rather sad test on this occasion really goes for isolation and tests every little thing that can happen separately.

@Test
    fun `a useless test for getRedditPost`() {
        coroutineTestRule.testDispatcher.runBlockingTest {
            // Given
            lifeCycleTestOwner.onResume()
            val redditPost = State.Post("This is a reddit post")
            When calling redditRepository.getPost() itReturns redditPost
            // When
            postViewModel.getRedditPost()
            // Then
            Verify on redditRepository that redditRepository.getPost() was called
            Verify on stateObserver that stateObserver.onChanged(State.Loading) was called
        }
    }

sad_test.kt

While this test will pass and it does verify a certain behavior, we can do better and provide some sort of documentation and high level view of the ViewModel to a stranger who doesn’t really know what going on in that file.

 @Test
    fun `geRedditPost shows loading first then shows the post after it was successfully fetched`() {
        coroutineTestRule.testDispatcher.runBlockingTest {
            // Given
            lifeCycleTestOwner.onResume()
            val redditPost = State.Post("This is a reddit post")
            When calling redditRepository.getPost() itReturns redditPost
            // When
            postViewModel.getRedditPost()
            // Then
            Verify on stateObserver that stateObserver.onChanged(State.Loading) was called
            Verify on stateObserver that stateObserver.onChanged(redditPost) was called
            VerifyNoFurtherInteractions on stateObserver
        }
    }

happy_test.kt

Wew lad

Unit tests will never replace decent UI tests on android (or your userbase throwing 1-star reviews on your app because it’s crashing all over the place), but this is good enough to build on for more complex screens. Much of the verbosity can also be decreased by using extension functions and test rules.

Later.