Micro-Mock
A micro Kotlin/Multiplatform Kotlin Symbol Processor that generates Mocks & Fakes.
Limitations:
-
Mocking only applies to interfaces
-
Faking only applies to concrete trees
Warning
|
Micro-Mock is in Beta!
|
Usage
Mocks
Caution
|
Only interfaces can be mocked! |
Requesting generation
You can declare that a class needs a specific mocked interface by using the @UsesMocks
annotation.
@UsesMocks(Database::class, API::class)
class MyTests {
}
Once a type appears in @UsesMocks
, the processor will generate a mock class for it.
Defining behaviour
To manipulate a mocked type, you need a Mocker
. You can then create mocked types and define their behaviour:
@UsesMocks(Database::class, API::class)
class MyTests {
@Test fun myUnitTest() {
val mocker = Mocker()
val db = MockDatabase(mocker)
val api = MockAPI(mocker)
mocker.on { db.open(isAny()) } returns Unit //(1)
mocker.on { api.getCurrentUser() } runs { fakeUser() } //(2)
}
}
-
returns
mocks the method to return the provided instance. -
runs
mocks the method to run and return the result of the provided function.
Note that a method must be mocked to run without throwing an exception (there is no "relaxed" mode).
You can mock methods according to specific argument constraints:
mocker.on { api.update(isNotNull()) } returns true
mocker.on { api.update(isNull()) } runs { nullCounter++ ; false }
Available constraints are:
-
isAny
is always valid (even withnull
values). -
isNull
andisNotNull
check nullability. -
isEqual
andisNotEqual
check regular equality. -
isSame
andisNotSame
check identity.
Note that passing a non-constraint value to the function is equivalent to passing isEqual(value)
mocker.on { api.getUserById(42) } returns fakeUser()
is strictly equivalent to:
mocker.on { api.getUserById(isEqual(42)) } returns fakeUser()
Verification
You can check that mock functions has been run in order with verify
.
val fakeUser = fakeUser()
mocker.on { db.loadUser(isAny()) } returns null
mocker.on { db.saveUser(isAny()) } returns Unit
mocker.on { api.getUserById(isAny()) } returns fakeUser
controller.onClickUser(userId = 42)
mocker.verify {
db.loadUser(42)
api.getUserById(42)
db.saveUser(fakeUser)
}
You can of course use constraints (in fact, not using passing a constraint is equivalent to passing isEqual(value)
):
mocker.verify {
api.getUserById(isAny())
db.saveUser(isNotNull())
}
The verify
block must be exhaustive: it must lists all mocked functions that was called, in order. This means that you can easily check that no mocked methods were run:
mocker.verify {}
You can use clearCalls
to clear the call log, in order to only verify for future method calls:
controller.onClickUser(userId = 42)
mocker.clearCalls() //(1)
controller.onClickDelete()
mocker.verify { db.deleteUser(42) }
-
All mocked calls before this won’t be verified.
Custom constraints
You can define your own constraints:
fun ArgConstraintsBuilder.isStrictlyPositive(capture: MutableList<Int>? = null): Int =
isValid(ArgConstraint(capture) {
if (it >= 0) ArgConstraint.Result.Success
else ArgConstraint.Result.Failure { "Expected a strictly positive value, got $it" }
})
…and use them in definition:
mocker.on { api.getSuccess(isStrictlyPositive()) } returns true
mocker.on { api.getSuccess(isAny()) } returns false
…or in verification:
mocker.verify { api.getUserById(isStrictlyPositive()) }
Fakes
Caution
|
Only concrete trees (concrete classes containing concrete classes) can be faked!. |
Data classes are ideal candidates for faking.
Requesting generation
You can declare that a class needs a specific faked data by using the @UsesFakes
annotation.
@UsesFakes(User::class)
class MyTests {
}
Once a type appears in @UsesFakes
, the processor will generate a fake function for it.
Instantiating
Once a class has been faked, you can get a new instance by calling its fake*
corresponding function:
@UsesFakes(User::class)
class MyTests {
val user = fakeUser()
}
Here are the rules the processor uses to generate fakes:
-
Nullable values are always
null
. -
Boolean
values are set tofalse
. -
Numeric values are set to
0
. -
String
values are set to empty""
. -
Other non-nullable non-primitive values are faked.
Tip
|
By using a val user = fakeUser().copy(id = 42)
|
Injecting your tests
Instead of creating your own mocks & fakes, it can be useful to inject them in your test class, especially if you have multiple tests using them.
@UsesFakes(User::class)
class MyTests {
@set:Mock lateinit var db: Database
@set:Mock lateinit var api: API
@set:Fake lateinit var user: User
lateinit var controller: Controller
val mocker = Mocker()
@BeforeTest fun setUp() {
mocker.reset() //(1)
this.injectMocks(mocker) //(2)
controller = ControllerImpl(db, api) //(3)
}
}
-
Resets the mocker before any test (which removes all mocked behaviour & logged calls), so that each test gets a "clean" mocker.
-
Injects mocks and fakes.
-
Create classes to be tested with injected mocks & fakes.
As soon as a class T
contains a @set:Mock
or @set:Fake
annotated property, a T.injectMocks(Mocker)
function will be created by the processor.
Important
|
Don’t forget to reset the Mocker in a @BeforeTest method! |
Setup
With KSP
Micro-Mock is a Kotlin Symbol Processor, so you need to apply KSP to use it.
Regular setup
plugins {
kotlin("multiplatform")
id("com.google.devtools.ksp") version "1.6.0-RC-1.0.1-RC" //(1)
}
repositories {
mavenCentral()
maven(url = "https://raw.githubusercontent.com/Kodein-Framework/Micro-Mock/mvn-repo") //(3)
}
kotlin {
jvm()
ios()
sourceSets {
val commonTest by getting {
dependencies {
implementation(kotlin("test"))
implementation("org.kodein.micromock:micro-mock:0.1") //(4)
}
}
}
}
dependencies {
"kspJvmTest"("org.kodein.micromock:micro-mock-processor:0.1") //(2)
"kspIosX64Test"("org.kodein.micromock:micro-mock-processor:0.1") //(2)
"kspIosArm64Test"("org.kodein.micromock:micro-mock-processor:0.1") //(2)
}
-
Applying the KSP plugin
-
Adding the processor on each required target
-
Adding the custom maven repository (won’t be necessary after stable release)
-
Adding the dependency to the Micro-Mock runtime
Buggy multiplatform
KSP for multiplatform is in beta, and KSP for the new JS/IR compiler is plainly not supported (yet).
If you need Micro-Mock for your tests but KSP is failing in your multiplatform project, here’s a trick that you can use:
plugins {
kotlin("multiplatform")
id("com.google.devtools.ksp")
}
kotlin {
jvm()
ios()
js(IR) {
browser()
nodejs()
}
sourceSets {
val commonTest by getting {
dependencies {
implementation(kotlin("test"))
implementation("org.kodein.micromock:micro-mock:0.1")
}
kotlin.srcDir("build/generated/ksp/jvmTest/kotlin") //(2)
}
}
}
dependencies {
"kspJvmTest"(project(":micro-mock-processor")) //(1)
}
tasks.withType<org.jetbrains.kotlin.gradle.dsl.KotlinCompile<*>>().all {
if (name.startsWith("compileTestKotlin")) {
dependsOn("kspTestKotlinJvm") //(3)
}
}
-
Apply the processor only on the JVM target
-
Use KSP generated JVM sources on all targets
-
Make compilation of all targets dependant on the JVM KSP processor
With the plugin
The Micro-Mock Gradle plugin applies the trick that only runs the processor on the JVM target and adds the generated sources to all targets. Note that this may collision with other Symbol Processors. This plugin will be deprecated once KSP properly supports Multiplatform & JS/IR.
pluginManagement {
repositories {
gradlePluginPortal()
maven(url = "https://raw.githubusercontent.com/Kodein-Framework/Micro-Mock/mvn-repo") //(1)
}
}
-
Adding the custom maven repository (won’t be necessary after stable release)
plugins {
kotlin("multiplatform")
id("org.kodein.micromock") version "0.1" //(1)
}
repositories {
mavenCentral()
maven(url = "https://raw.githubusercontent.com/Kodein-Framework/Micro-Mock/mvn-repo") //(2)
}
kotlin {
jvm()
ios()
js(IR) {
browser()
nodejs()
}
sourceSets {
val commonTest by getting {
dependencies {
implementation(kotlin("test"))
}
}
}
}
-
Applying the Micro-Mock plugin.
-
Adding the custom maven repository (won’t be necessary after stable release)