A Koltin multiplatform library that provides an easier way to mock and write unit tests for a multiplatform project
This project may contain experimental code and may not be ready for general use. Support and/or releases may be limited.
In your multiplatform project include
Koltin DSL:
implementation("com.careem.mockingbird:mockingbird:$mockingBirdVersion")
Groovy DSL:
implementation "com.careem.mockingbird:mockingbird:$mockingBirdVersion"
MockingBird doesn't use any annotation processor or reflection. This means that it is a bit more verbose with respect to libraries
like Mockito
or Mockk
The mock generation plugin generates mock/spy boilerplate code for you, the plugin can be used along with manual mocks, it is currently experimental and has several limitations.
To use this plugin you have to use mockingbird version 2.9.0
or above, to see examples you can explore the samples
project,
You can open samples
is a standalone project.
WARNING: If you do not what to use the plugin you can use the old way of manual mock generation, check Mocks or Spies
To start using the plugin you need to include it in your project build.gradle.kts
or build.gradle
Be sure you have mavenCentral()
in your buildscripts
repositories, your project build gradle might look similar the one below
Kotlin DSL:
buildscript {
repositories {
...
mavenCentral()
}
dependencies {
...
classpath("com.careem.mockingbird:mockingbird-compiler:$mockingBirdVersion")
}
}
Groovy DSL:
buildscript {
repositories {
...
mavenCentral()
}
dependencies {
...
classpath "com.careem.mockingbird:mockingbird-compiler:$mockingBirdVersion"
}
}
To generate mocks for a specific module you have first to apply the plugin in this module's build gradle
Kotlin DSL:
apply(plugin = "com.careem.mockingbird")
Groovy DSL:
apply plugin: "com.careem.mockingbird"
Examples are under :samples:kspsample
The plugin has a new version that allow you to use google ksp to generate the mocks annotating the filed in your test classes.
To enable the ksp based code gen it is enough for you to add the google ksp plugin to your module where the plugin is applied for example your plugin block could look similar to
plugins {
id("com.google.devtools.ksp") version "1.7.10-1.0.6"
id("com.careem.mockingbird")
}
To create a mock you just need to annotate the field in your test class example:
class KspSampleTest {
@Mock
val pippoMock: PippoSample = PippoSampleMock()
...
}
Where PippoSample
is the interface you want to mock and PippoSampleMock
is the mock that will be generated by ksp.
To create a spy a similar syntax can be used
class KspSampleSpyTest {
@Spy
val outerInterfaceSpy: OuterInterface = OuterInterfaceSpy(outerInterfaceRealImpl)
...
}
Note: that you have to pass the real implementation to the spy, this allow to call the real implementation if the function call is not mocked
Examples are under :samples:sample
NOTE: the plugin doesn't discover which interfaces to mock, it's up to you to configure those.
And then specify for what interfaces you what to generate mocks for, see the example below
Kotlin DSL:
configure<com.careem.mockingbird.MockingbirdPluginExtension> {
generateMocksFor = listOf(
"com.careem.mockingbird.sample.Mock1",
"com.careem.mockingbird.sample.MockWithExternalDependencies",
"com.careem.mockingbird.common.sample.ExternalContract"
)
}
Groovy DSL
mockingBird {
generateMocksFor = [
'com.careem.mockingbird.sample.Mock1',
'com.careem.mockingbird.sample.MockWithExternalDependencies',
'com.careem.mockingbird.common.sample.ExternalContract'
]
}
To generate mocks you can simply run ./gradlew generateMocks
or simply run ./gradlew build
, for faster development loop
using generatedMocks
is recommended
- Only interfaces can be mocked
- Only interfaces without
inline
functions can be mocked - Only interfaces without
reified
functions can be mocked
When your mocks are ready you can write your tests and specify the behavior you want when a method on your mock is called, and verify the method is called your mock.
To do that you will use:
every
everyAnswer
verify
every
allows you to specify the value you want to return for a specific invocation
If you want to return 3 when myDependencyMock.method3(4, 5)
is called, your every
will look like
testMock.every(
methodName = MyDependencyMock.Method.method3,
arguments = mapOf(MyDependencyMock.Arg.value1 to 4, MyDependencyMock.Arg.value2 to 5)
) { 3 }
If you wish to perform specific logic when you mock is called you can use everyAnswer
, here you can specify the behavior you
want for your mock. A typical use case is when you want to invoke a callback that was passed as parameter to the mocked function.
The code for a callback invocation will look like
myMock.everyAnswers(
methodName = MyMock.Method.run,
arguments = mapOf(
MyMock.Arg.callback to callback,
)
) {
val callback = it.arguments[MyMock.Arg.callback] as () -> Unit
callback.invoke()
}
After the invocation of your mock is defined, you need to verify it is invoked to make your unit test valid. For example if you
want to verify myDependencyMock.method3(4, 5)
is invoked, you should do something like:
testMock.verify(
exactly = 1,
methodName = MyDependencyMock.Method.method3,
arguments = mapOf(MyDependencyMock.Arg.value1 to 4, MyDependencyMock.Arg.value2 to 5),
timeoutMillis = 5000L
)
Note: exactly
is how many times you want to verify invocation of your mock is invoked, by default it will be 1, so no need to
set it up if you want to verify exactly 1 time invocation.
Note: when timeoutMillis
is set with a value greater than 0 the test condition will be evaluated multiple times up
to timeoutMillis
. If the condition is not satisfied within the given timeout the verify will fail.
When your mocks are ready you can write your tests and specify the behavior you want when a method on your mock is called, and verify the method is called your mock. Sometimes besides mocking, we want to verify the equality of argument that passed to the mock's invocation, sometimes we don't care about the argument value or sometimes we want to strongly verify that the invocation is not invoked no matter what arguments is passed. In all these cases, we need matching arguments.
To do matching, you will use:
any()
slot()
andcapture
As it looks like, any()
matcher will give you ability to ignore the compare of argument when mocking invocation or verify it.
For example, if you want to return 3 when myDependencyMock.method3
is called no matter what two arguments is passed in,
your every
will look like:
testMock.every(
methodName = MyDependencyMock.Method.method3,
arguments = mapOf(MyDependencyMock.Arg.value1 to any(), MyDependencyMock.Arg.value2 to any())
) { 3 }
By doing this, both myDependencyMock.method3(1,2)
or myDependencyMock.method3(3,4)
will all returns 3. Similar to this every
, you can easily verify myDependencyMock.method3
is invoked and ignore the argument comparing by:
testMock.verify(
exactly = 1,
methodName = MyDependencyMock.Method.method3,
arguments = mapOf(MyDependencyMock.Arg.value1 to any(), MyDependencyMock.Arg.value2 to any())
)
A normal use case on verify with any()
matcher is verify invocation is invoked exactly = 0
with
any()
arguments which means it is never invoked completely.
Another use case for matching is: for example you want to verify myDependencyMock.method4(object1)
is invoked, but the reference of object1 is not mocked or initiated inside the test case, In this case, an easy way to verify, or
say matching arguments, is create an object using slot()
or capturedList()
and then
capture
this object when verify the invocation, something like:
val objectSlot = slot<Object>()
val objectCapturedList = capturedList<Object>()
testMock.every(
methodName = MyDependencyMock.Method.method1,
arguments = mapOf(MyDependencyMock.Arg.str to TEST_STRING)
) {}
// capturing by slot
testMock.verify(
methodName = MyDependencyMock.Method.method4,
arguments = mapOf(MyDependencyMock.Arg.object1 to capture(objectSlot))
)
assertEquals(expectedProperty, objectSlot.captured.property)
// capturing by capturedList
testMock.verify(
methodName = MyDependencyMock.Method.method4,
arguments = mapOf(MyDependencyMock.Arg.object1 to capture(objectCapturedList))
)
assertEquals(2, objectCapturedList.captured.size)
assertEquals(expectedProperty, capturedList.captured[0])
assertEquals(expectedProperty, capturedList.captured[1])
For capturing slot, a common use case for this capturing is when a new instance is created inside testing method and you want to compare some properties of the captured object initialized correctly. For capturing list, a common use case is invocation is invoked multiple times and you want to verify the arguments of each separately.
Changing the test mode will allow you to mock objects for different test scenarios, for example integration tests or unit tests.
By default mockingbirds handles mocks in a way that they can be shared across multiple threads, sometimes this will introduce some
limitations when you want to test classes that cannot be shared across threads and that for this reason they might have something
like ensureNeverFrozen
in their constructor.
For those cases you might want to use the LOCAL_THREAD
test mode where the arguments you pass to the mock do not need to be
frozen because you know that your class is a single threaded class.
Example of LOCAL_THREAD
mode:
@Test
fun testLocalModeDoNotFreezeClass() = runWithTestMode(TestMode.LOCAL_THREAD) {
val myDependencyMock = MyDependencyMock()
myDependencyMock.everyAnswers(
methodName = MyDependencyMock.Method.method6,
arguments = mapOf(
MyDependencyMock.Arg.callback to any()
)
) { it.getArgument<() -> Unit>(MyDependencyMock.Arg.callback).invoke() }
val instance = LocalThreadAccessibleClass(myDependencyMock)
instance.execute()
myDependencyMock.verify(
methodName = MyDependencyMock.Method.method6,
arguments = mapOf(Mocks.MyDependencySpy.Arg.callback to any())
)
assertEquals(1, instance.counter)
}
In this section it is explained how to generate mocks and spy manually, we suggest to use this approach only when you face issues with the Mock generator plugin.
The first step you need to do is create a mock class for the object you want to mock, you need a mock for each dependency type you want to mock
The library provides 2 functions to help you write your mocks.
mock
this function allows you to mock non-methods with return types other than UnitmockUnit
this function allows you to mock Unit methods
These helpers enable you to map your mock invocations to MockingBird environment.
Your mock class must implement Mock
, in addition to extending the actual class or implementing an interface
See below for an example on how to create a mock :
interface MyDependency {
fun method1(str: String)
fun method2(str: String, value: Int)
fun method3(value1: Int, value2: Int): Int
}
class MyDependencyMock : MyDependency, Mock {
object Method {
const val method1 = "method1"
const val method2 = "method2"
const val method3 = "method3"
const val method4 = "method4"
}
object Arg {
const val str = "str"
const val value = "value"
const val value1 = "value1"
const val value2 = "value2"
const val object1 = "object1"
}
override fun method1(str: String) = mockUnit(
methodName = Method.method1,
arguments = mapOf(
Arg.str to str
)
)
override fun method2(str: String, value: Int) = mockUnit(
methodName = Method.method2,
arguments = mapOf(
Arg.str to str,
Arg.value to value
)
)
override fun method3(value1: Int, value2: Int): Int = mock(
methodName = Method.method3,
arguments = mapOf(
Arg.value1 to value1,
Arg.value2 to value2
)
)
override fun method4(object1: Object): Int = mock(
methodName = Method.method4,
arguments = mapOf(
Arg.object1 to object1
)
)
}
When you need a combination of real behavior and mocked behavior you can use spy
with spy you wrap wrap a real implementation.
Doing so Mocking Bird will record the interactions with the spied object.
To mock a specific invocation you can use the spied object like a normal mock, see sections below for further details.
A Spy sample object is reported here
interface MyDependency {
fun method1(str: String)
fun method2(str: String, value: Int)
fun method3(value1: Int, value2: Int): Int
fun method4(): Int
}
class MyDependencySpy(private val delegate: MyDependency) : MyDependency, Spy {
object Method {
const val method1 = "method1"
const val method2 = "method2"
const val method3 = "method3"
const val method4 = "method4"
}
object Arg {
const val str = "str"
const val value = "value"
const val value1 = "value1"
const val value2 = "value2"
}
override fun method1(str: String) = spy(
methodName = Method.method1,
arguments = mapOf(
Arg.str to str
),
delegate = { delegate.method1(str) }
)
override fun method2(str: String, value: Int) = spy(
methodName = Method.method2,
arguments = mapOf(
Arg.str to str,
Arg.value to value
),
delegate = { delegate.method2(str, value) }
)
override fun method3(value1: Int, value2: Int): Int = spy(
methodName = Method.method3,
arguments = mapOf(
Arg.value1 to value1,
Arg.value2 to value2
),
delegate = { delegate.method3(value1, value2) }
)
override fun method4(): Int = spy(
methodName = Method.method4,
delegate = { delegate.method4() }
)
}
class MyDependencyImpl : MyDependency {
private var value: AtomicInt = atomic(0)
override fun method1(str: String) {
}
override fun method2(str: String, value: Int) {
}
override fun method3(value1: Int, value2: Int): Int {
value.value = value1 + value2
return value.value
}
override fun method4(): Int {
return value.value
}
}
Copyright Careem, an Uber Technologies Inc. company
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.