写在前面
-
注:学习交流使用!
前言
最近项目中使用KMP开发了RTC音视频和IM数据多端同步两个模块,由于时间、多平台调试等原因项目模块匆忙上线,很遗憾没有完成单元测试。现在项目基本上稳定运行,有较多的时间可以补充遗留的单元测试内容,同时也有时间记录下KMP中单元测试工具的使用方法,以便其他开发者更优雅地编写KMP中的Kotlin单元测试。
技术目标
MockK是一款功能强大、易于使用的Kotlin mocking框架。它具有简洁的语法和强大的功能,能够过帮助开发者轻松的进行单元测试、集成测试。MockK提供了一套丰富灵活的API,可以轻松地创建模拟对象并进行相关的操作,来验证方法调用和预期的返回值。另外,它还提供了Mockito、PowerMock等不具备的高级功能,例如mock静态类、final类等。本文将介绍MockK在KMP中的基本使用方法,并深入探讨一些额外的高级特性。
前期分析
由于Mockito、PowerMockito主要针对Java语言进行的设计,因此在处理kotlin语言上存在缺陷。MockK是从零开始专门为Kotlin构建,它能够针对Kotlin实现更强大和高级的功能。
Mockito存在的问题
- 类型:Mockito不支持对 final class、匿名内部类以及基本类型(如 int)的 mock。
- 方法:Mockito不支持对静态方法、 final 方法、私有方法、equals() 和 hashCode() 方法进行 mock。
- 关键字:Mockito使用时when要加上反引号才能使用(与kotlin关键字when冲突),这种写法非常不友好。
PowerMockito存在的问题
- 兼容性:在Android上编写单元测试,使用了某版本无法支持对静态方法与final类进行mock
MockK的优势
-
强大的mock能力:MockK支持final class、匿名内部类以及基本类型的mock,同时支持静态、final方法的mock。
-
简化测试代码:MockK提供了简洁而直观的 API,使得创建和管理模拟对象变得容易。它的语法清晰简洁,可以快速定义模拟对象的行为和预期结果,从而减少冗余的测试代码。
-
模拟复杂场景:MockK不仅可以模拟普通的对象行为,还可以处理更复杂的场景,如模拟 lambda 表达式、捕获函数调用参数等。这使得在测试中处理回调函数、异步操作或依赖其他组件的情况变得更加容易。
-
支持依赖注入框架:MockK可以与常见的依赖注入框架(如Koin、Dagger)集成,使得在单元测试中模拟依赖项变得更加便捷。通过模拟依赖项,我们可以更好地隔离被测试单元的功能,并提供更可靠的测试环境。
使用教程
引入Mockk
首先mockk仅支持JVM平台,如果在KMP中编写了通用的commonMain代码,那么它将无法工作。由于项目中KMP支持的平台有Android、iOS、PC Mac、Pc windows,而没有支持专门JVM平台,因此考虑将mockk放置到Android平台的androidUnitTest中,同时让Android的单元测试运行在JVM平台上。
在androidUnitTest源码集合添加依赖
kotlin
val androidUnitTest by getting {
dependencies {
implementation("io.mockk:mockk:1.12.0")
}
}
备注:不要使用官网提示的testImplementation,KMP中没有这个方法支持
基本使用
- 在commonMain中编写产品类
kotlin
class Kid(private val mother: Mother) {
var money = 0
private set
fun wantMoney() {
money += mother.giveMoney()
}
}
class Mother {
fun giveMoney(): Int {
return 100
}
- 在androidUnitTest中编写测试类
kotlin
package com.subscribe.kmpproject.unittest
import com.subscribe.kmpproject.unit.Kid
import com.subscribe.kmpproject.unit.Mother
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import junit.framework.TestCase.assertEquals
import kotlin.test.Test
class CommonGreetingTest {
@Test
fun testExample() {
// 准备阶段
val mother = mockk<Mother>()
val kid = Kid(mother)
every { mother.giveMoney() } returns 30
// 执行阶段
kid.wantMoney()
// 校验阶段
verify {
kid.wantMoney()
}
assertEquals(30, kid.money)
}
}
备注:其中every定义了mock对象mother的行为,当调用giveMoney时,返回值为30; verify用于校验kid对象的wantMoney是否被调用过。
参数匹配
sql
every {
mockObj.someMethod(any())
} returns "Mocked Result"
备注:在定义mock对象行为时,可以进行参数匹配,此处使用了any()表明可以匹配任意的输入参数。
函数验证
markdown
verify {
mockObj.someMethod()
}
备注:校验函数是否被调用过,前面的例子中已经写了。更高级的,还可以校验函数的调用次数、顺序、参数匹配等等。
scss
verify(exactly = 10) {
mockObj.someMethod()
}
备注:校验方法必须精确被调用10次
scss
verify {
mockObj.firstMethod()
mockObj.secondMethod()
}
备注:校验调用顺序,firstMethod必须在secondMethod之前进行调用,否则验证不通过
偏函数模拟
scss
every {
mockObject.someMethod(any())
} answers {
originalCall(it.invocation.args.first())
}
备注:对于某些方法调用,我们并不想完全使用模拟的值,而是想使用特定的函数调用过程,那么可以使用originalCall来实现对实际函数的调用。
构造函数
scss
mockkConstructor(MyClass::class)
every {
anyConstructed<MyClass>().someMethod()
} returns "Mocked Result"// 执行测试代码
unmockkConstructor(MyClass::class)
备注:使用mockkConstructor方法mock构造函数,并通过anyConstructed进行类的构造,最后通过 unmockkConstructor取消构造函数的mock。
Lambada表达式
css
val lambdaMock: () -> Unit = mockk()
every {
lambdaMock.invoke()
} just Runs
使用注解进行mock
kotlin
class Car {
fun getName(): String {
return "NewCar"
}
}
class AnnotationTest {
@MockK
lateinit var car: Car
@Before
fun setUp() {
MockKAnnotations.init(this)
}
@Test
fun getName() {
every { car.getName() } returns "MyCar"
val name = car.getName()
assertEquals("MyCar", name)
}
}
备注:使用@MockK可以mock并注入一个对象,同时需要在@Before初始化函数中调用注入方法MockKAnnotations.init(this)
所有方法跳过准备
kotlin
class Car {
fun getName(): String {
return "NewCar"
}
}
class AnnotationTest {
@MockK
lateinit var car: Car
@Before
fun setUp() {
MockKAnnotations.init(this)
}
@Test
fun getName() {
val name = car.getName()
}
}
对于mock对象而言,其方法的调用都需要预设行为,否则会报错:io.mockk.MockKException: no answer found for: Car(car#1).getName()。如果我们不想对每个方法都预设,比如一个对象的方法实在太多了有上千个,那么我们可以使用以下三种方案,来取消对象的方法预设:
mock构造参数
kotlin
@Test
fun getName() {
val car = mock<Car>(relaxed = true)
}
mock注解指定
kotlin
@RelaxedMockK
lateinit var car: Car
mock注解初始化
kotlin
@MockK
lateinit var car: Car
@Before
fun setUp() {
MockKAnnotations.init(this, relaxed = true)
}
Unit方法跳过准备
返回值为Unit类型的跳过校验,而非Unit的方法不跳过校验
kotlin
@Test
fun getName() {
val car = mock<Car>(relaxUnitFun = true)
}
### 抓取参数
kotlin
class Mother {
fun inform(money: Int) {
println("Mother.inform $money 元")
}
fun giveMoney(): Int {
return 100
}
}
class CaptureTest {
@Test
fun getName() {
// 准备
var mother: Mother = mockk<Mother>()
val slot = slot<Int>()
every { mother.inform(capture(slot)) } just Runs
// 执行
mother.inform(0)
// 校验
assertEquals(0, slot.captured)
}
}
备注:首先在准备阶段创建了一个slot槽位,接着配合capture函数定义slot可以捕获到值。在执行阶段inform传入的参数,可以被slot捕获到,并存储在slot.captured的变量中。
### 静态方法
kotlin
object UtilKotlin {
@JvmStatic
fun method(): String {
return "UtilKotlin.ok()"
}
}
class Utils {
fun method() {
UtilKotlin.method()
}
}
class StaticClassTest {
@Test
fun testMethod() {
// 准备
val utils = Utils()
mockkStatic(UtilKotlin::class)
every { UtilKotlin.method() } returns "MockResult"
// 执行
utils.method()
// 校验
verify { UtilKotlin.method() }
assertEquals("MockResult", UtilKotlin.method())
}
}
备注:实际上Java的类的静态方法也可以模拟,不过咱这里在KMP环境中只针对kotlin
### 静态对象
kotlin
class UtilKotlinX {
companion object {
@JvmStatic
fun method(): String {
return "UtilKotlinX.ok()"
}
}
}
class UtilsX {
fun method() {
UtilKotlinX.method()
}
}
class ObjectTest {
@Test
fun testMethod() {
// Given
val utilsX = UtilsX()
mockkObject(UtilKotlinX)
mockkObject(UtilKotlinX.Companion)
every { UtilKotlinX.method() } returns "Test"
// When
utilsX.method()
// Then
verify { UtilKotlinX.method() }
assertEquals("Test", UtilKotlinX.method())
}
}
备注:模拟的如果是静态方法,那么参考13;模拟的如果是一个对象,那么使用mockObject即可
### 其他功能
更多高级功能请参考:官网地址
源码参考
总结
MockK是一款功能强大、易于使用的Kotlin mocking框架,由于专门针对Kotlin进行设计,可以轻松的支持static方法、static类、final类mock。在Kotlin Multiplatform项目中,由于MockK不支持跨平台只支持JVM平台,因此需要将commonMain的测试代码,放置在可以运行于JVM虚拟机的源码集中。MockK的使用也比较简单,会使用Mokito的话很容易上手。下一篇将记录在KMP中的使用情况,以及自动构建需要如何配置。