Testing is an essential part of the software development process. A unit test generally exercises the functionality of the smallest possible unit of code (which could be a method or class) in a repeatable way. You should add unit tests when you need to verify the logic of a specific code in your app.
The scope of this article is to explain the testing of functions with listeners
and functions taking lambda as arguments
. If you want to learn the basics of testing, there are some good tutorials available:
- https://www.raywenderlich.com/195-android-unit-testing-with-mockito
- https://proandroiddev.com/understanding-unit-tests-for-android-in-2021-71984f370240
Note: In this article, mockk is used for mocks; the same concepts will also apply to other mocking tools.
Consider a function that has a listener like getCurrentLocation
in the below gist.
class CurrentLocationCoroutineWrapperImpl( | |
private val fusedLocationProviderClient: FusedLocationProviderClient | |
) { | |
@RequiresPermission( | |
anyOf = | |
[ | |
Manifest.permission.ACCESS_FINE_LOCATION, | |
Manifest.permission.ACCESS_COARSE_LOCATION | |
] | |
) | |
suspend fun getCurrentLocation(priority: Int) = | |
suspendCancellableCoroutine { cont -> | |
fusedLocationProviderClient.getCurrentLocation( | |
priority, null | |
).apply { | |
addOnSuccessListener { location: Location? -> | |
if (location == null) { | |
cont.resumeWithException(LocationNotFoundException()) | |
} else { | |
cont.resume(location) | |
} | |
} | |
} | |
} | |
} |
The unit test for the getCurrentLocation
function in which the location returned successfully (i.e. the addOnSuccessListener
gets called) is :
class CurrentLocationCoroutineWrapperImplTest { | |
private val fusedLocationProviderClient: FusedLocationProviderClient = mockk(relaxed = true) | |
private lateinit var currentLocationCoroutineWrapperImpl: CurrentLocationCoroutineWrapperImpl | |
@Before | |
fun before() { | |
currentLocationCoroutineWrapperImpl = | |
CurrentLocationCoroutineWrapperImpl(fusedLocationProviderClient) | |
} | |
@Test | |
fun `getCurrentLocation with locationPriority success`() = | |
runTest { | |
val locationTaskMock: Task<Location> = mockk(relaxed = true) | |
val locationMock: Location = mockk() | |
every { | |
fusedLocationProviderClient.getCurrentLocation( | |
Priority.PRIORITY_HIGH_ACCURACY, | |
any() | |
) | |
} returns locationTaskMock | |
val slot = slot<OnSuccessListener<Location>>() | |
every { locationTaskMock.addOnSuccessListener(capture(slot)) } answers { | |
slot.captured.onSuccess(locationTaskMock.result) | |
locationTaskMock | |
} | |
every { locationTaskMock.result } returns locationMock | |
val result = | |
currentLocationCoroutineWrapperImpl.getCurrentLocation(Priority.PRIORITY_HIGH_ACCURACY) | |
assertEquals(locationMock, result) | |
verify { | |
fusedLocationProviderClient.getCurrentLocation( | |
Priority.PRIORITY_HIGH_ACCURACY, | |
any() | |
) | |
} | |
} | |
} |
Let’s understand the test. whenever getCurrentLocation
will be called, addOnSuccessListener
should be invoked, to do that we have to use argument captor.
ArgumentCaptor allows us to capture an argument passed to a method to inspect it. This is especially useful when we can’t access the argument outside of the method we’d like to test.
There are two ways to capture arguments: using CapturingSlot<TYPE>
and using MutableListOf<TYPE>
.
CapturingSlot
allows to capture only one value, for our case, this will work if you have multiple values you can use MutableListOf<TYPE>.
val slot = slot<OnSuccessListener<Location>>() every { locationTaskMock.addOnSuccessListener(capture(slot))} answers { slot.captured.onSuccess(locationTaskMock.result) locationTaskMock }
Job Offers
This creates a slot
and a mock
and set expected behavior following way: in case locationTaskMock.addOnSuccessListener
is called, then OnSuccessListener
is captured to the slot
locationTaskMock.result
is answered.
val result = currentLocationCoroutineWrapperImpl.getCurrentLocation(Priority.PRIORITY_HIGH_ACCURACY)
Now, this function can be asserted easily.
Let’s take another example wherein a function that takes a higher-order function as an argument like connect(connectionListener: (isConnected: Boolean) -> Unit)
class HighOrderDemo @Inject constructor( | |
private val connectRemote: ConnectRemote, | |
) { | |
fun connect(connectionListener: (isConnected: Boolean) -> Unit) { | |
try { | |
connectRemote.connect{ | |
connectionListener.invoke(it) | |
} | |
} catch (ex: Exception) { | |
connectionListener.invoke(false) | |
} | |
} | |
} |
Unit test for the above function connect
is:
class HighOrderDemoTest { | |
private lateinit var highOrderDemo: HighOrderDemo | |
private var connectRemote: ConnectRemote = mockk(relaxed = true) | |
@Before | |
fun before() { | |
highOrderDemo = HighOrderDemo( | |
connectRemote | |
) | |
} | |
@Test | |
fun `test successful connection`() { | |
every { connectRemote.connect(captureLambda<(isConnected: Boolean) -> Unit>()) } answers { | |
lambda<(isConnected: Boolean) -> Unit>().captured.invoke(true) | |
} | |
highOrderDemo.connect { | |
assertEquals(it, true) | |
} | |
} | |
} |
every { connectRemote .connect(captureLambda<(isConnected: Boolean) -> Unit>()) } answers { lambda<(isConnected: Boolean) -> Unit>().captured.invoke(true) }
Here also, in case connectRemote.connect
is called then connectionListener: (isConnected: Boolean) -> Unit
lambda is captured and true
is returned.
then highOrderDemo.connect
can be asserted via
highOrderDemo.connect { assertEquals(it, true) }
Update:
We can write tests for the above two methods without argument captor
as well in the following way:
- Listener
every { locationTaskMock.addOnSuccessListener(any()) } answers { firstArg<OnSuccessListener<Location>>().onSuccess(locationTaskMock.result) locationTaskMock }
- Higher-order function
every { connectRemote.connect(any()) } answers { firstArg<(isConnected: Boolean) -> Unit>().invoke(true) }
That’s it for now, I hope it helps! Enjoy and feel free to leave a comment if something is not clear or if you have questions. Thank you for reading! 🙌🙏
This article was originally published on proandroiddev.com on October 31, 2022