Overview
This post is meant to be for some tweaks and tricks in MockK like testing Private methods with their return value and livedata changes. It's not an introduction to the library. Actually the documentation is great for exploring the library.
Introduction
Why should we write code to test an already implemented feature, when we can use that time in implementing another feature ?
Many developers seem to underestimate the value added by unit and integration test code.
Actually I believe the code written in Software testing is of more value on the long run than that of the production, because as long as project is not dead and is used, there will always be new feature requests and new bugs emerge that needs to be fixed.
How will we make sure that the code added didn't make other issues in the already working features?
Software testing is needed for the survival of the production code. Manual testing made by us will never be enough to verify that every thing is working fine.
And of course the testing code changes over time, and because of that you have to write your tests in a clean way, to be able to change them easily.
The importance of Mocking
We use mocking in many cases:
- mocking the dependencies needed by a class to test it separately as a black box
- Make our tests run faster by mocking databases or server responses instead of using real ones, and many more.
Mockk
MockK is one of the best mocking libraries for Kotlin, it supports coroutines and has many cool features.
No more talk and let's see a practical example.
Here is an example LoginViewModel
class that we will test through the post
class LoginVM constructor(
private val loginUseCase: ILoginUseCase,
) {
fun login(userIdentifier: String, password: String): Boolean {
return if (isFormValid(userIdentifier, password)) {
loginValidatedUser(userIdentifier, password)
} else false
}
private fun isFormValid(userIdentifier: String, password: String): Boolean {
return if (isFieldEmpty(userIdentifier)) {
false
} else if (!isInputNumerical(userIdentifier) && !isEmailValid(userIdentifier)) {
false
} else if (isInputNumerical(userIdentifier) && !isPhoneNumberValid(userIdentifier)) {
false
} else if (isFieldEmpty(password)) {
false
} else isPasswordValid(password)
}
private fun loginValidatedUser(userIdentifier: String, password: String): Boolean {
return loginUseCase.login(userIdentifier, password)
}
}
This class has one public method which is login
that takes userIdentifier
and password
and then it checks if they are valid or not and depending on that it will call the loginUseCase
object.
And that's the ILoginUseCase interface:
interface ILoginUseCase {
fun login(userIdentifier: String, password: String): Boolean
}
To test this class we will need to mock the login use case which is passed in the constructor, first.
And that's how it's done in MockK:
val loginUseCase = mockk<ILoginUseCase>()
val viewModel = LoginVM(loginUseCase)
Testing a mocked object is not called at all
Now let's call the login
method in the LoginVM
with invalid userIdentifier
and check if the loginUseCase
is not called at all
val result = viewModel.login("", "")
verify { loginUseCase wasNot Called } //used only to check the whole mocked object
Testing that a method is not called
That's how to see if a specific method is not called at all:
verify(exactly = 0) {
loginUseCase.login(any(),any() )
} //used to detect if a method was not called in the mocked object
any()
is used to represent any argument, if we want to check if the method with a specific argument, we will remove any()
and pass that value instead.
Mocking a method
Now if we provided a valid userIdentifier
and password
to the login method in the LoginVM
we will get an exception for not defining the behavior of the login
method in the use case
So let's make this method return false
at first and then true
and always remember, the last returned value will remain the answer of that method if it's called more than twice.
val loginUseCase = mockk<ILoginUseCase>()
every { loginUseCase.login(any(), any()) } returns false andThen true
val viewModel = LoginVM(loginUseCase)
//return false
var result = viewModel.login("david@gmail.com", "Test@123")
assertEquals(false, result)
//return true
result = viewModel.login("david@gmail.com", "Test@123")
assertEquals(true, result)
Custom Mocking
We can return a specific response depending on the passed arguments and here's how:
val loginUseCase = mockk<ILoginUseCase>()
every { loginUseCase.login(any(), any()) } answers {
val email: String = arg(0)
val password: String = arg(1)
email == "david@gmail.com" && password == "Test@123"
}
val viewModel = LoginVM(loginUseCase)
assertEquals(true, viewModel.login("david@gmail.com", "Test@123"))
assertEquals(false, viewModel.login("davi@gmail.com", "Test@123"))
assertEquals(false, viewModel.login("david@gmail.com", "Test@23"))
Testing private methods
Even though I don't recommend it, the need to test private methods could be a code smell that you need to refactor the logic inside that method into a separate testable class and leave that method private.
But Yes
We will use spyk
provided by MockK which is used to create a real object that we can spy on and create some mocked behavior for specific methods of that object.
Since MockK uses reflection to test the private methods, we will need to pass the method name and the arguments types as shown in the verify
block.
val viewModel = spyk(LoginVM(loginUseCase), recordPrivateCalls = true)
verify { viewModel["isFormValid"](any<String>(), any<String>())}
Testing return of private methods:
Sadly the library doesn't support testing the return of private methods in a direct way, but it has many features that we can use them to test this value.
Let's try to test isFormValid
private method in the view model class.
The idea I came up with is to use every
block provided by MockK DSL to mock the behavior of that private method and call the actual method in that block using callOriginal()
provided by MockK to record the return value in a separate value, and return that value in the defined mocking behavior so that nothing differs from the behavior of the original method
val loginUseCase = mockk<ILoginUseCase>()
val viewModel = spyk(LoginVM(loginUseCase), recordPrivateCalls = true)
var returnValue: Boolean? = null
every { viewModel["isFormValid"](any<String>(), any<String>()) } answers {
returnValue = callOriginal() as Boolean
return@answers returnValue
}
viewModel.login("david", "123")
assertEquals(false, returnValue!!)
Capturing the arguments passed to a mocked method
We can always get the passed argument to our mocked method to make some complex test cases.
Actually I find it an important technique that helped me a lot to capture the changes in live data or any observable object.
Let's see example first, we will try to capture the passed email to the login method:
val loginUseCase = mockk<ILoginUseCase>(relaxed = true)
val viewModel = LoginVM(loginUseCase)
val captureEmailArg = mutableListOf<String>()
viewModel.login("david@gmail.com", "Test@123")
viewModel.login("davi@gmail.com", "Test@123")
viewModel.login("dav@gmail.com", "Test@123")
verify {loginUseCase.login(capture(captureEmailArg), any())}
println(captureEmailArg)
The printed list is [david@gmail.com, davi@gmail.com, dav@gmail.com]
But how to use this to test the changes in a livedata
and not only the most recent value?
The idea is simple, we will mock an Observer
object and pass it to the liveData
object and when this liveData
changes it will call the onChanged
method of the observer object passing the new state as an argument, and that's what we need this argument is the new value of the live data that we need to capture similar to the example above
Here's the code for observing a livedata
that holds a String
val mockedObserver :Observer<String> = mockk(relaxed = true)
val changes = mutableListOf<String>()
every { mockedObserver.onChanged(capture(changes)) }
val liveData = MutableLiveData("")
liveData.observeForever(mockedObserver)
Now the changes
will contain all the changes happened in the live data.
Here is a repo with most of the code used.
Thanks for reading so far, if you have an extra trick or tip for using this awesome library you can share it with us in the comments ^_^
Top comments (0)