技术目标

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的优势
  1. 强大的mock能力:MockK支持final class、匿名内部类以及基本类型的mock,同时支持静态、final方法的mock。
  2. 简化测试代码:MockK提供了简洁而直观的 API,使得创建和管理模拟对象变得容易。它的语法清晰简洁,可以快速定义模拟对象的行为和预期结果,从而减少冗余的测试代码。
  3. 模拟复杂场景:MockK不仅可以模拟普通的对象行为,还可以处理更复杂的场景,如模拟 lambda 表达式、捕获函数调用参数等。这使得在测试中处理回调函数、异步操作或依赖其他组件的情况变得更加容易。
  4. 支持依赖注入框架:MockK可以与常见的依赖注入框架(如Koin、Dagger)集成,使得在单元测试中模拟依赖项变得更加便捷。通过模拟依赖项,我们可以更好地隔离被测试单元的功能,并提供更可靠的测试环境。

使用教程

引入Mockk

首先mockk仅支持JVM平台,如果在KMP中编写了通用的commonMain代码,那么它将无法工作。由于项目中KMP支持的平台有Android、iOS、PC Mac、Pc windows,而没有支持专门JVM平台,因此考虑将mockk放置到Android平台的androidUnitTest中,同时让Android的单元测试运行在JVM平台上。

Kotlin Multiplatform单元测试-mockk工具篇_Test

在androidUnitTest源码集合添加依赖

val androidUnitTest by getting {
    dependencies {
        implementation("io.mockk:mockk:1.12.0")
    }
}
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.

备注:不要使用官网提示的testImplementation,KMP中没有这个方法支持

基本使用
  • 在commonMain中编写产品类
class Kid(private val mother: Mother) {
    var money = 0
        private set

    fun wantMoney() {
        money += mother.giveMoney()
    }
}

class Mother {
    fun giveMoney(): Int {
        return 100
    }
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.
  • 12.
  • 13.
  • 在androidUnitTest中编写测试类
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)
    }

}
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.
  • 12.
  • 13.
  • 14.
  • 15.
  • 16.
  • 17.
  • 18.
  • 19.
  • 20.
  • 21.
  • 22.
  • 23.
  • 24.
  • 25.
  • 26.
  • 27.
  • 28.
  • 29.

备注:其中every定义了mock对象mother的行为,当调用giveMoney时,返回值为30; verify用于校验kid对象的wantMoney是否被调用过。

参数匹配
every { 
    mockObj.someMethod(any()) 
} returns "Mocked Result"
  • 1.
  • 2.
  • 3.

备注:在定义mock对象行为时,可以进行参数匹配,此处使用了any()表明可以匹配任意的输入参数。

函数验证
verify { 
    mockObj.someMethod() 
}
  • 1.
  • 2.
  • 3.

备注:校验函数是否被调用过,前面的例子中已经写了。更高级的,还可以校验函数的调用次数、顺序、参数匹配等等。

verify(exactly = 10) { 
    mockObj.someMethod() 
}
  • 1.
  • 2.
  • 3.

备注:校验方法必须精确被调用10次

verify { 
    mockObj.firstMethod() 
    mockObj.secondMethod() 
}
  • 1.
  • 2.
  • 3.
  • 4.

备注:校验调用顺序,firstMethod必须在secondMethod之前进行调用,否则验证不通过

偏函数模拟
every { 
    mockObject.someMethod(any()) 
} answers { 
    originalCall(it.invocation.args.first()) 
}
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.

备注:对于某些方法调用,我们并不想完全使用模拟的值,而是想使用特定的函数调用过程,那么可以使用originalCall来实现对实际函数的调用。

构造函数
mockkConstructor(MyClass::class)
every { 
    anyConstructed<MyClass>().someMethod() 
} returns "Mocked Result"// 执行测试代码
unmockkConstructor(MyClass::class)
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.

备注:使用mockkConstructor方法mock构造函数,并通过anyConstructed进行类的构造,最后通过 unmockkConstructor取消构造函数的mock。

Lambada表达式
val lambdaMock: () -> Unit = mockk()
every { 
    lambdaMock.invoke() 
} just Runs
  • 1.
  • 2.
  • 3.
  • 4.
