Testing ViewModel with Mockito
This article explains how to implement Mockito tests with coroutines.
The problem
Add Mockito tests to the project using depencency injection (refactoring documented in the previous article).
Credits
I learned how to set up the testing rules so that the code can rely on the main thread from an answer on StackOverflow. I learned how to use the @Rule
annotation in Kotlin from another answer.
Preparing the project
According to the doco, this is how one configures the tests so that they can use Dispatchers.Main
:
private val mainThreadSurrogate = newSingleThreadContext("UI thread")
@Before
fun setUp() {
Dispatchers.setMain(mainThreadSurrogate)
}
@After
fun tearDown() {
Dispatchers.resetMain() // reset main dispatcher to the original Main dispatcher
mainThreadSurrogate.close()
}
The classes and particular functions that are going to be mocked also need the open
keyword:
open class LocationChannelFactory(ctx: Context)
and:
open fun getLocationChannel(): ReceiveChannel<LatLng>
Without open
next to the class name the test simply wouldn’t compile. Without open
at the function name it would compile, but it would call the actual function, instead of mocking its result, when I write:
`when`(mockLocationChannelFactory.getLocationChannel())
.thenReturn(locationChannel)
Testing
Doco recommends using runBlockingTest()
, which modifies the CoroutineDispatcher
, so that whenever delay()
is called, it doesn’t wait, but proceeds immediately to the next line. I can’t use that, because in the code I use some computationally-intensive calculations, such as measuring the distance between two points. I do need delay()
to wait several milliseconds, so I can’t do that. I decided to use runBlocking()
instead:
@Test
fun distanceTest() = runBlocking {
var distance = 0.0
var lastDistance = distance
I start by setting the initial value of distance
to 0.0
. I then define a local function that will check whether the distance has increased since the last check:
suspend fun progress() {
delay(INTERVAL)
assertTrue(distance > lastDistance)
lastDistance = distance
}
Subsequently I create mock instances of LocationChannelFactory
and RotationChannelFactory
. For brevity, only LocationChannelFactory
is discussed herein:
val mockLocationChannelFactory: LocationChannelFactory = mock(LocationChannelFactory::class.java)
val mockRotationChannelFactory: RotationChannelFactory = mock(RotationChannelFactory::class.java)
val locationChannel = Channel<LatLng>(Channel.CONFLATED)
val rotationChannel = Channel<Float>(Channel.CONFLATED)
`when`(mockLocationChannelFactory.getLocationChannel()).thenReturn(locationChannel)
`when`(mockRotationChannelFactory.getRotationChannel()).thenReturn(rotationChannel)
Subsequently there is a definition of the Job
that will send fake locations to the Channel
:
launch(Dispatchers.IO) {
locationChannel.send(HOME)
var lat = HOME.latitude
while (true) {
delay(INTERVAL)
lat += LATITUDE_STEP
locationChannel.send(LatLng(lat, HOME.longitude))
}
}
Next, I create the actual ViewModel
and start observing the LiveData
:
val liveDataJob = Job()
val vm = CompassViewModel(
mockRotationChannelFactory,
mockLocationChannelFactory,
EmptyCoroutineContext + liveDataJob)
vm.setDestination(DestinationModel(HOME, ""))
val destination = vm.direction
val distanceObserver = Observer<DirectionModel> {
distance = it.distance
}
destination.observeForever(distanceObserver)
In the above code, I need to crate a Job
that will be later canceled in order to throw CancellationException
inside the LiveData
. I need this in order to test whether the Channel
is closed. Alternatively, I could test that by just removing the Observer
from the LiveData
, and waiting a couple of seconds for the timeout to kick in. To save time, I decided to instead manually cancel the CoroutineContext
used in this LiveData
. This is the code that creates the LiveData
that wraps the Channel
returned by LocationChannelFactory
:
fun <T> channelLiveData(
context: CoroutineContext = EmptyCoroutineContext,
block: () -> ReceiveChannel<T>): Lazy<LiveData<T>> = lazy {
liveData(context) {
val channel = block()
try {
while (true) {
emit(channel.receive())
}
}
finally {
channel.cancel()
}
}
}
The way LiveData
is created inside the CompassViewModel
assures that the Channel
it uses will be closed when the CoroutineContext
is canceled.
Next, there is the code that waits for the CompassViewModel
to stabilize (start emitting values) and calls progress()
two times. This is the local function that waits a bit, and then asserts that the distance calculated by the CompassViewModel
has increased during that time.
Finally, there is the code that asserts the Channel
hasn’t been closed yet, cancels the Job
associated with the LiveData
, waits again for the CompassViewModel
to stabilize, and asserts that the Channel
which that LiveData
was using is closed now.
Conclusion
The present article has demonstated the use of the following:
- Mockito.
- Depencency injection in constructor.
- Testing coroutines using
runBlocking()
. - Checking at the end of the test that
Channel
has been closed.
In order to achieve this, I had to replace dependency retrieval, whith I had been using extensively in this blog before that time, with dependency injection. I described the process in the previous article.
Choosing dependency injection or dependecny retrieval is a matter of personal preference of the developer, as both can be successfully used for testin. I discussed testing with dependency injection in an early article in this blog.
Donations
If the reader has enjoyed the present article, they may want to donate some bitcoin at the address presented below. Readers may also look at my donations page.
BTC: bc1qncxh5xs6erq6w4qz3a7xl7f50agrgn3w58dsfp