使用注解进行mock
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)
    }
}
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.
  • 12.
  • 13.
  • 14.
  • 15.
  • 16.
  • 17.
  • 18.
  • 19.
  • 20.
  • 21.
  • 22.
  • 23.

备注:使用@MockK可以mock并注入一个对象,同时需要在@Before初始化函数中调用注入方法MockKAnnotations.init(this)

所有方法跳过准备
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()
    }
}
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.
  • 12.
  • 13.
  • 14.
  • 15.
  • 16.
  • 17.
  • 18.
  • 19.
  • 20.
  • 21.

对于mock对象而言,其方法的调用都需要预设行为,否则会报错:io.mockk.MockKException: no answer found for: Car(car#1).getName()。如果我们不想对每个方法都预设,比如一个对象的方法实在太多了有上千个,那么我们可以使用以下三种方案,来取消对象的方法预设:

mock构造参数
@Test
fun getName() {
    val car = mock<Car>(relaxed = true)
}
  • 1.
  • 2.
  • 3.
  • 4.
mock注解指定
@RelaxedMockK
lateinit var car: Car
  • 1.
  • 2.
mock注解初始化
@MockK
lateinit var car: Car

@Before
fun setUp() {
    MockKAnnotations.init(this, relaxed = true)
}
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
Unit方法跳过准备

返回值为Unit类型的跳过校验,而非Unit的方法不跳过校验

@Test
fun getName() {
    val car = mock<Car>(relaxUnitFun = true)
}
  • 1.
  • 2.
  • 3.
  • 4.
抓取参数
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)
    }
}
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.
  • 12.
  • 13.
  • 14.
  • 15.
  • 16.
  • 17.
  • 18.
  • 19.
  • 20.
  • 21.
  • 22.
  • 23.
  • 24.
  • 25.
  • 26.

备注:首先在准备阶段创建了一个slot槽位,接着配合capture函数定义slot可以捕获到值。在执行阶段inform传入的参数,可以被slot捕获到,并存储在slot.captured的变量中。

静态方法
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())
    }
}
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.
  • 12.
  • 13.
  • 14.
  • 15.
  • 16.
  • 17.
  • 18.
  • 19.
  • 20.
  • 21.
  • 22.
  • 23.
  • 24.
  • 25.
  • 26.
  • 27.
  • 28.
  • 29.
  • 30.

备注:实际上Java的类的静态方法也可以模拟,不过咱这里在KMP环境中只针对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())
    }
}
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.
  • 12.
  • 13.
  • 14.
  • 15.
  • 16.
  • 17.
  • 18.
  • 19.
  • 20.
  • 21.
  • 22.
  • 23.
  • 24.
  • 25.
  • 26.
  • 27.
  • 28.
  • 29.
  • 30.
  • 31.
  • 32.
  • 33.

备注:模拟的如果是静态方法,那么参考13;模拟的如果是一个对象,那么使用mockObject即可

总结

MockK是一款功能强大、易于使用的Kotlin mocking框架,由于专门针对Kotlin进行设计,可以轻松的支持static方法、static类、final类mock。在Kotlin Multiplatform项目中,由于MockK不支持跨平台只支持JVM平台,因此需要将commonMain的测试代码,放置在可以运行于JVM虚拟机的源码集中。MockK的使用也比较简单,会使用Mokito的话很容易上手。下一篇将记录在KMP中的使用情况,以及自动构建需要如何配置。

最后

如果想要成为架构师或想突破20~30K薪资范畴,那就不要局限在编码,业务,要会选型、扩展,提升编程思维。此外,良好的职业规划也很重要,学习的习惯很重要,但是最重要的还是要能持之以恒,任何不能坚持落实的计划都是空谈。

如果你没有方向,这里给大家分享一套由阿里高级架构师编写的《Android八大模块进阶笔记》,帮大家将杂乱、零散、碎片化的知识进行体系化的整理,让大家系统而高效地掌握Android开发的各个知识点。

Kotlin Multiplatform单元测试-mockk工具篇_单元测试_02


相对于我们平时看的碎片化内容,这份笔记的知识点更系统化,更容易理解和记忆,是严格按照知识体系编排的。